@tatara/cli 0.3.0 → 0.5.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 (36) hide show
  1. package/dist/bin.mjs +311 -132
  2. package/package.json +1 -1
  3. package/templates/features/admin/files/src/modules/example/routes/admin-greeting.routes.ts +1 -1
  4. package/templates/features/auth/files/src/core/auth/routes/me.routes.ts +1 -1
  5. package/templates/features/auth-keycloak/files/src/core/auth/commands/keycloak-token.ts +2 -2
  6. package/templates/features/auth-keycloak/files/src/core/auth/keycloak.ts +7 -56
  7. package/templates/features/auth-keycloak/files/src/core/auth/tests/keycloak-tokens.test.ts +3 -3
  8. package/templates/features/auth-keycloak/files+auth-orgs/src/core/auth/tests/keycloak-organizations.test.ts +1 -1
  9. package/templates/features/authz/files/src/core/authz/db/seed.ts +1 -1
  10. package/templates/features/authz/files/src/core/authz/routes/authorization.routes.ts +1 -1
  11. package/templates/features/authz/files+drizzle/src/core/authz/db/store.ts +1 -1
  12. package/templates/features/authz/files+prisma/src/core/authz/db/store.ts +1 -1
  13. package/templates/features/base/files/src/core/http/app.ts +2 -8
  14. package/templates/features/base/files/tests/helpers/api.ts +3 -47
  15. package/templates/features/better-auth/files/src/core/auth/auth.ts +1 -1
  16. package/templates/features/better-auth/files/src/core/auth/db/seed.ts +1 -1
  17. package/templates/features/better-auth/files/src/core/auth/delivery.ts +6 -30
  18. package/templates/features/better-auth/files/src/core/auth/principal.ts +7 -38
  19. package/templates/features/better-auth/files/src/core/auth/sms.ts +3 -12
  20. package/templates/features/better-auth/files/tests/helpers/auth.ts +9 -12
  21. package/templates/features/db/files/tests/global-setup.ts +4 -16
  22. package/templates/features/db/files+mysql+drizzle/src/core/db/admin.ts +2 -53
  23. package/templates/features/db/files+mysql+prisma/src/core/db/admin.ts +7 -78
  24. package/templates/features/db/files+postgres/src/core/db/admin.ts +2 -50
  25. package/templates/features/drizzle/files+mysql/src/core/db/migrate.ts +1 -13
  26. package/templates/features/drizzle/files+postgres/src/core/db/migrate.ts +1 -13
  27. package/templates/features/prisma/files/src/core/db/migrate.ts +1 -13
  28. package/templates/generators/resource/files+drizzle/src/modules/__moduleKebab__/services/__nameKebab__.service.ts +1 -1
  29. package/templates/generators/resource/files+prisma/src/modules/__moduleKebab__/services/__nameKebab__.service.ts +1 -1
  30. package/templates/versions.json +5 -1
  31. package/templates/features/auth/files/src/core/auth/middleware.ts +0 -29
  32. package/templates/features/auth-keycloak/files/src/core/auth/keycloak-plugin.ts +0 -154
  33. package/templates/features/better-auth/files/src/core/auth/openapi.ts +0 -60
  34. package/templates/features/db/files/src/core/db/id.ts +0 -13
  35. package/templates/features/db/files/src/core/db/seed.ts +0 -14
  36. package/templates/features/db/files/tests/helpers/database-url.ts +0 -9
package/dist/bin.mjs CHANGED
@@ -286,7 +286,7 @@ export type PermissionKey =
286
286
  const seeds = await find(root, dirs.map((d) => `${d}/db/seed.ts`));
287
287
  await writeIfChanged(join(out, "seeds.ts"), listModule(seeds, {
288
288
  prefix: "seed",
289
- typeImport: "import type { SeedDefinition } from \"#core/db/seed\";",
289
+ typeImport: "import type { SeedDefinition } from \"@tatara/db\";",
290
290
  typeName: "SeedDefinition",
291
291
  exportName: "seeds"
292
292
  }), written);
@@ -837,7 +837,7 @@ const admin = {
837
837
  {
838
838
  file: "src/core/http/app.ts",
839
839
  slot: "imports",
840
- content: "import { requireRole } from \"#core/auth/middleware\";"
840
+ content: "import { requireRole } from \"@tatara/auth\";"
841
841
  },
842
842
  {
843
843
  file: "src/core/http/app.ts",
@@ -1057,8 +1057,8 @@ const i18n = {
1057
1057
  },
1058
1058
  {
1059
1059
  file: "src/core/http/app.ts",
1060
- slot: "error-message",
1061
- content: "if (err.translatable) message = t(c.get(\"language\"), `errors.${err.code}`, message);"
1060
+ slot: "error-options",
1061
+ content: ["message: (err, c) =>", " err.translatable ? t(c.get(\"language\"), `errors.${err.code}`, err.message) : err.message,"].join("\n")
1062
1062
  },
1063
1063
  {
1064
1064
  file: "AGENTS.md",
@@ -1077,6 +1077,7 @@ const auth = {
1077
1077
  id: "auth",
1078
1078
  kind: "auth",
1079
1079
  label: "Auth (shared)",
1080
+ dependencies: ["@tatara/auth"],
1080
1081
  slots: [
1081
1082
  {
1082
1083
  file: "src/core/http/app.ts",
@@ -1107,7 +1108,7 @@ const auth = {
1107
1108
 
1108
1109
  - \`c.var.principal\` is set for every request with valid credentials: \`{ userId, orgId, roles, kind }\`.
1109
1110
  - Protect routes with \`middleware: [requireAuth()]\` or \`requireRole("admin")\` from
1110
- \`#core/auth/middleware\`, and declare \`security: [{ bearerAuth: [] }]\` so the route is
1111
+ \`@tatara/auth\`, and declare \`security: [{ bearerAuth: [] }]\` so the route is
1111
1112
  documented as protected. Read the caller with \`currentPrincipal(c)\`.
1112
1113
  - In tests, \`asUser()\` from \`#tests/helpers/auth\` returns headers for a fresh authenticated user.
1113
1114
  `
@@ -1121,7 +1122,7 @@ const BETTER_AUTH_AGENTS = `### better-auth
1121
1122
  - Sessions work as cookies (browsers) and as \`Authorization: Bearer <session token>\` (mobile, APIs).
1122
1123
  - Outside production, every email and SMS is kept in an in-memory outbox:
1123
1124
  \`GET /api/dev/outbox?to=<address>\` returns the latest messages, including sign-in codes.
1124
- - To send real SMS, implement \`SmsSender\` in \`src/core/auth/sms.ts\`.
1125
+ - To send real SMS, implement \`SmsSender\` (from \`@tatara/auth\`) in \`src/core/auth/sms.ts\`.
1125
1126
  `;
1126
1127
  const drizzlePg = ["drizzle", "postgres"];
1127
1128
  const drizzleMy = ["drizzle", "mysql"];
@@ -1161,12 +1162,12 @@ const betterAuth = {
1161
1162
  {
1162
1163
  file: "src/core/http/app.ts",
1163
1164
  slot: "imports",
1164
- content: "import { outboxEnabled, readOutbox } from \"#core/auth/delivery\";"
1165
+ content: "import { outbox } from \"#core/auth/delivery\";"
1165
1166
  },
1166
1167
  {
1167
1168
  file: "src/core/http/app.ts",
1168
1169
  slot: "imports",
1169
- content: "import { mergeAuthOpenApi } from \"#core/auth/openapi\";"
1170
+ content: "import { mergeAuthOpenApi } from \"@tatara/auth\";"
1170
1171
  },
1171
1172
  {
1172
1173
  file: "src/core/http/app.ts",
@@ -1175,8 +1176,12 @@ const betterAuth = {
1175
1176
  },
1176
1177
  {
1177
1178
  file: "src/core/http/app.ts",
1178
- slot: "openapi",
1179
- content: "if (name === \"api\") await mergeAuthOpenApi(document, `${API_PREFIX}/auth`);"
1179
+ slot: "openapi-options",
1180
+ content: [
1181
+ "async extend(document) {",
1182
+ " if (name === \"api\") await mergeAuthOpenApi(document, auth, `${API_PREFIX}/auth`);",
1183
+ "},"
1184
+ ].join("\n")
1180
1185
  },
1181
1186
  {
1182
1187
  file: "src/core/http/app.ts",
@@ -1185,8 +1190,8 @@ const betterAuth = {
1185
1190
  content: [
1186
1191
  "if (name === \"api\") {",
1187
1192
  " app.on([\"GET\", \"POST\"], `${API_PREFIX}/auth/*`, (c) => auth.handler(c.req.raw));",
1188
- " if (outboxEnabled()) {",
1189
- " app.get(`${API_PREFIX}/dev/outbox`, (c) => c.json({ messages: readOutbox(c.req.query(\"to\")) }));",
1193
+ " if (outbox.enabled) {",
1194
+ " app.get(`${API_PREFIX}/dev/outbox`, (c) => c.json({ messages: outbox.read(c.req.query(\"to\")) }));",
1190
1195
  " }",
1191
1196
  "}"
1192
1197
  ].join("\n")
@@ -1464,64 +1469,11 @@ const authOrgs = {
1464
1469
  ...adminFields
1465
1470
  ]
1466
1471
  };
1467
- const KEYCLOAK_AGENTS = `### Keycloak
1468
-
1469
- - The API also accepts Keycloak access tokens (\`Authorization: Bearer <token>\`). The
1470
- \`keycloakTokens()\` better-auth plugin (\`src/core/auth/keycloak-plugin.ts\`) verifies signature,
1471
- issuer, audience \`__APP_NAME__-api\` and expiry; login happens in the frontend via OIDC (PKCE)
1472
- against the \`__APP_NAME__-web\` client.
1473
- - The token's subject is the user id. The user (and a \`keycloak\` account) is created in
1474
- better-auth's tables on first use; a token whose email already belongs to another account is
1475
- rejected. Service-account tokens become \`kind: "service"\` principals with no user row.
1476
- - Roles come from the token (realm roles, \`roles\`, and client roles for the API); a role named like
1477
- a built-in authz role grants it. The organization comes from the \`organization\` claim.
1478
- - Local dev realm: \`keycloak/realm.json\` (users alice/alice with admin, bob/bob).
1479
- \`__PM_EXEC__ tatara run keycloak:token --user alice\` prints a token for curl.
1480
- - Tests sign tokens with a local key: \`keycloakToken()\` from \`#tests/helpers/auth\`.
1481
- `;
1482
- const KEYCLOAK_TEST_HELPER = [
1483
- "const { publicKey, privateKey } = await generateKeyPair(\"RS256\");",
1484
- "setKeySource(async (header) => {",
1485
- " if (header.kid !== \"test\") throw new Error(\"unknown key\");",
1486
- " return publicKey;",
1487
- "});",
1488
- "",
1489
- "export interface KeycloakTokenOptions {",
1490
- " sub?: string;",
1491
- " email?: string;",
1492
- " emailVerified?: boolean;",
1493
- " roles?: string[];",
1494
- " organization?: string[] | Record<string, unknown>;",
1495
- " username?: string;",
1496
- " issuer?: string;",
1497
- " audience?: string;",
1498
- " expiresIn?: string | number;",
1499
- "}",
1500
- "",
1501
- "/** Signs an access token the API accepts, as if Keycloak issued it. */",
1502
- "export async function keycloakToken(options: KeycloakTokenOptions = {}): Promise<string> {",
1503
- " const sub = options.sub ?? newId();",
1504
- " return new SignJWT({",
1505
- " email: options.email ?? `user-${sub}@example.com`,",
1506
- " email_verified: options.emailVerified ?? true,",
1507
- " preferred_username: options.username ?? `user-${sub.slice(0, 8)}`,",
1508
- " roles: options.roles ?? [\"user\"],",
1509
- " ...(options.organization ? { organization: options.organization } : {}),",
1510
- " })",
1511
- " .setProtectedHeader({ alg: \"RS256\", kid: \"test\" })",
1512
- " .setSubject(sub)",
1513
- " .setIssuer(options.issuer ?? issuer)",
1514
- " .setAudience(options.audience ?? env.KEYCLOAK_AUDIENCE)",
1515
- " .setIssuedAt()",
1516
- " .setExpirationTime(options.expiresIn ?? \"5m\")",
1517
- " .sign(privateKey);",
1518
- "}"
1519
- ].join("\n");
1520
1472
  const authKeycloak = {
1521
1473
  id: "auth-keycloak",
1522
1474
  kind: "auth-method",
1523
1475
  label: "Keycloak tokens",
1524
- dependencies: ["jose"],
1476
+ dependencies: ["@tatara/keycloak", "jose"],
1525
1477
  env: [
1526
1478
  {
1527
1479
  key: "KEYCLOAK_PORT",
@@ -1570,53 +1522,56 @@ const authKeycloak = {
1570
1522
  {
1571
1523
  file: "src/core/auth/auth.ts",
1572
1524
  slot: "imports",
1573
- content: "import { keycloakTokens } from \"#core/auth/keycloak-plugin\";"
1525
+ content: "import { keycloakTokens } from \"@tatara/keycloak\";"
1526
+ },
1527
+ {
1528
+ file: "src/core/auth/auth.ts",
1529
+ slot: "imports",
1530
+ content: "import { keycloak } from \"#core/auth/keycloak\";"
1531
+ },
1532
+ {
1533
+ file: "src/core/auth/auth.ts",
1534
+ slot: "imports",
1535
+ content: "import { logger } from \"#core/logging/logger\";"
1574
1536
  },
1575
1537
  {
1576
1538
  file: "src/core/auth/auth.ts",
1577
1539
  slot: "plugins",
1578
1540
  unless: ["auth-orgs"],
1579
- content: "keycloakTokens(),",
1541
+ content: "keycloakTokens({ verifier: keycloak, logger }),",
1580
1542
  order: 800
1581
1543
  },
1582
1544
  {
1583
1545
  file: "src/core/auth/auth.ts",
1584
1546
  slot: "plugins",
1585
1547
  when: ["auth-orgs"],
1586
- content: "keycloakTokens({ organizations: true }),",
1548
+ content: "keycloakTokens({ verifier: keycloak, logger, organizations: true }),",
1587
1549
  order: 800
1588
1550
  },
1589
1551
  {
1590
1552
  file: "src/core/auth/principal.ts",
1591
- slot: "no-session",
1592
- content: [
1593
- "if (!session) {",
1594
- " const header = c.req.header(\"authorization\");",
1595
- " const token = header?.startsWith(\"Bearer \") ? header.slice(7) : undefined;",
1596
- " const principal = token ? await auth.api.verifyKeycloakToken({ body: { token } }) : null;",
1597
- " if (principal) c.set(\"principal\", principal);",
1598
- "}"
1599
- ].join("\n")
1553
+ slot: "resolvers",
1554
+ content: "(c) => keycloakPrincipal(auth, c.req.header(\"authorization\")),"
1600
1555
  },
1601
1556
  {
1602
- file: "tests/helpers/auth.ts",
1557
+ file: "src/core/auth/principal.ts",
1603
1558
  slot: "imports",
1604
- content: "import { generateKeyPair, SignJWT } from \"jose\";"
1559
+ content: "import { keycloakPrincipal } from \"@tatara/keycloak\";"
1605
1560
  },
1606
1561
  {
1607
1562
  file: "tests/helpers/auth.ts",
1608
1563
  slot: "imports",
1609
- content: "import { issuer, setKeySource } from \"#core/auth/keycloak\";"
1564
+ content: "import { useTestKeys } from \"@tatara/keycloak/testing\";"
1610
1565
  },
1611
1566
  {
1612
1567
  file: "tests/helpers/auth.ts",
1613
1568
  slot: "imports",
1614
- content: "import { env } from \"#core/config/env\";"
1569
+ content: "import { keycloak } from \"#core/auth/keycloak\";"
1615
1570
  },
1616
1571
  {
1617
1572
  file: "tests/helpers/auth.ts",
1618
1573
  slot: "helpers",
1619
- content: KEYCLOAK_TEST_HELPER
1574
+ content: ["/** Signs an access token the API accepts, as if Keycloak issued it. */", "export const keycloakToken = await useTestKeys(keycloak);"].join("\n")
1620
1575
  },
1621
1576
  {
1622
1577
  file: "tests/helpers/auth.ts",
@@ -1624,7 +1579,7 @@ const authKeycloak = {
1624
1579
  unless: ["auth-orgs"],
1625
1580
  content: [
1626
1581
  "if (options.roles) {",
1627
- " const sub = newId();",
1582
+ " const sub = crypto.randomUUID();",
1628
1583
  " const token = await keycloakToken({ sub, roles: options.roles, email: options.email });",
1629
1584
  " return { userId: sub, headers: { authorization: `Bearer ${token}` } };",
1630
1585
  "}"
@@ -1633,7 +1588,21 @@ const authKeycloak = {
1633
1588
  {
1634
1589
  file: "AGENTS.md",
1635
1590
  slot: "sections",
1636
- content: KEYCLOAK_AGENTS
1591
+ content: `### Keycloak
1592
+
1593
+ - The API also accepts Keycloak access tokens (\`Authorization: Bearer <token>\`). The
1594
+ \`keycloakTokens()\` better-auth plugin (\`@tatara/keycloak\`) verifies signature,
1595
+ issuer, audience \`__APP_NAME__-api\` and expiry; login happens in the frontend via OIDC (PKCE)
1596
+ against the \`__APP_NAME__-web\` client.
1597
+ - The token's subject is the user id. The user (and a \`keycloak\` account) is created in
1598
+ better-auth's tables on first use; a token whose email already belongs to another account is
1599
+ rejected. Service-account tokens become \`kind: "service"\` principals with no user row.
1600
+ - Roles come from the token (realm roles, \`roles\`, and client roles for the API); a role named like
1601
+ a built-in authz role grants it. The organization comes from the \`organization\` claim.
1602
+ - Local dev realm: \`keycloak/realm.json\` (users alice/alice with admin, bob/bob).
1603
+ \`__PM_EXEC__ tatara run keycloak:token --user alice\` prints a token for curl.
1604
+ - Tests sign tokens with a local key: \`keycloakToken()\` from \`#tests/helpers/auth\`.
1605
+ `
1637
1606
  }
1638
1607
  ]
1639
1608
  };
@@ -1655,6 +1624,7 @@ const base = {
1655
1624
  "pino"
1656
1625
  ],
1657
1626
  devDependencies: [
1627
+ "@tatara/testing",
1658
1628
  "@tatara/cli",
1659
1629
  "typescript",
1660
1630
  "@types/node",
@@ -1852,6 +1822,7 @@ const db$1 = {
1852
1822
  id: "db",
1853
1823
  kind: "database",
1854
1824
  label: "Database (shared)",
1825
+ dependencies: ["@tatara/db"],
1855
1826
  scripts: {
1856
1827
  "db:generate": "tatara db generate",
1857
1828
  "db:migrate": "tatara db migrate",
@@ -1874,7 +1845,7 @@ const db$1 = {
1874
1845
  file: "tests/setup.ts",
1875
1846
  slot: "setup",
1876
1847
  content: [
1877
- "import { workerDatabaseUrl } from \"#tests/helpers/database-url\";",
1848
+ "import { workerDatabaseUrl } from \"@tatara/testing\";",
1878
1849
  "",
1879
1850
  "// One database per worker, created by tests/global-setup.ts.",
1880
1851
  "process.env.DATABASE_URL = workerDatabaseUrl(process.env.DATABASE_URL ?? \"\", process.env.VITEST_POOL_ID ?? \"1\");"
@@ -2485,7 +2456,7 @@ function guardSlots(project, n) {
2485
2456
  for (const [route, action] of Object.entries(ACTIONS)) add(`guard-${route}`, `middleware: [requirePermission("${n.kebab}:${action}")] as const,\nsecurity: [{ bearerAuth: ["${n.kebab}:${action}"] }],`);
2486
2457
  add("auth-errors", "...errorResponses(401, 403),");
2487
2458
  } else if (project.has("auth")) {
2488
- add("imports", "import { requireAuth } from \"#core/auth/middleware\";");
2459
+ add("imports", "import { requireAuth } from \"@tatara/auth\";");
2489
2460
  for (const route of Object.keys(ACTIONS)) add(`guard-${route}`, "middleware: [requireAuth()] as const,\nsecurity: [{ bearerAuth: [] }],");
2490
2461
  add("auth-errors", "...errorResponses(401),");
2491
2462
  }
@@ -3215,10 +3186,151 @@ function createBaseline(project, deps) {
3215
3186
  } };
3216
3187
  }
3217
3188
  //#endregion
3189
+ //#region src/upgrade/codemods/auth-package.ts
3190
+ const AUTH = "@tatara/auth";
3191
+ const KEYCLOAK = "@tatara/keycloak";
3192
+ const APP$1 = "src/core/http/app.ts";
3193
+ const HELPERS = "tests/helpers/auth.ts";
3194
+ function groups(keycloak) {
3195
+ const all = [
3196
+ {
3197
+ name: "OpenAPI merge and dev outbox",
3198
+ regenerate: [APP$1, "src/core/auth/delivery.ts"],
3199
+ remove: ["src/core/auth/openapi.ts"],
3200
+ exports: [{ specifier: "#core/auth/openapi" }, {
3201
+ specifier: "#core/auth/delivery",
3202
+ names: [
3203
+ "outboxEnabled",
3204
+ "readOutbox",
3205
+ "OutboxMessage"
3206
+ ]
3207
+ }]
3208
+ },
3209
+ {
3210
+ name: "SMS sender",
3211
+ regenerate: ["src/core/auth/sms.ts"],
3212
+ remove: [],
3213
+ exports: [{
3214
+ specifier: "#core/auth/sms",
3215
+ names: ["SmsSender"]
3216
+ }]
3217
+ },
3218
+ {
3219
+ name: "principal middleware",
3220
+ regenerate: ["src/core/auth/principal.ts"],
3221
+ remove: [],
3222
+ exports: []
3223
+ }
3224
+ ];
3225
+ if (!keycloak) {
3226
+ all.push({
3227
+ name: "test helpers",
3228
+ regenerate: [HELPERS],
3229
+ remove: [],
3230
+ exports: []
3231
+ });
3232
+ return all;
3233
+ }
3234
+ all.push({
3235
+ name: "Keycloak",
3236
+ regenerate: [
3237
+ "src/core/auth/auth.ts",
3238
+ "src/core/auth/keycloak.ts",
3239
+ "src/core/auth/commands/keycloak-token.ts",
3240
+ "src/core/auth/tests/keycloak-tokens.test.ts",
3241
+ HELPERS
3242
+ ],
3243
+ remove: ["src/core/auth/keycloak-plugin.ts"],
3244
+ exports: [
3245
+ { specifier: "#core/auth/keycloak-plugin" },
3246
+ {
3247
+ specifier: "#core/auth/keycloak",
3248
+ names: [
3249
+ "issuer",
3250
+ "setKeySource",
3251
+ "verifyAccessToken",
3252
+ "rolesOf",
3253
+ "organizationOf",
3254
+ "KeycloakClaims"
3255
+ ]
3256
+ },
3257
+ {
3258
+ specifier: "#tests/helpers/auth",
3259
+ names: ["KeycloakTokenOptions"]
3260
+ }
3261
+ ]
3262
+ });
3263
+ return all;
3264
+ }
3265
+ async function read$2(ctx, path) {
3266
+ return ctx.read(path).catch(() => void 0);
3267
+ }
3268
+ const escapeRegExp$1 = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3269
+ /** True when `text` imports `entry` (or one of its gone names). */
3270
+ function usesExport(text, entry) {
3271
+ const from = new RegExp(`import\\s+(?:type\\s+)?([^;]*?)\\s+from\\s+["']${escapeRegExp$1(entry.specifier)}["']`, "g");
3272
+ const gone = entry.names;
3273
+ for (const match of text.matchAll(from)) {
3274
+ if (!gone) return true;
3275
+ const braces = (match[1] ?? "").match(/\{([^}]*)\}/);
3276
+ if (!braces) return true;
3277
+ if ((braces[1] ?? "").split(",").map((part) => part.trim().replace(/^type\s+/, "").split(/\s+as\s+/)[0]?.trim()).some((name) => name && gone.includes(name))) return true;
3278
+ }
3279
+ return false;
3280
+ }
3281
+ /** Why the group can't move, or [] when it can. */
3282
+ async function blockersOf(ctx, group, sources) {
3283
+ const blockers = [];
3284
+ for (const path of [...group.regenerate, ...group.remove]) {
3285
+ const text = await read$2(ctx, path);
3286
+ if (text === void 0) continue;
3287
+ if (!await ctx.baseline.isUnchanged(path, text)) blockers.push(`${path} is customised`);
3288
+ }
3289
+ const own = /* @__PURE__ */ new Set([...group.regenerate, ...group.remove]);
3290
+ for (const path of sources) {
3291
+ if (own.has(path)) continue;
3292
+ const text = await ctx.read(path);
3293
+ if (group.exports.some((entry) => usesExport(text, entry))) blockers.push(`${path} imports what moves`);
3294
+ }
3295
+ return blockers;
3296
+ }
3297
+ const authPackage = {
3298
+ version: "0.5.0",
3299
+ id: "auth-package",
3300
+ description: "Use @tatara/auth (guards, principal, OpenAPI merge, outbox) and @tatara/keycloak",
3301
+ async run(ctx) {
3302
+ const { config } = ctx.project;
3303
+ if (config.auth === "none") return;
3304
+ const keycloak = config.authMethods.includes("keycloak");
3305
+ await ctx.addDependency(AUTH, versionOf(AUTH));
3306
+ if (keycloak) await ctx.addDependency(KEYCLOAK, versionOf(KEYCLOAK));
3307
+ const sources = await glob(["src/**/*.ts", "tests/**/*.ts"], {
3308
+ cwd: ctx.project.root,
3309
+ ignore: ["src/generated/**"]
3310
+ });
3311
+ const ready = [];
3312
+ for (const group of groups(keycloak)) {
3313
+ const blockers = await blockersOf(ctx, group, sources);
3314
+ if (blockers.length === 0) ready.push(group);
3315
+ else ctx.report.notes.push(`${group.name} was not moved onto ${group.name === "Keycloak" ? KEYCLOAK : AUTH}: ${blockers.join("; ")}. Compare these files with a newly generated app and move them by hand.`);
3316
+ }
3317
+ for (const group of ready) {
3318
+ for (const path of group.regenerate) {
3319
+ if (await read$2(ctx, path) === void 0) continue;
3320
+ const next = await ctx.renderCurrent(path);
3321
+ if (next !== void 0) await ctx.write(path, next);
3322
+ }
3323
+ for (const path of group.remove) await ctx.removeIfUnchanged(path);
3324
+ }
3325
+ if (await ctx.removeIfUnchanged("src/core/auth/middleware.ts") === "kept") ctx.report.notes.push("src/core/auth/middleware.ts is customised; its imports still point at it.");
3326
+ else await ctx.rewriteImports("#core/auth/middleware", AUTH);
3327
+ }
3328
+ };
3329
+ //#endregion
3218
3330
  //#region src/upgrade/codemods/core-package.ts
3219
3331
  const CORE = "@tatara/core";
3220
3332
  /** Files @tatara/core replaces, with the import specifier apps used for each. */
3221
- const MOVED$1 = {
3333
+ const MOVED$2 = {
3222
3334
  "src/core/commands/define.ts": "#core/commands/define",
3223
3335
  "src/core/commands/runner.ts": "#core/commands/runner",
3224
3336
  "src/core/process/lifecycle.ts": "#core/process/lifecycle",
@@ -3253,8 +3365,8 @@ async function rewriteEnv(ctx) {
3253
3365
  }
3254
3366
  /** Only call sites of functions that now come from @tatara/core take the logger argument. */
3255
3367
  async function passLogger(ctx, gone) {
3256
- const runnerGone = gone.includes(MOVED$1["src/core/commands/runner.ts"]);
3257
- const lifecycleGone = gone.includes(MOVED$1["src/core/process/lifecycle.ts"]);
3368
+ const runnerGone = gone.includes(MOVED$2["src/core/commands/runner.ts"]);
3369
+ const lifecycleGone = gone.includes(MOVED$2["src/core/process/lifecycle.ts"]);
3258
3370
  const cli = await exists(ctx, CLI_ENTRY);
3259
3371
  if (cli) {
3260
3372
  let next = cli;
@@ -3307,7 +3419,7 @@ const corePackage = {
3307
3419
  async run(ctx) {
3308
3420
  await ctx.addDependency(CORE, versionOf(CORE));
3309
3421
  const gone = [];
3310
- for (const [path, specifier] of Object.entries(MOVED$1)) if (await ctx.removeIfUnchanged(path) === "kept") ctx.report.notes.push(`${path} is customised; its imports still point at it. Move the change into the app or eject.`);
3422
+ for (const [path, specifier] of Object.entries(MOVED$2)) if (await ctx.removeIfUnchanged(path) === "kept") ctx.report.notes.push(`${path} is customised; its imports still point at it. Move the change into the app or eject.`);
3311
3423
  else gone.push(specifier);
3312
3424
  for (const specifier of gone) await ctx.rewriteImports(specifier, CORE);
3313
3425
  await rewriteEnv(ctx);
@@ -3317,6 +3429,68 @@ const corePackage = {
3317
3429
  }
3318
3430
  };
3319
3431
  //#endregion
3432
+ //#region src/upgrade/codemods/db-testing.ts
3433
+ /** Copies replaced by a package export of the same name. */
3434
+ const MOVED$1 = [
3435
+ {
3436
+ path: "src/core/db/id.ts",
3437
+ specifier: "#core/db/id",
3438
+ to: "@tatara/db",
3439
+ needsDb: true
3440
+ },
3441
+ {
3442
+ path: "src/core/db/seed.ts",
3443
+ specifier: "#core/db/seed",
3444
+ to: "@tatara/db",
3445
+ needsDb: true
3446
+ },
3447
+ {
3448
+ path: "tests/helpers/database-url.ts",
3449
+ specifier: "#tests/helpers/database-url",
3450
+ to: "@tatara/testing",
3451
+ needsDb: false
3452
+ }
3453
+ ];
3454
+ /** App files that stay, but become what this CLI generates (driver re-exports, thin helpers). */
3455
+ const REGENERATED = [
3456
+ "src/core/db/admin.ts",
3457
+ "src/core/db/migrate.ts",
3458
+ "tests/helpers/api.ts",
3459
+ "tests/global-setup.ts"
3460
+ ];
3461
+ async function read$1(ctx, path) {
3462
+ return ctx.read(path).catch(() => void 0);
3463
+ }
3464
+ const dbTesting = {
3465
+ version: "0.4.0",
3466
+ id: "db-testing",
3467
+ description: "Use @tatara/db (ids, seeds, driver helpers, migrations) and @tatara/testing",
3468
+ async run(ctx) {
3469
+ const hasDb = ctx.project.config.database !== "none";
3470
+ if (hasDb) await ctx.addDependency("@tatara/db", versionOf("@tatara/db"));
3471
+ await ctx.addDependency("@tatara/testing", versionOf("@tatara/testing"), { dev: true });
3472
+ const regenerate = [];
3473
+ for (const path of REGENERATED) {
3474
+ const text = await read$1(ctx, path);
3475
+ if (text === void 0) continue;
3476
+ if (await ctx.baseline.isUnchanged(path, text)) regenerate.push(path);
3477
+ else ctx.report.notes.push(`${path} is customised and was kept; compare it with a newly generated app.`);
3478
+ }
3479
+ const moved = MOVED$1.filter((m) => hasDb || !m.needsDb);
3480
+ for (const path of regenerate) {
3481
+ const next = await ctx.renderCurrent(path);
3482
+ if (next !== void 0) await ctx.write(path, next);
3483
+ }
3484
+ for (const entry of moved) {
3485
+ if (await ctx.removeIfUnchanged(entry.path) === "kept") {
3486
+ ctx.report.notes.push(`${entry.path} is customised; its imports still point at it.`);
3487
+ continue;
3488
+ }
3489
+ await ctx.rewriteImports(entry.specifier, entry.to);
3490
+ }
3491
+ }
3492
+ };
3493
+ //#endregion
3320
3494
  //#region src/upgrade/codemods/http-package.ts
3321
3495
  const HTTP = "@tatara/http";
3322
3496
  /** Wiring files the app keeps; replaced by what this CLI generates when the app never changed them. */
@@ -3382,48 +3556,53 @@ async function outsideImporters(ctx) {
3382
3556
  //#endregion
3383
3557
  //#region src/upgrade/codemods.ts
3384
3558
  /** Every codemod tatara ships, oldest first. Each step that moves code into a package adds one. */
3385
- const CODEMODS = [corePackage, {
3386
- version: "0.3.0",
3387
- id: "http-package",
3388
- description: "Use @tatara/http for app setup, errors, the envelope, OpenAPI docs and the server",
3389
- async run(ctx) {
3390
- await ctx.addDependency(HTTP, versionOf(HTTP));
3391
- const blockers = [];
3392
- const replace = [];
3393
- for (const path of [
3394
- APP,
3395
- ROUTES,
3396
- ...SERVERS
3397
- ]) {
3398
- const text = await read(ctx, path);
3399
- if (text === void 0) continue;
3400
- if (await ctx.baseline.isUnchanged(path, text)) replace.push(path);
3401
- else blockers.push(`${path} is customised`);
3402
- }
3403
- const present = [];
3404
- for (const entry of MOVED) {
3405
- const text = await read(ctx, entry.path);
3406
- if (text === void 0) continue;
3407
- present.push(entry);
3408
- if (!await ctx.baseline.isUnchanged(entry.path, text)) blockers.push(`${entry.path} is customised`);
3409
- }
3410
- for (const path of await outsideImporters(ctx)) blockers.push(`${path} imports one of the moved files`);
3411
- if (blockers.length > 0) {
3412
- ctx.report.notes.push(`Nothing in src/core/http was changed: ${blockers.join("; ")}. Move these onto @tatara/http (createApp, mountRoutes, serveOpenApi, handleErrors, startServer and its error classes) by hand, then delete the old files.`);
3413
- return;
3414
- }
3415
- for (const path of replace) {
3416
- const next = await ctx.renderCurrent(path);
3417
- if (next !== void 0) await ctx.write(path, next);
3559
+ const CODEMODS = [
3560
+ corePackage,
3561
+ {
3562
+ version: "0.3.0",
3563
+ id: "http-package",
3564
+ description: "Use @tatara/http for app setup, errors, the envelope, OpenAPI docs and the server",
3565
+ async run(ctx) {
3566
+ await ctx.addDependency(HTTP, versionOf(HTTP));
3567
+ const blockers = [];
3568
+ const replace = [];
3569
+ for (const path of [
3570
+ APP,
3571
+ ROUTES,
3572
+ ...SERVERS
3573
+ ]) {
3574
+ const text = await read(ctx, path);
3575
+ if (text === void 0) continue;
3576
+ if (await ctx.baseline.isUnchanged(path, text)) replace.push(path);
3577
+ else blockers.push(`${path} is customised`);
3578
+ }
3579
+ const present = [];
3580
+ for (const entry of MOVED) {
3581
+ const text = await read(ctx, entry.path);
3582
+ if (text === void 0) continue;
3583
+ present.push(entry);
3584
+ if (!await ctx.baseline.isUnchanged(entry.path, text)) blockers.push(`${entry.path} is customised`);
3585
+ }
3586
+ for (const path of await outsideImporters(ctx)) blockers.push(`${path} imports one of the moved files`);
3587
+ if (blockers.length > 0) {
3588
+ 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.`);
3589
+ return;
3590
+ }
3591
+ for (const path of replace) {
3592
+ const next = await ctx.renderCurrent(path);
3593
+ if (next !== void 0) await ctx.write(path, next);
3594
+ }
3595
+ for (const entry of present) await ctx.removeIfUnchanged(entry.path);
3596
+ for (const specifier of [
3597
+ "#core/http/errors",
3598
+ "#core/http/response",
3599
+ "#core/http/middleware/validation"
3600
+ ]) await ctx.rewriteImports(specifier, HTTP);
3418
3601
  }
3419
- for (const entry of present) await ctx.removeIfUnchanged(entry.path);
3420
- for (const specifier of [
3421
- "#core/http/errors",
3422
- "#core/http/response",
3423
- "#core/http/middleware/validation"
3424
- ]) await ctx.rewriteImports(specifier, HTTP);
3425
- }
3426
- }];
3602
+ },
3603
+ dbTesting,
3604
+ authPackage
3605
+ ];
3427
3606
  //#endregion
3428
3607
  //#region src/upgrade/context.ts
3429
3608
  const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tatara/cli",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Create and grow opinionated Hono backends",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,5 +1,5 @@
1
1
  import { createRoute } from "@hono/zod-openapi";
2
- import { currentPrincipal } from "#core/auth/middleware";
2
+ import { currentPrincipal } from "@tatara/auth";
3
3
  import { errorResponses, jsonContent, success, successSchema } from "@tatara/http";
4
4
  import { createRouter, defineRoutes } from "#core/http/routes";
5
5
  import { greetingSchema } from "#modules/example/validators/greeting.validators";
@@ -1,5 +1,5 @@
1
1
  import { createRoute, z } from "@hono/zod-openapi";
2
- import { currentPrincipal, requireAuth } from "#core/auth/middleware";
2
+ import { currentPrincipal, requireAuth } from "@tatara/auth";
3
3
  import { errorResponses, jsonContent, success, successSchema } from "@tatara/http";
4
4
  import { createRouter, defineRoutes } from "#core/http/routes";
5
5
 
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { issuer } from "#core/auth/keycloak";
2
+ import { keycloak } from "#core/auth/keycloak";
3
3
  import { defineCommand } from "@tatara/core";
4
4
  import { env } from "#core/config/env";
5
5
 
@@ -13,7 +13,7 @@ export default defineCommand({
13
13
  }),
14
14
  async run({ user, password, client }) {
15
15
  if (env.NODE_ENV === "production") throw new Error("keycloak:token is for development only");
16
- const res = await fetch(`${issuer}/protocol/openid-connect/token`, {
16
+ const res = await fetch(`${keycloak.issuer}/protocol/openid-connect/token`, {
17
17
  method: "POST",
18
18
  headers: { "content-type": "application/x-www-form-urlencoded" },
19
19
  body: new URLSearchParams({