@tatara/cli 0.4.0 → 0.6.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 (42) hide show
  1. package/dist/bin.mjs +377 -94
  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 +2 -2
  8. package/templates/features/auth-keycloak/files+authz/src/core/auth/tests/keycloak-authz.test.ts +2 -2
  9. package/templates/features/authz/files/src/core/authz/commands/grant-role.ts +5 -5
  10. package/templates/features/authz/files/src/core/authz/commands/sync-permissions.ts +2 -2
  11. package/templates/features/authz/files/src/core/authz/db/seed.ts +2 -2
  12. package/templates/features/authz/files/src/core/authz/index.ts +9 -0
  13. package/templates/features/authz/files/src/core/authz/routes/authorization.routes.ts +9 -280
  14. package/templates/features/authz/files/src/core/authz/system-roles.ts +0 -2
  15. package/templates/features/authz/files/src/core/authz/tests/authz.test.ts +1 -2
  16. package/templates/features/authz/files/tests/helpers/authz.ts +6 -8
  17. package/templates/features/authz/files+drizzle/src/core/authz/db/store.ts +3 -3
  18. package/templates/features/authz/files+prisma/src/core/authz/db/store.ts +3 -3
  19. package/templates/features/better-auth/files/src/core/auth/delivery.ts +6 -30
  20. package/templates/features/better-auth/files/src/core/auth/principal.ts +7 -38
  21. package/templates/features/better-auth/files/src/core/auth/sms.ts +3 -12
  22. package/templates/features/better-auth/files/tests/helpers/auth.ts +9 -12
  23. package/templates/generators/permission/files/src/modules/__moduleKebab__/permissions/__nameKebab__.permissions.ts +1 -1
  24. package/templates/generators/resource/files+authz/src/modules/__moduleKebab__/permissions/__nameKebab__.permissions.ts +1 -1
  25. package/templates/generators/route/files+authz/src/modules/__moduleKebab__/permissions/__nameKebab__.permissions.ts +1 -1
  26. package/templates/versions.json +4 -1
  27. package/templates/features/auth/files/src/core/auth/middleware.ts +0 -29
  28. package/templates/features/auth-keycloak/files/src/core/auth/keycloak-plugin.ts +0 -154
  29. package/templates/features/authz/files/src/core/authz/cache.ts +0 -31
  30. package/templates/features/authz/files/src/core/authz/catalog.ts +0 -85
  31. package/templates/features/authz/files/src/core/authz/constants.ts +0 -32
  32. package/templates/features/authz/files/src/core/authz/middleware.ts +0 -21
  33. package/templates/features/authz/files/src/core/authz/permissions.ts +0 -31
  34. package/templates/features/authz/files/src/core/authz/resolver.ts +0 -98
  35. package/templates/features/authz/files/src/core/authz/services/audit.ts +0 -24
  36. package/templates/features/authz/files/src/core/authz/services/grants.ts +0 -29
  37. package/templates/features/authz/files/src/core/authz/services/members.ts +0 -84
  38. package/templates/features/authz/files/src/core/authz/services/roles.ts +0 -73
  39. package/templates/features/authz/files/src/core/authz/services/sync.ts +0 -34
  40. package/templates/features/authz/files/src/core/authz/types.ts +0 -87
  41. package/templates/features/authz/files/src/core/authz/validators.ts +0 -99
  42. package/templates/features/better-auth/files/src/core/auth/openapi.ts +0 -60
package/dist/bin.mjs CHANGED
@@ -273,13 +273,12 @@ async function sync(project = loadProject()) {
273
273
  const permissions = await find(root, ["src/modules/*/permissions/*.permissions.ts"]);
274
274
  const imports = permissions.map((f) => `import ${identifier(f, "permissions")} from "${specifier(f)}";`);
275
275
  const names = permissions.map((f) => identifier(f, "permissions"));
276
- await writeIfChanged(join(out, "permissions.ts"), `${HEADER}import { mergeCatalogs, type PermissionKeysOf } from "#core/authz/catalog";
277
- import corePermissions from "#core/authz/permissions";
276
+ await writeIfChanged(join(out, "permissions.ts"), `${HEADER}import { authzPermissions, mergeCatalogs, type PermissionKeysOf } from "@tatara/authz";
278
277
  ${imports.join("\n")}${imports.length ? "\n" : ""}
279
- export const catalog = mergeCatalogs(corePermissions${names.map((n) => `, ${n}`).join("")});
278
+ export const catalog = mergeCatalogs(authzPermissions${names.map((n) => `, ${n}`).join("")});
280
279
 
281
280
  export type PermissionKey =
282
- | PermissionKeysOf<typeof corePermissions>${names.map((n) => `\n\t| PermissionKeysOf<typeof ${n}>`).join("")};
281
+ | PermissionKeysOf<typeof authzPermissions>${names.map((n) => `\n\t| PermissionKeysOf<typeof ${n}>`).join("")};
283
282
  `, written);
284
283
  }
285
284
  if (project.config.database !== "none") {
@@ -682,6 +681,7 @@ const authz = {
682
681
  id: "authz",
683
682
  kind: "addon",
684
683
  label: "Permission system",
684
+ dependencies: ["@tatara/authz"],
685
685
  slots: [
686
686
  {
687
687
  file: "tests/helpers/auth.ts",
@@ -711,9 +711,9 @@ const authz = {
711
711
 
712
712
  - Permissions are \`resource:action\` (optionally scoped \`own | team | org\`, where a wider scope
713
713
  satisfies a narrower one). Each module declares its resources and the grants built-in roles get
714
- in \`permissions/<name>.permissions.ts\` (\`definePermissions\`); \`tatara sync\` merges them into a
714
+ in \`permissions/<name>.permissions.ts\` (\`definePermissions\` from \`@tatara/authz\`); \`tatara sync\` merges them into a
715
715
  typed catalog.
716
- - Guard routes with \`middleware: [requirePermission("project:read")]\` from \`#core/authz/middleware\`
716
+ - Guard routes with \`middleware: [requirePermission("project:read")]\` from \`#core/authz/index\`
717
717
  and declare \`security: [{ bearerAuth: ["project:read"] }]\`. For scoped actions, call
718
718
  \`resolveScope(principal, "doc:read")\` in the service and filter rows by the returned scope.
719
719
  - Effective permissions = role grants (assigned roles + built-in roles whose key matches an
@@ -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",
@@ -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",
@@ -1178,7 +1179,7 @@ const betterAuth = {
1178
1179
  slot: "openapi-options",
1179
1180
  content: [
1180
1181
  "async extend(document) {",
1181
- " if (name === \"api\") await mergeAuthOpenApi(document, `${API_PREFIX}/auth`);",
1182
+ " if (name === \"api\") await mergeAuthOpenApi(document, auth, `${API_PREFIX}/auth`);",
1182
1183
  "},"
1183
1184
  ].join("\n")
1184
1185
  },
@@ -1189,8 +1190,8 @@ const betterAuth = {
1189
1190
  content: [
1190
1191
  "if (name === \"api\") {",
1191
1192
  " app.on([\"GET\", \"POST\"], `${API_PREFIX}/auth/*`, (c) => auth.handler(c.req.raw));",
1192
- " if (outboxEnabled()) {",
1193
- " 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\")) }));",
1194
1195
  " }",
1195
1196
  "}"
1196
1197
  ].join("\n")
@@ -1453,7 +1454,7 @@ const authOrgs = {
1453
1454
  file: "src/core/auth/auth.ts",
1454
1455
  slot: "imports",
1455
1456
  when: ["authz"],
1456
- content: "import { removeMemberAccess } from \"#core/authz/services/members\";"
1457
+ content: "import { removeMemberAccess } from \"#core/authz/index\";"
1457
1458
  },
1458
1459
  {
1459
1460
  file: "tests/helpers/auth.ts",
@@ -1468,64 +1469,11 @@ const authOrgs = {
1468
1469
  ...adminFields
1469
1470
  ]
1470
1471
  };
1471
- const KEYCLOAK_AGENTS = `### Keycloak
1472
-
1473
- - The API also accepts Keycloak access tokens (\`Authorization: Bearer <token>\`). The
1474
- \`keycloakTokens()\` better-auth plugin (\`src/core/auth/keycloak-plugin.ts\`) verifies signature,
1475
- issuer, audience \`__APP_NAME__-api\` and expiry; login happens in the frontend via OIDC (PKCE)
1476
- against the \`__APP_NAME__-web\` client.
1477
- - The token's subject is the user id. The user (and a \`keycloak\` account) is created in
1478
- better-auth's tables on first use; a token whose email already belongs to another account is
1479
- rejected. Service-account tokens become \`kind: "service"\` principals with no user row.
1480
- - Roles come from the token (realm roles, \`roles\`, and client roles for the API); a role named like
1481
- a built-in authz role grants it. The organization comes from the \`organization\` claim.
1482
- - Local dev realm: \`keycloak/realm.json\` (users alice/alice with admin, bob/bob).
1483
- \`__PM_EXEC__ tatara run keycloak:token --user alice\` prints a token for curl.
1484
- - Tests sign tokens with a local key: \`keycloakToken()\` from \`#tests/helpers/auth\`.
1485
- `;
1486
- const KEYCLOAK_TEST_HELPER = [
1487
- "const { publicKey, privateKey } = await generateKeyPair(\"RS256\");",
1488
- "setKeySource(async (header) => {",
1489
- " if (header.kid !== \"test\") throw new Error(\"unknown key\");",
1490
- " return publicKey;",
1491
- "});",
1492
- "",
1493
- "export interface KeycloakTokenOptions {",
1494
- " sub?: string;",
1495
- " email?: string;",
1496
- " emailVerified?: boolean;",
1497
- " roles?: string[];",
1498
- " organization?: string[] | Record<string, unknown>;",
1499
- " username?: string;",
1500
- " issuer?: string;",
1501
- " audience?: string;",
1502
- " expiresIn?: string | number;",
1503
- "}",
1504
- "",
1505
- "/** Signs an access token the API accepts, as if Keycloak issued it. */",
1506
- "export async function keycloakToken(options: KeycloakTokenOptions = {}): Promise<string> {",
1507
- " const sub = options.sub ?? newId();",
1508
- " return new SignJWT({",
1509
- " email: options.email ?? `user-${sub}@example.com`,",
1510
- " email_verified: options.emailVerified ?? true,",
1511
- " preferred_username: options.username ?? `user-${sub.slice(0, 8)}`,",
1512
- " roles: options.roles ?? [\"user\"],",
1513
- " ...(options.organization ? { organization: options.organization } : {}),",
1514
- " })",
1515
- " .setProtectedHeader({ alg: \"RS256\", kid: \"test\" })",
1516
- " .setSubject(sub)",
1517
- " .setIssuer(options.issuer ?? issuer)",
1518
- " .setAudience(options.audience ?? env.KEYCLOAK_AUDIENCE)",
1519
- " .setIssuedAt()",
1520
- " .setExpirationTime(options.expiresIn ?? \"5m\")",
1521
- " .sign(privateKey);",
1522
- "}"
1523
- ].join("\n");
1524
1472
  const authKeycloak = {
1525
1473
  id: "auth-keycloak",
1526
1474
  kind: "auth-method",
1527
1475
  label: "Keycloak tokens",
1528
- dependencies: ["jose"],
1476
+ dependencies: ["@tatara/keycloak", "jose"],
1529
1477
  env: [
1530
1478
  {
1531
1479
  key: "KEYCLOAK_PORT",
@@ -1574,53 +1522,56 @@ const authKeycloak = {
1574
1522
  {
1575
1523
  file: "src/core/auth/auth.ts",
1576
1524
  slot: "imports",
1577
- 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\";"
1578
1536
  },
1579
1537
  {
1580
1538
  file: "src/core/auth/auth.ts",
1581
1539
  slot: "plugins",
1582
1540
  unless: ["auth-orgs"],
1583
- content: "keycloakTokens(),",
1541
+ content: "keycloakTokens({ verifier: keycloak, logger }),",
1584
1542
  order: 800
1585
1543
  },
1586
1544
  {
1587
1545
  file: "src/core/auth/auth.ts",
1588
1546
  slot: "plugins",
1589
1547
  when: ["auth-orgs"],
1590
- content: "keycloakTokens({ organizations: true }),",
1548
+ content: "keycloakTokens({ verifier: keycloak, logger, organizations: true }),",
1591
1549
  order: 800
1592
1550
  },
1593
1551
  {
1594
1552
  file: "src/core/auth/principal.ts",
1595
- slot: "no-session",
1596
- content: [
1597
- "if (!session) {",
1598
- " const header = c.req.header(\"authorization\");",
1599
- " const token = header?.startsWith(\"Bearer \") ? header.slice(7) : undefined;",
1600
- " const principal = token ? await auth.api.verifyKeycloakToken({ body: { token } }) : null;",
1601
- " if (principal) c.set(\"principal\", principal);",
1602
- "}"
1603
- ].join("\n")
1553
+ slot: "resolvers",
1554
+ content: "(c) => keycloakPrincipal(auth, c.req.header(\"authorization\")),"
1604
1555
  },
1605
1556
  {
1606
- file: "tests/helpers/auth.ts",
1557
+ file: "src/core/auth/principal.ts",
1607
1558
  slot: "imports",
1608
- content: "import { generateKeyPair, SignJWT } from \"jose\";"
1559
+ content: "import { keycloakPrincipal } from \"@tatara/keycloak\";"
1609
1560
  },
1610
1561
  {
1611
1562
  file: "tests/helpers/auth.ts",
1612
1563
  slot: "imports",
1613
- content: "import { issuer, setKeySource } from \"#core/auth/keycloak\";"
1564
+ content: "import { useTestKeys } from \"@tatara/keycloak/testing\";"
1614
1565
  },
1615
1566
  {
1616
1567
  file: "tests/helpers/auth.ts",
1617
1568
  slot: "imports",
1618
- content: "import { env } from \"#core/config/env\";"
1569
+ content: "import { keycloak } from \"#core/auth/keycloak\";"
1619
1570
  },
1620
1571
  {
1621
1572
  file: "tests/helpers/auth.ts",
1622
1573
  slot: "helpers",
1623
- 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")
1624
1575
  },
1625
1576
  {
1626
1577
  file: "tests/helpers/auth.ts",
@@ -1628,7 +1579,7 @@ const authKeycloak = {
1628
1579
  unless: ["auth-orgs"],
1629
1580
  content: [
1630
1581
  "if (options.roles) {",
1631
- " const sub = newId();",
1582
+ " const sub = crypto.randomUUID();",
1632
1583
  " const token = await keycloakToken({ sub, roles: options.roles, email: options.email });",
1633
1584
  " return { userId: sub, headers: { authorization: `Bearer ${token}` } };",
1634
1585
  "}"
@@ -1637,7 +1588,21 @@ const authKeycloak = {
1637
1588
  {
1638
1589
  file: "AGENTS.md",
1639
1590
  slot: "sections",
1640
- 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
+ `
1641
1606
  }
1642
1607
  ]
1643
1608
  };
@@ -2487,11 +2452,11 @@ function guardSlots(project, n) {
2487
2452
  order: fills.length
2488
2453
  });
2489
2454
  if (project.has("authz")) {
2490
- add("imports", "import { requirePermission } from \"#core/authz/middleware\";");
2455
+ add("imports", "import { requirePermission } from \"#core/authz/index\";");
2491
2456
  for (const [route, action] of Object.entries(ACTIONS)) add(`guard-${route}`, `middleware: [requirePermission("${n.kebab}:${action}")] as const,\nsecurity: [{ bearerAuth: ["${n.kebab}:${action}"] }],`);
2492
2457
  add("auth-errors", "...errorResponses(401, 403),");
2493
2458
  } else if (project.has("auth")) {
2494
- add("imports", "import { requireAuth } from \"#core/auth/middleware\";");
2459
+ add("imports", "import { requireAuth } from \"@tatara/auth\";");
2495
2460
  for (const route of Object.keys(ACTIONS)) add(`guard-${route}`, "middleware: [requireAuth()] as const,\nsecurity: [{ bearerAuth: [] }],");
2496
2461
  add("auth-errors", "...errorResponses(401),");
2497
2462
  }
@@ -3221,6 +3186,316 @@ function createBaseline(project, deps) {
3221
3186
  } };
3222
3187
  }
3223
3188
  //#endregion
3189
+ //#region src/upgrade/codemods/imports.ts
3190
+ const escapeRegExp$1 = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3191
+ /**
3192
+ * The names `text` imports (or re-exports) from `specifier`, or null when it doesn't use it.
3193
+ * A namespace, default, side-effect or dynamic import gives ["*"]: it could reach any name.
3194
+ */
3195
+ function importsOf(text, specifier) {
3196
+ const spec = `["']${escapeRegExp$1(specifier)}["']`;
3197
+ const names = [];
3198
+ let found = false;
3199
+ const fromClause = new RegExp(`(?:import|export)\\s+(?:type\\s+)?([^;]*?)\\s+from\\s+${spec}`, "g");
3200
+ for (const match of text.matchAll(fromClause)) {
3201
+ found = true;
3202
+ const braces = (match[1] ?? "").match(/^\{([^}]*)\}$/);
3203
+ if (!braces) {
3204
+ names.push("*");
3205
+ continue;
3206
+ }
3207
+ for (const part of (braces[1] ?? "").split(",")) {
3208
+ const name = part.trim().replace(/^type\s+/, "").split(/\s+as\s+/)[0]?.trim();
3209
+ if (name) names.push(name);
3210
+ }
3211
+ }
3212
+ if (new RegExp(`import\\s*\\(\\s*${spec}\\s*\\)|import\\s+${spec}`).test(text)) {
3213
+ found = true;
3214
+ names.push("*");
3215
+ }
3216
+ return found ? names : null;
3217
+ }
3218
+ //#endregion
3219
+ //#region src/upgrade/codemods/auth-package.ts
3220
+ const AUTH = "@tatara/auth";
3221
+ const KEYCLOAK = "@tatara/keycloak";
3222
+ const APP$1 = "src/core/http/app.ts";
3223
+ const HELPERS = "tests/helpers/auth.ts";
3224
+ function groups(keycloak) {
3225
+ const all = [
3226
+ {
3227
+ name: "OpenAPI merge and dev outbox",
3228
+ regenerate: [APP$1, "src/core/auth/delivery.ts"],
3229
+ remove: ["src/core/auth/openapi.ts"],
3230
+ exports: [{ specifier: "#core/auth/openapi" }, {
3231
+ specifier: "#core/auth/delivery",
3232
+ names: [
3233
+ "outboxEnabled",
3234
+ "readOutbox",
3235
+ "OutboxMessage"
3236
+ ]
3237
+ }]
3238
+ },
3239
+ {
3240
+ name: "SMS sender",
3241
+ regenerate: ["src/core/auth/sms.ts"],
3242
+ remove: [],
3243
+ exports: [{
3244
+ specifier: "#core/auth/sms",
3245
+ names: ["SmsSender"]
3246
+ }]
3247
+ },
3248
+ {
3249
+ name: "principal middleware",
3250
+ regenerate: ["src/core/auth/principal.ts"],
3251
+ remove: [],
3252
+ exports: []
3253
+ }
3254
+ ];
3255
+ if (!keycloak) {
3256
+ all.push({
3257
+ name: "test helpers",
3258
+ regenerate: [HELPERS],
3259
+ remove: [],
3260
+ exports: []
3261
+ });
3262
+ return all;
3263
+ }
3264
+ all.push({
3265
+ name: "Keycloak",
3266
+ regenerate: [
3267
+ "src/core/auth/auth.ts",
3268
+ "src/core/auth/keycloak.ts",
3269
+ "src/core/auth/commands/keycloak-token.ts",
3270
+ "src/core/auth/tests/keycloak-tokens.test.ts",
3271
+ HELPERS
3272
+ ],
3273
+ remove: ["src/core/auth/keycloak-plugin.ts"],
3274
+ exports: [
3275
+ { specifier: "#core/auth/keycloak-plugin" },
3276
+ {
3277
+ specifier: "#core/auth/keycloak",
3278
+ names: [
3279
+ "issuer",
3280
+ "setKeySource",
3281
+ "verifyAccessToken",
3282
+ "rolesOf",
3283
+ "organizationOf",
3284
+ "KeycloakClaims"
3285
+ ]
3286
+ },
3287
+ {
3288
+ specifier: "#tests/helpers/auth",
3289
+ names: ["KeycloakTokenOptions"]
3290
+ }
3291
+ ]
3292
+ });
3293
+ return all;
3294
+ }
3295
+ async function read$3(ctx, path) {
3296
+ return ctx.read(path).catch(() => void 0);
3297
+ }
3298
+ /** True when `text` imports `entry` (or one of its gone names). */
3299
+ function usesExport(text, entry) {
3300
+ const names = importsOf(text, entry.specifier);
3301
+ if (!names) return false;
3302
+ const gone = entry.names;
3303
+ return !gone || names.some((name) => name === "*" || gone.includes(name));
3304
+ }
3305
+ /** Why the group can't move, or [] when it can. */
3306
+ async function blockersOf(ctx, group, sources) {
3307
+ const blockers = [];
3308
+ for (const path of [...group.regenerate, ...group.remove]) {
3309
+ const text = await read$3(ctx, path);
3310
+ if (text === void 0) continue;
3311
+ if (!await ctx.baseline.isUnchanged(path, text)) blockers.push(`${path} is customised`);
3312
+ }
3313
+ const own = /* @__PURE__ */ new Set([...group.regenerate, ...group.remove]);
3314
+ for (const path of sources) {
3315
+ if (own.has(path)) continue;
3316
+ const text = await ctx.read(path);
3317
+ if (group.exports.some((entry) => usesExport(text, entry))) blockers.push(`${path} imports what moves`);
3318
+ }
3319
+ return blockers;
3320
+ }
3321
+ const authPackage = {
3322
+ version: "0.5.0",
3323
+ id: "auth-package",
3324
+ description: "Use @tatara/auth (guards, principal, OpenAPI merge, outbox) and @tatara/keycloak",
3325
+ async run(ctx) {
3326
+ const { config } = ctx.project;
3327
+ if (config.auth === "none") return;
3328
+ const keycloak = config.authMethods.includes("keycloak");
3329
+ await ctx.addDependency(AUTH, versionOf(AUTH));
3330
+ if (keycloak) await ctx.addDependency(KEYCLOAK, versionOf(KEYCLOAK));
3331
+ const sources = await glob(["src/**/*.ts", "tests/**/*.ts"], {
3332
+ cwd: ctx.project.root,
3333
+ ignore: ["src/generated/**"]
3334
+ });
3335
+ const ready = [];
3336
+ for (const group of groups(keycloak)) {
3337
+ const blockers = await blockersOf(ctx, group, sources);
3338
+ if (blockers.length === 0) ready.push(group);
3339
+ 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.`);
3340
+ }
3341
+ for (const group of ready) {
3342
+ for (const path of group.regenerate) {
3343
+ if (await read$3(ctx, path) === void 0) continue;
3344
+ const next = await ctx.renderCurrent(path);
3345
+ if (next !== void 0) await ctx.write(path, next);
3346
+ }
3347
+ for (const path of group.remove) await ctx.removeIfUnchanged(path);
3348
+ }
3349
+ 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.");
3350
+ else await ctx.rewriteImports("#core/auth/middleware", AUTH);
3351
+ }
3352
+ };
3353
+ //#endregion
3354
+ //#region src/upgrade/codemods/authz-package.ts
3355
+ const AUTHZ = "@tatara/authz";
3356
+ const INDEX = "#core/authz/index";
3357
+ const DIR = "src/core/authz";
3358
+ /** Copies the package replaces. */
3359
+ const REMOVED = [
3360
+ "cache.ts",
3361
+ "catalog.ts",
3362
+ "constants.ts",
3363
+ "middleware.ts",
3364
+ "permissions.ts",
3365
+ "resolver.ts",
3366
+ "types.ts",
3367
+ "validators.ts",
3368
+ "services/audit.ts",
3369
+ "services/grants.ts",
3370
+ "services/members.ts",
3371
+ "services/roles.ts",
3372
+ "services/sync.ts"
3373
+ ].map((f) => `${DIR}/${f}`);
3374
+ /** App files that stay (or are new), and become what this CLI generates. system-roles.ts is the app's config and stays as it is. */
3375
+ const REGENERATED$1 = [
3376
+ `${DIR}/index.ts`,
3377
+ `${DIR}/db/seed.ts`,
3378
+ `${DIR}/commands/grant-role.ts`,
3379
+ `${DIR}/commands/sync-permissions.ts`,
3380
+ `${DIR}/routes/authorization.routes.ts`,
3381
+ `${DIR}/tests/authz.test.ts`,
3382
+ "src/core/auth/tests/keycloak-authz.test.ts",
3383
+ "tests/helpers/authz.ts"
3384
+ ];
3385
+ /** Where other files' imports go, and the names that exist there. undefined: every name. */
3386
+ const MOVES = [
3387
+ {
3388
+ from: "#core/authz/constants",
3389
+ to: AUTHZ
3390
+ },
3391
+ {
3392
+ from: "#core/authz/types",
3393
+ to: AUTHZ
3394
+ },
3395
+ {
3396
+ from: "#core/authz/catalog",
3397
+ to: AUTHZ
3398
+ },
3399
+ {
3400
+ from: "#core/authz/validators",
3401
+ to: AUTHZ
3402
+ },
3403
+ {
3404
+ from: "#core/authz/middleware",
3405
+ to: INDEX,
3406
+ names: ["requirePermission"]
3407
+ },
3408
+ {
3409
+ from: "#core/authz/resolver",
3410
+ to: INDEX,
3411
+ names: ["checkPermission", "resolveScope"]
3412
+ },
3413
+ {
3414
+ from: "#core/authz/services/members",
3415
+ to: INDEX,
3416
+ names: ["removeMemberAccess"]
3417
+ },
3418
+ {
3419
+ from: "#core/authz/cache",
3420
+ to: INDEX,
3421
+ names: []
3422
+ },
3423
+ {
3424
+ from: "#core/authz/permissions",
3425
+ to: INDEX,
3426
+ names: []
3427
+ },
3428
+ ...[
3429
+ "audit",
3430
+ "grants",
3431
+ "roles",
3432
+ "sync"
3433
+ ].map((s) => ({
3434
+ from: `#core/authz/services/${s}`,
3435
+ to: INDEX,
3436
+ names: []
3437
+ }))
3438
+ ];
3439
+ /** Names the 0.5 constants file had that the package dropped. */
3440
+ const DROPPED_CONSTANTS = ["CACHE_TTL_MS"];
3441
+ async function read$2(ctx, path) {
3442
+ return ctx.read(path).catch(() => void 0);
3443
+ }
3444
+ const authzPackage = {
3445
+ version: "0.6.0",
3446
+ id: "authz-package",
3447
+ description: "Use @tatara/authz (catalog, resolver, guards, services, routes)",
3448
+ async run(ctx) {
3449
+ if (!ctx.project.config.addons.includes("authz")) return;
3450
+ await ctx.addDependency(AUTHZ, versionOf(AUTHZ));
3451
+ const blockers = [];
3452
+ const regenerate = [];
3453
+ for (const path of REGENERATED$1) {
3454
+ const text = await read$2(ctx, path);
3455
+ if (text === void 0) {
3456
+ if (path === `${DIR}/index.ts`) regenerate.push(path);
3457
+ continue;
3458
+ }
3459
+ if (await ctx.baseline.isUnchanged(path, text)) regenerate.push(path);
3460
+ else blockers.push(`${path} is customised`);
3461
+ }
3462
+ const removed = [];
3463
+ for (const path of REMOVED) {
3464
+ const text = await read$2(ctx, path);
3465
+ if (text === void 0) continue;
3466
+ removed.push(path);
3467
+ if (!await ctx.baseline.isUnchanged(path, text)) blockers.push(`${path} is customised`);
3468
+ }
3469
+ const own = /* @__PURE__ */ new Set([...REGENERATED$1, ...REMOVED]);
3470
+ const sources = await glob(["src/**/*.ts", "tests/**/*.ts"], {
3471
+ cwd: ctx.project.root,
3472
+ ignore: ["src/generated/**"]
3473
+ });
3474
+ for (const path of sources) {
3475
+ if (own.has(path)) continue;
3476
+ const text = await ctx.read(path);
3477
+ for (const move of MOVES) {
3478
+ const names = importsOf(text, move.from);
3479
+ if (!names) continue;
3480
+ const allowed = move.names;
3481
+ const missing = names.filter((n) => n === "*" || (allowed ? !allowed.includes(n) : move.from.endsWith("/constants") && DROPPED_CONSTANTS.includes(n)));
3482
+ if (missing.length > 0) blockers.push(`${path} imports ${missing.join(", ")} from ${move.from}`);
3483
+ }
3484
+ }
3485
+ if (blockers.length > 0) {
3486
+ ctx.report.notes.push(`Nothing in ${DIR} was changed: ${blockers.join("; ")}. Compare with a newly generated app and move onto @tatara/authz (createAuthz in ${DIR}/index.ts) by hand.`);
3487
+ ctx.report.blocked.push("authz-package");
3488
+ return;
3489
+ }
3490
+ for (const path of regenerate) {
3491
+ const next = await ctx.renderCurrent(path);
3492
+ if (next !== void 0) await ctx.write(path, next);
3493
+ }
3494
+ for (const path of removed) await ctx.removeIfUnchanged(path);
3495
+ for (const move of MOVES) await ctx.rewriteImports(move.from, move.to);
3496
+ }
3497
+ };
3498
+ //#endregion
3224
3499
  //#region src/upgrade/codemods/core-package.ts
3225
3500
  const CORE = "@tatara/core";
3226
3501
  /** Files @tatara/core replaces, with the import specifier apps used for each. */
@@ -3480,6 +3755,7 @@ const CODEMODS = [
3480
3755
  for (const path of await outsideImporters(ctx)) blockers.push(`${path} imports one of the moved files`);
3481
3756
  if (blockers.length > 0) {
3482
3757
  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.`);
3758
+ ctx.report.blocked.push("http-package");
3483
3759
  return;
3484
3760
  }
3485
3761
  for (const path of replace) {
@@ -3494,7 +3770,9 @@ const CODEMODS = [
3494
3770
  ]) await ctx.rewriteImports(specifier, HTTP);
3495
3771
  }
3496
3772
  },
3497
- dbTesting
3773
+ dbTesting,
3774
+ authPackage,
3775
+ authzPackage
3498
3776
  ];
3499
3777
  //#endregion
3500
3778
  //#region src/upgrade/context.ts
@@ -3506,7 +3784,8 @@ function createContext(project, baseline, opts = {}) {
3506
3784
  regenerated: [],
3507
3785
  removed: [],
3508
3786
  kept: [],
3509
- notes: []
3787
+ notes: [],
3788
+ blocked: []
3510
3789
  };
3511
3790
  const abs = (path) => join(project.root, path);
3512
3791
  let rendered;
@@ -3618,13 +3897,16 @@ async function upgrade(project, opts = {}) {
3618
3897
  const from = project.config.version;
3619
3898
  const ctx = createContext(project, opts.baseline ?? createBaseline(project), { dryRun: opts.dryRun });
3620
3899
  const due = [...opts.codemods ?? CODEMODS].filter((m) => compareVersions(m.version, from) > 0 && compareVersions(m.version, target) <= 0).sort((a, b) => compareVersions(a.version, b.version));
3900
+ let reached = target;
3621
3901
  for (const mod of due) {
3902
+ const blockedBefore = ctx.report.blocked.length;
3622
3903
  try {
3623
3904
  await mod.run(ctx);
3624
3905
  } catch (error) {
3625
3906
  throw new Error(`codemod ${mod.id} (${mod.version}) failed: ${error.message}`, { cause: error });
3626
3907
  }
3627
3908
  ctx.report.ran.push(mod.id);
3909
+ if (ctx.report.blocked.length > blockedBefore && reached === target) reached = due.slice(0, due.indexOf(mod)).at(-1)?.version ?? from;
3628
3910
  }
3629
3911
  if (opts.dryRun) return ctx.report;
3630
3912
  const format = opts.formatFile ?? biomeFormat;
@@ -3634,9 +3916,9 @@ async function upgrade(project, opts = {}) {
3634
3916
  const config = JSON.parse(await readFile(path, "utf8"));
3635
3917
  const next = {
3636
3918
  ...config,
3637
- version: target,
3919
+ version: reached,
3638
3920
  generated: {
3639
- cli: target,
3921
+ cli: reached,
3640
3922
  files: await nextRecord(project, ctx.report)
3641
3923
  }
3642
3924
  };
@@ -3656,6 +3938,7 @@ function printUpgrade(report, from, to) {
3656
3938
  for (const path of report.removed) console.log(pc.dim(` removed ${path} (now provided by a package)`));
3657
3939
  for (const { path } of report.kept) console.log(pc.yellow(` kept ${path}: changed in this app; move the change into options or eject`));
3658
3940
  for (const note of report.notes) console.log(` ${note}`);
3941
+ if (report.blocked.length > 0) console.log(pc.yellow(` ${report.blocked.join(", ")} did not run: make the changes above, then run tatara upgrade again`));
3659
3942
  }
3660
3943
  //#endregion
3661
3944
  //#region src/bin.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tatara/cli",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Create and grow opinionated Hono backends",
5
5
  "type": "module",
6
6
  "license": "MIT",