@tatara/cli 0.7.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";
@@ -190,6 +190,18 @@ function ignored(v, text) {
190
190
  return new RegExp(`tatara-check-ignore\\s+${v.rule}:\\s*\\S`).test(previous);
191
191
  }
192
192
  //#endregion
193
+ //#region src/project/ejected.ts
194
+ /** Packages the app ejected (`tatara eject`); their code lives in src/ejected/<name>. */
195
+ function ejectedPackages(config) {
196
+ return Object.keys(config.ejected ?? {});
197
+ }
198
+ /** Points `@tatara/<name>` and `@tatara/<name>/<entry>` imports at the app's ejected copy. */
199
+ function pointAtEjected(text, names) {
200
+ if (names.length === 0) return text;
201
+ const pattern = new RegExp(`(["'])@tatara/(${names.join("|")})(?:/([\\w-]+))?\\1`, "g");
202
+ return text.replace(pattern, (_match, quote, name, entry) => `${quote}#ejected/${name}/${entry ?? "index"}${quote}`);
203
+ }
204
+ //#endregion
193
205
  //#region src/commands/sync.ts
194
206
  var sync_exports = /* @__PURE__ */ __exportAll({
195
207
  specifier: () => specifier,
@@ -227,6 +239,55 @@ ${imports.join("\n")}${imports.length ? "\n" : ""}
227
239
  export const ${options.exportName}: ${options.typeName}[] = [${names.join(", ")}];
228
240
  `;
229
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
+ }
230
291
  async function writeIfChanged(path, content, written) {
231
292
  if ((existsSync(path) ? await readFile(path, "utf8") : void 0) !== content) {
232
293
  await writeFile(path, content);
@@ -235,19 +296,21 @@ async function writeIfChanged(path, content, written) {
235
296
  }
236
297
  async function sync(project = loadProject()) {
237
298
  const { root } = project;
299
+ const ejected = ejectedPackages(project.config);
300
+ const write = (path, content, out) => writeIfChanged(path, pointAtEjected(content, ejected), out);
238
301
  const out = join(root, "src/generated");
239
302
  await mkdir(out, { recursive: true });
240
303
  const written = [];
241
304
  const dirs = ["src/core/*", "src/modules/*"];
242
305
  const routes = await find(root, dirs.map((d) => `${d}/routes/*.routes.ts`));
243
- await writeIfChanged(join(out, "routes.ts"), listModule(routes, {
306
+ await write(join(out, "routes.ts"), listModule(routes, {
244
307
  prefix: "routes",
245
308
  typeImport: "import type { RouteDefinition } from \"#core/http/routes\";",
246
309
  typeName: "RouteDefinition",
247
310
  exportName: "routes"
248
311
  }), written);
249
312
  const commands = await find(root, dirs.map((d) => `${d}/commands/*.ts`));
250
- await writeIfChanged(join(out, "commands.ts"), listModule(commands, {
313
+ await write(join(out, "commands.ts"), listWithPackages(commands, await contributions(project, "commands"), {
251
314
  prefix: "command",
252
315
  typeImport: "import type { CommandDefinition } from \"@tatara/core\";",
253
316
  typeName: "CommandDefinition",
@@ -255,14 +318,14 @@ async function sync(project = loadProject()) {
255
318
  }), written);
256
319
  if (project.has("jobs")) {
257
320
  const consumers = await find(root, dirs.map((d) => `${d}/consumers/*.ts`));
258
- await writeIfChanged(join(out, "consumers.ts"), listModule(consumers, {
321
+ await write(join(out, "consumers.ts"), listModule(consumers, {
259
322
  prefix: "consumer",
260
323
  typeImport: "import type { ConsumerDefinition } from \"@tatara/jobs\";",
261
324
  typeName: "ConsumerDefinition",
262
325
  exportName: "consumers"
263
326
  }), written);
264
327
  const schedules = await find(root, dirs.map((d) => `${d}/schedules/*.ts`));
265
- await writeIfChanged(join(out, "schedules.ts"), listModule(schedules, {
328
+ await write(join(out, "schedules.ts"), listModule(schedules, {
266
329
  prefix: "schedule",
267
330
  typeImport: "import type { ScheduleDefinition } from \"@tatara/jobs\";",
268
331
  typeName: "ScheduleDefinition",
@@ -273,7 +336,7 @@ async function sync(project = loadProject()) {
273
336
  const permissions = await find(root, ["src/modules/*/permissions/*.permissions.ts"]);
274
337
  const imports = permissions.map((f) => `import ${identifier(f, "permissions")} from "${specifier(f)}";`);
275
338
  const names = permissions.map((f) => identifier(f, "permissions"));
276
- await writeIfChanged(join(out, "permissions.ts"), `${HEADER}import { authzPermissions, mergeCatalogs, type PermissionKeysOf } from "@tatara/authz";
339
+ await write(join(out, "permissions.ts"), `${HEADER}import { authzPermissions, mergeCatalogs, type PermissionKeysOf } from "@tatara/authz";
277
340
  ${imports.join("\n")}${imports.length ? "\n" : ""}
278
341
  export const catalog = mergeCatalogs(authzPermissions${names.map((n) => `, ${n}`).join("")});
279
342
 
@@ -283,7 +346,7 @@ export type PermissionKey =
283
346
  }
284
347
  if (project.config.database !== "none") {
285
348
  const seeds = await find(root, dirs.map((d) => `${d}/db/seed.ts`));
286
- await writeIfChanged(join(out, "seeds.ts"), listModule(seeds, {
349
+ await write(join(out, "seeds.ts"), listWithPackages(seeds, await contributions(project, "seeds"), {
287
350
  prefix: "seed",
288
351
  typeImport: "import type { SeedDefinition } from \"@tatara/db\";",
289
352
  typeName: "SeedDefinition",
@@ -292,7 +355,7 @@ export type PermissionKey =
292
355
  }
293
356
  if (project.config.orm === "drizzle") {
294
357
  const schemas = await find(root, dirs.map((d) => `${d}/db/*.schema.ts`));
295
- await writeIfChanged(join(out, "schema.ts"), `${HEADER}${schemas.map((f) => `export * from "${specifier(f)}";`).join("\n")}\nexport {};\n`, written);
358
+ await write(join(out, "schema.ts"), `${HEADER}${schemas.map((f) => `export * from "${specifier(f)}";`).join("\n")}\nexport {};\n`, written);
296
359
  }
297
360
  if (project.config.orm === "prisma") {
298
361
  const bin = join(root, "node_modules/.bin/prisma");
@@ -650,6 +713,125 @@ function printDoctor(checks) {
650
713
  }
651
714
  }
652
715
  //#endregion
716
+ //#region src/commands/eject.ts
717
+ var EjectError = class extends Error {};
718
+ const short = (name) => name.replace(/^@tatara\//, "");
719
+ const full = (name) => `@tatara/${short(name)}`;
720
+ const NOT_LIBRARIES = /* @__PURE__ */ new Set(["cli", "create-tatara"]);
721
+ async function readJson(path) {
722
+ return JSON.parse(await readFile(path, "utf8"));
723
+ }
724
+ /**
725
+ * Copies a package's source into src/ejected/<name>, points the app's imports at it and records
726
+ * it in tatara.json. From then on the app owns that code and `tatara upgrade` leaves it alone.
727
+ */
728
+ async function eject(project, name, opts = {}) {
729
+ const target = short(name);
730
+ if (NOT_LIBRARIES.has(target)) throw new EjectError(`${full(target)} is a tool, not a library`);
731
+ const appPkgPath = join(project.root, "package.json");
732
+ const appPkg = await readJson(appPkgPath);
733
+ const installed = Object.keys({
734
+ ...appPkg.dependencies,
735
+ ...appPkg.devDependencies
736
+ }).filter((dep) => dep.startsWith("@tatara/") && !NOT_LIBRARIES.has(short(dep))).map(short);
737
+ const already = ejectedPackages(project.config);
738
+ if (already.includes(target)) throw new EjectError(`${full(target)} is already ejected`);
739
+ if (!installed.includes(target)) throw new EjectError(`${full(target)} is not a dependency of this app`);
740
+ const manifests = /* @__PURE__ */ new Map();
741
+ for (const dep of installed) {
742
+ const path = join(project.root, "node_modules", full(dep), "package.json");
743
+ if (existsSync(path)) manifests.set(dep, await readJson(path));
744
+ }
745
+ const chain = [target];
746
+ for (let i = 0; i < chain.length; i++) for (const [dep, manifest] of manifests) {
747
+ if (chain.includes(dep)) continue;
748
+ if (full(chain[i]) in (manifest.peerDependencies ?? {})) chain.push(dep);
749
+ }
750
+ const dependents = chain.slice(1);
751
+ if (dependents.length > 0 && !opts.withDependents) throw new EjectError(`${dependents.map(full).join(", ")} depend on ${full(target)}. Eject them too with --with-dependents.`);
752
+ const report = {
753
+ ejected: chain,
754
+ copied: [],
755
+ rewritten: [],
756
+ added: []
757
+ };
758
+ const versions = {};
759
+ for (const dep of chain) {
760
+ const manifest = manifests.get(dep);
761
+ const source = join(project.root, "node_modules", full(dep), "src");
762
+ if (!manifest || !existsSync(source)) throw new EjectError(`${full(dep)} ships no source to eject; update it to a version that does, then run this again`);
763
+ versions[dep] = manifest.version;
764
+ const files = await glob(["**/*.ts"], {
765
+ cwd: source,
766
+ ignore: ["**/*.test.ts"]
767
+ });
768
+ for (const file of files) {
769
+ const to = `src/ejected/${dep}/${file}`;
770
+ await mkdir(dirname(join(project.root, to)), { recursive: true });
771
+ await cp(join(source, file), join(project.root, to));
772
+ report.copied.push(to);
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
+ }
778
+ }
779
+ const ejected = [...already, ...chain];
780
+ for (const file of await glob(["src/**/*.ts", "tests/**/*.ts"], { cwd: project.root })) {
781
+ const path = join(project.root, file);
782
+ const text = await readFile(path, "utf8");
783
+ const next = pointAtEjected(text, ejected);
784
+ if (next !== text) {
785
+ await writeFile(path, next);
786
+ if (!report.copied.includes(file)) report.rewritten.push(file);
787
+ }
788
+ }
789
+ const deps = { ...appPkg.dependencies };
790
+ const listed = new Set(Object.keys({
791
+ ...appPkg.dependencies,
792
+ ...appPkg.devDependencies
793
+ }));
794
+ for (const dep of chain) {
795
+ const manifest = manifests.get(dep);
796
+ for (const [peer, range] of Object.entries(manifest.peerDependencies ?? {})) {
797
+ if (listed.has(peer) || manifest.peerDependenciesMeta?.[peer]?.optional) continue;
798
+ if (peer.startsWith("@tatara/") && ejected.includes(short(peer))) continue;
799
+ deps[peer] = range;
800
+ listed.add(peer);
801
+ report.added.push(peer);
802
+ }
803
+ }
804
+ for (const dep of chain) delete deps[full(dep)];
805
+ const devDeps = { ...appPkg.devDependencies };
806
+ for (const dep of chain) delete devDeps[full(dep)];
807
+ const nextPkg = {
808
+ ...appPkg,
809
+ imports: {
810
+ ...appPkg.imports,
811
+ "#ejected/*": "./src/ejected/*.ts"
812
+ },
813
+ dependencies: Object.fromEntries(Object.entries(deps).sort(([a], [b]) => a.localeCompare(b))),
814
+ devDependencies: devDeps
815
+ };
816
+ await writeFile(appPkgPath, `${JSON.stringify(nextPkg, null, " ")}\n`);
817
+ const configPath = join(project.root, "tatara.json");
818
+ const config = await readJson(configPath);
819
+ await writeFile(configPath, `${JSON.stringify({
820
+ ...config,
821
+ ejected: {
822
+ ...config.ejected,
823
+ ...versions
824
+ }
825
+ }, null, " ")}\n`);
826
+ if (opts.formatFile) for (const file of [
827
+ ...report.copied,
828
+ ...report.rewritten,
829
+ "package.json",
830
+ "tatara.json"
831
+ ]) await opts.formatFile(file, project);
832
+ return report;
833
+ }
834
+ //#endregion
653
835
  //#region src/paths.ts
654
836
  /** Package root: one level above both src/ (dev) and dist/ (published, bundled). */
655
837
  const PACKAGE_ROOT = resolve(import.meta.dirname, "..");
@@ -2254,6 +2436,10 @@ function pnpmWorkspace(features) {
2254
2436
  lines.push("# tatara's own packages are installed as soon as they are released", "minimumReleaseAgeExclude:", " - \"@tatara/*\"");
2255
2437
  return `${lines.join("\n")}\n`;
2256
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
+ }
2257
2443
  /** Folds applicable variants into each feature so later steps see one flat definition. */
2258
2444
  function effectiveFeatures(options) {
2259
2445
  const features = resolveFeatures(options);
@@ -2277,6 +2463,9 @@ async function renderApp(options, generate = {}) {
2277
2463
  const selected = new Set(features.map((f) => f.id));
2278
2464
  const tokenValues = tokens(options);
2279
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
+ }
2280
2469
  const env = dedupeEnv(features, tokenValues);
2281
2470
  files.set("src/core/config/env.ts", envModule(env.filter((v) => !v.composeOnly)));
2282
2471
  files.set(".env.example", envFile(env, "example"));
@@ -2580,7 +2769,7 @@ async function generate(project, request) {
2580
2769
  for (const [rel, text] of rendered) {
2581
2770
  const full = join(project.root, rel);
2582
2771
  await mkdir(dirname(full), { recursive: true });
2583
- await writeFile(full, text);
2772
+ await writeFile(full, pointAtEjected(text, ejectedPackages(project.config)));
2584
2773
  }
2585
2774
  if (!request.skipPostProcess) await postProcess(project, files);
2586
2775
  return {
@@ -2869,7 +3058,10 @@ async function newApp(dir, rawFlags) {
2869
3058
  const name = root.split(/[\\/]/).at(-1) ?? "app";
2870
3059
  const options = validateOptions(interactive ? await prompt(name, flags) : fromFlags(name, flags));
2871
3060
  const pm = pmCommands(options.packageManager);
2872
- 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) });
2873
3065
  const project = loadProject(root);
2874
3066
  await afterCreate(project, "files");
2875
3067
  if (flags.install !== false) {
@@ -3255,10 +3447,7 @@ async function blockersOf$1(ctx, group, sources) {
3255
3447
  return blockers;
3256
3448
  }
3257
3449
  async function applyGroup(ctx, group) {
3258
- for (const path of group.regenerate) {
3259
- const next = await ctx.renderCurrent(path);
3260
- if (next !== void 0) await ctx.write(path, next);
3261
- }
3450
+ for (const path of group.regenerate) await ctx.regenerate(path);
3262
3451
  for (const path of group.remove) await ctx.removeIfUnchanged(path);
3263
3452
  for (const rewrite of group.rewrites) await ctx.rewriteImports(rewrite.from, rewrite.to);
3264
3453
  }
@@ -3504,8 +3693,7 @@ const authPackage = {
3504
3693
  for (const group of ready) {
3505
3694
  for (const path of group.regenerate) {
3506
3695
  if (await read$3(ctx, path) === void 0) continue;
3507
- const next = await ctx.renderCurrent(path);
3508
- if (next !== void 0) await ctx.write(path, next);
3696
+ await ctx.regenerate(path);
3509
3697
  }
3510
3698
  for (const path of group.remove) await ctx.removeIfUnchanged(path);
3511
3699
  }
@@ -3650,15 +3838,43 @@ const authzPackage = {
3650
3838
  ctx.report.blocked.push("authz-package");
3651
3839
  return;
3652
3840
  }
3653
- for (const path of regenerate) {
3654
- const next = await ctx.renderCurrent(path);
3655
- if (next !== void 0) await ctx.write(path, next);
3656
- }
3841
+ for (const path of regenerate) await ctx.regenerate(path);
3657
3842
  for (const path of removed) await ctx.removeIfUnchanged(path);
3658
3843
  for (const move of MOVES) await ctx.rewriteImports(move.from, move.to);
3659
3844
  }
3660
3845
  };
3661
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
3662
3878
  //#region src/upgrade/codemods/core-package.ts
3663
3879
  const CORE = "@tatara/core";
3664
3880
  /** Files @tatara/core replaces, with the import specifier apps used for each. */
@@ -3823,6 +4039,20 @@ const dbTesting = {
3823
4039
  }
3824
4040
  };
3825
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
3826
4056
  //#region src/upgrade/codemods/http-package.ts
3827
4057
  const HTTP = "@tatara/http";
3828
4058
  /** Wiring files the app keeps; replaced by what this CLI generates when the app never changed them. */
@@ -3885,58 +4115,94 @@ async function outsideImporters(ctx) {
3885
4115
  }
3886
4116
  return out;
3887
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$/, "")}`;
3888
4173
  //#endregion
3889
4174
  //#region src/upgrade/codemods.ts
3890
4175
  /** Every codemod tatara ships, oldest first. Each step that moves code into a package adds one. */
3891
4176
  const CODEMODS = [
3892
4177
  corePackage,
4178
+ httpPackage,
4179
+ dbTesting,
4180
+ authPackage,
4181
+ authzPackage,
4182
+ addonPackages,
4183
+ frameworkTests,
4184
+ authzStores,
3893
4185
  {
3894
- version: "0.3.0",
3895
- id: "http-package",
3896
- 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",
3897
4189
  async run(ctx) {
3898
- await ctx.addDependency(HTTP, versionOf(HTTP));
3899
- const blockers = [];
3900
- const replace = [];
3901
- for (const path of [
3902
- APP,
3903
- ROUTES,
3904
- ...SERVERS
3905
- ]) {
3906
- const text = await read(ctx, path);
3907
- if (text === void 0) continue;
3908
- if (await ctx.baseline.isUnchanged(path, text)) replace.push(path);
3909
- else blockers.push(`${path} is customised`);
3910
- }
3911
- const present = [];
3912
- for (const entry of MOVED) {
3913
- const text = await read(ctx, entry.path);
3914
- if (text === void 0) continue;
3915
- present.push(entry);
3916
- if (!await ctx.baseline.isUnchanged(entry.path, text)) blockers.push(`${entry.path} is customised`);
3917
- }
3918
- for (const path of await outsideImporters(ctx)) blockers.push(`${path} imports one of the moved files`);
3919
- if (blockers.length > 0) {
3920
- 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.`);
3921
- ctx.report.blocked.push("http-package");
3922
- return;
3923
- }
3924
- for (const path of replace) {
3925
- const next = await ctx.renderCurrent(path);
3926
- 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);
3927
4203
  }
3928
- for (const entry of present) await ctx.removeIfUnchanged(entry.path);
3929
- for (const specifier of [
3930
- "#core/http/errors",
3931
- "#core/http/response",
3932
- "#core/http/middleware/validation"
3933
- ]) await ctx.rewriteImports(specifier, HTTP);
3934
4204
  }
3935
- },
3936
- dbTesting,
3937
- authPackage,
3938
- authzPackage,
3939
- addonPackages
4205
+ }
3940
4206
  ];
3941
4207
  //#endregion
3942
4208
  //#region src/upgrade/context.ts
@@ -3953,7 +4219,9 @@ function createContext(project, baseline, opts = {}) {
3953
4219
  };
3954
4220
  const abs = (path) => join(project.root, path);
3955
4221
  let rendered;
3956
- const write = async (path, text) => {
4222
+ const ejected = ejectedPackages(project.config);
4223
+ const write = async (path, written) => {
4224
+ const text = path.endsWith(".ts") ? pointAtEjected(written, ejected) : written;
3957
4225
  if (!report.written.includes(path)) {
3958
4226
  report.written.push(path);
3959
4227
  const before = existsSync(abs(path)) ? await readFile(abs(path), "utf8") : void 0;
@@ -3963,7 +4231,7 @@ function createContext(project, baseline, opts = {}) {
3963
4231
  await mkdir(dirname(abs(path)), { recursive: true });
3964
4232
  await writeFile(abs(path), text);
3965
4233
  };
3966
- return {
4234
+ const ctx = {
3967
4235
  project,
3968
4236
  baseline,
3969
4237
  report,
@@ -4013,7 +4281,30 @@ function createContext(project, baseline, opts = {}) {
4013
4281
  });
4014
4282
  return (await rendered).get(path);
4015
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
+ },
4016
4302
  async addDependency(name, range, { dev = false } = {}) {
4303
+ if (ejected.includes(name.replace(/^@tatara\//, ""))) {
4304
+ const note = `${name} is ejected; it was not added (its code is in src/ejected)`;
4305
+ if (!report.notes.includes(note)) report.notes.push(note);
4306
+ return;
4307
+ }
4017
4308
  const pkg = JSON.parse(await readFile(abs("package.json"), "utf8"));
4018
4309
  const key = dev ? "devDependencies" : "dependencies";
4019
4310
  pkg[key] = {
@@ -4023,9 +4314,33 @@ function createContext(project, baseline, opts = {}) {
4023
4314
  await write("package.json", `${JSON.stringify(pkg, null, " ")}\n`);
4024
4315
  }
4025
4316
  };
4317
+ return ctx;
4026
4318
  }
4027
4319
  //#endregion
4028
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
+ }
4029
4344
  async function biomeFormat(file, project) {
4030
4345
  const biome = join(project.root, "node_modules", ".bin", "biome");
4031
4346
  if (!existsSync(biome)) return;
@@ -4072,9 +4387,11 @@ async function upgrade(project, opts = {}) {
4072
4387
  ctx.report.ran.push(mod.id);
4073
4388
  if (ctx.report.blocked.length > blockedBefore && reached === target) reached = due.slice(0, due.indexOf(mod)).at(-1)?.version ?? from;
4074
4389
  }
4390
+ await bumpPackages(ctx);
4075
4391
  if (opts.dryRun) return ctx.report;
4076
4392
  const format = opts.formatFile ?? biomeFormat;
4077
4393
  for (const path of ctx.report.written) await format(path, project);
4394
+ await (opts.runInstall ?? pmInstall)(project);
4078
4395
  await (opts.runSync ?? sync)(project);
4079
4396
  const path = join(project.root, "tatara.json");
4080
4397
  const config = JSON.parse(await readFile(path, "utf8"));
@@ -4107,7 +4424,7 @@ function printUpgrade(report, from, to) {
4107
4424
  //#endregion
4108
4425
  //#region src/bin.ts
4109
4426
  const program = new Command("tatara").description("Create and grow opinionated Hono backends").version(cliVersion());
4110
- 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) => {
4111
4428
  await newApp(dir, flags);
4112
4429
  });
4113
4430
  program.command("sync").description("Regenerate src/generated from discovered module files").option("--quiet", "no output").action(async (flags) => {
@@ -4182,6 +4499,17 @@ program.command("upgrade").description("Run this CLI version's codemods on the a
4182
4499
  printUpgrade(await upgrade(project, { dryRun: flags.dryRun }), from, cliVersion());
4183
4500
  if (!flags.dryRun && flags.verify !== false && !await verify(loadProject())) process.exitCode = 1;
4184
4501
  });
4502
+ program.command("eject <package>").description("Copy a @tatara/* package's source into src/ejected; the app owns it from then on").option("--with-dependents", "also eject installed @tatara/* packages that depend on it").action(async (name, flags) => {
4503
+ const report = await eject(loadProject(), name, {
4504
+ withDependents: flags.withDependents,
4505
+ formatFile: biomeFormat
4506
+ });
4507
+ const names = report.ejected.map((n) => `@tatara/${n}`).join(", ");
4508
+ console.log(pc.green(`ejected ${names} into src/ejected (${report.copied.length} files)`));
4509
+ if (report.rewritten.length > 0) console.log(` ${report.rewritten.length} files now import the ejected copy`);
4510
+ if (report.added.length > 0) console.log(` added dependencies: ${report.added.join(", ")}`);
4511
+ console.log(` run your package manager's install, then verify; tatara upgrade skips ${names} from now on`);
4512
+ });
4185
4513
  program.command("doctor").description("Check the toolchain, services, env and layout").option("--json", "machine-readable output").action(async (flags) => {
4186
4514
  const checks = await doctor(loadProject());
4187
4515
  if (flags.json) console.log(JSON.stringify(checks, null, 2));
@@ -4189,7 +4517,7 @@ program.command("doctor").description("Check the toolchain, services, env and la
4189
4517
  if (checks.some((c) => !c.ok)) process.exitCode = 1;
4190
4518
  });
4191
4519
  program.parseAsync().catch((error) => {
4192
- if (error instanceof OptionsError || error instanceof NotATataraProject || error instanceof GeneratorError || error instanceof DbError) console.error(pc.red(error.message));
4520
+ if (error instanceof OptionsError || error instanceof EjectError || error instanceof NotATataraProject || error instanceof GeneratorError || error instanceof DbError) console.error(pc.red(error.message));
4193
4521
  else console.error(error);
4194
4522
  process.exit(1);
4195
4523
  });