@cedarjs/cli 8.0.0-canary.3542 → 8.0.0-canary.3546

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.
@@ -1,4 +1,5 @@
1
1
  import fs from "node:fs";
2
+ import path from "node:path";
2
3
  import { terminalLink } from "termi-link";
3
4
  import { runBin } from "@cedarjs/cli-helpers/packageManager/exec";
4
5
  import { recordTelemetryAttributes } from "@cedarjs/cli-helpers/telemetry";
@@ -32,6 +33,7 @@ const handler = async ({
32
33
  }) => {
33
34
  recordTelemetryAttributes({ command: "lint", fix, format });
34
35
  try {
36
+ await generateMissingGraphQLSchema();
35
37
  const sbPath = getPaths().web.storybook;
36
38
  const eslintArgs = [
37
39
  fix && "--fix",
@@ -64,6 +66,29 @@ const handler = async ({
64
66
  process.exitCode = 1;
65
67
  }
66
68
  };
69
+ async function generateMissingGraphQLSchema() {
70
+ const paths = getPaths();
71
+ if (fs.existsSync(paths.generated.schema) || !fs.existsSync(paths.api.graphql) || !fs.existsSync(paths.web.src)) {
72
+ return;
73
+ }
74
+ let messages;
75
+ try {
76
+ const { generateGraphQLSchema } = await import("@cedarjs/internal/dist/generate/graphqlSchema");
77
+ fs.mkdirSync(path.dirname(paths.generated.schema), { recursive: true });
78
+ const { errors } = await generateGraphQLSchema();
79
+ messages = errors.map(({ message }) => message);
80
+ } catch (error) {
81
+ messages = [error instanceof Error ? error.message : String(error)];
82
+ }
83
+ if (messages.length > 0) {
84
+ console.warn(
85
+ "Could not generate the GraphQL schema, so the GraphQL documents in web/src are not linted against it:"
86
+ );
87
+ for (const message of messages) {
88
+ console.warn(message);
89
+ }
90
+ }
91
+ }
67
92
  export {
68
93
  builder,
69
94
  command,
@@ -1,5 +1,5 @@
1
1
  import j from "jscodeshift";
2
- const TENANCY_WEB = "@cedarjs/tenancy/web";
2
+ const TENANCY_MODULE = "src/tenancy";
3
3
  function transform(fileInfo) {
4
4
  const root = j.withParser("tsx")(fileInfo.source);
5
5
  const routers = root.find(j.JSXOpeningElement, { name: { name: "Router" } });
@@ -12,9 +12,9 @@ function transform(fileInfo) {
12
12
  }
13
13
  const attributes = router.attributes ?? [];
14
14
  const hasProp = attributes.some(
15
- (attribute) => j.JSXAttribute.check(attribute) && attribute.name.name === "useOrgRoles"
15
+ (attribute) => j.JSXAttribute.check(attribute) && attribute.name.name === "tenancy"
16
16
  );
17
- const hasImport = root.find(j.ImportSpecifier, { local: { name: "useOrgRoles" } }).length > 0;
17
+ const hasImport = root.find(j.ImportSpecifier, { local: { name: "tenancy" } }).length > 0;
18
18
  if (hasProp && hasImport) {
19
19
  return fileInfo.source;
20
20
  }
@@ -23,8 +23,8 @@ function transform(fileInfo) {
23
23
  (attribute) => j.JSXAttribute.check(attribute) && attribute.name.name === "useAuth"
24
24
  );
25
25
  const prop = j.jsxAttribute(
26
- j.jsxIdentifier("useOrgRoles"),
27
- j.jsxExpressionContainer(j.identifier("useOrgRoles"))
26
+ j.jsxIdentifier("tenancy"),
27
+ j.jsxExpressionContainer(j.identifier("tenancy"))
28
28
  );
29
29
  attributes.splice(
30
30
  useAuthIndex === -1 ? attributes.length : useAuthIndex + 1,
@@ -34,17 +34,12 @@ function transform(fileInfo) {
34
34
  router.attributes = attributes;
35
35
  }
36
36
  if (!hasImport) {
37
- const specifier = j.importSpecifier(j.identifier("useOrgRoles"));
38
- const tenancyImport = root.find(j.ImportDeclaration, { source: { value: TENANCY_WEB } }).nodes().find(
39
- (declaration) => declaration.importKind !== "type" && declaration.specifiers?.every(
40
- (existing) => j.ImportSpecifier.check(existing)
37
+ root.find(j.ImportDeclaration).at(-1).insertAfter(
38
+ j.importDeclaration(
39
+ [j.importSpecifier(j.identifier("tenancy"))],
40
+ j.literal(TENANCY_MODULE)
41
41
  )
42
42
  );
43
- if (tenancyImport?.specifiers) {
44
- tenancyImport.specifiers.push(specifier);
45
- } else {
46
- root.find(j.ImportDeclaration).at(-1).insertAfter(j.importDeclaration([specifier], j.literal(TENANCY_WEB)));
47
- }
48
43
  }
49
44
  return root.toSource();
50
45
  }
@@ -25,8 +25,9 @@ const ACCEPT_INVITATION_MUTATION: TypedDocumentNode<
25
25
  `
26
26
 
27
27
  /**
28
- * Landing page for an invitation link (`/invite/{token}`). Outside
29
- * `OrgScope`, since the visitor is not a member of the organization yet.
28
+ * Landing page for an invitation link (`/invite/{token}`). Outside any
29
+ * `PrivateSet` with `{orgSlug}` routes, since the visitor is not a member of
30
+ * the organization yet.
30
31
  */
31
32
  const InvitePage = () => {
32
33
  const { token } = useParams()
@@ -0,0 +1,42 @@
1
+ import { createTenancy } from '@cedarjs/tenancy/web'
2
+
3
+ /**
4
+ * How the Router handles organizations, passed to it in Routes.tsx:
5
+ *
6
+ * <Router useAuth={useAuth} tenancy={tenancy}>
7
+ *
8
+ * The Router renders the organization's provider (its Apollo client with the
9
+ * `cedar-org` header, `useCurrentOrg()`, and `useAuth().hasPermission`)
10
+ * around the layouts and page of every `PrivateSet` whose routes have
11
+ * `{orgSlug}` in their path, so there is nothing to wrap them with:
12
+ *
13
+ * <PrivateSet wrap={OrgLayout} unauthenticated="login">
14
+ * <Route path="/org/{orgSlug}/projects" page={ProjectsPage} name="projects" />
15
+ * </PrivateSet>
16
+ *
17
+ * A user who isn't signed in is sent to log in, and one who isn't a member
18
+ * of the organization sees `notAMember`. Pages only some members may open
19
+ * take `orgRoles` or `orgPermission` (see api/src/lib/permissions), and the
20
+ * one a member without them is sent to is `unauthorized`:
21
+ *
22
+ * <PrivateSet unauthenticated="login" unauthorized="projects" orgRoles="owner">
23
+ * <Route path="/org/{orgSlug}/settings" page={SettingsPage} name="settings" />
24
+ * </PrivateSet>
25
+ *
26
+ * <PrivateSet unauthenticated="login" unauthorized="projects" orgPermission="members.manage">
27
+ * <Route path="/org/{orgSlug}/members" page={MembersPage} name="members" />
28
+ * </PrivateSet>
29
+ *
30
+ * Routes with `{orgSlug}` outside a `PrivateSet` are public pages with no
31
+ * organization context: they can't use `useCurrentOrg()` and their queries
32
+ * carry no `cedar-org` header.
33
+ *
34
+ * Customize the tenancy here, with the options of `createTenancy`:
35
+ * - `useOrg` finds the organization of a route, for one that doesn't come
36
+ * from the `orgSlug` path param, such as from the host
37
+ * - `onSetOrg` is called by `useCurrentOrg().setOrg` instead of navigating,
38
+ * for an organization selected from state rather than the URL
39
+ */
40
+ export const tenancy = createTenancy({
41
+ notAMember: <p>You don&apos;t have access to this organization.</p>,
42
+ })
@@ -434,15 +434,11 @@ ${SIGNUP_SNIPPET}`;
434
434
  }
435
435
  },
436
436
  {
437
- title: "Adding OrgScope and the invitation landing page...",
437
+ title: "Adding the tenancy and the invitation landing page...",
438
438
  task: async () => {
439
439
  await writeTemplateFile(
440
- "OrgScope.tsx.template",
441
- path.join(
442
- paths.web.components,
443
- "OrgScope",
444
- `OrgScope.${componentExt}`
445
- )
440
+ "tenancy.tsx.template",
441
+ path.join(paths.web.src, `tenancy.${componentExt}`)
446
442
  );
447
443
  await writeTemplateFile(
448
444
  "InvitePage.tsx.template",
@@ -455,11 +451,11 @@ ${SIGNUP_SNIPPET}`;
455
451
  }
456
452
  },
457
453
  {
458
- title: "Passing useOrgRoles to the Router in web/src/Routes...",
454
+ title: "Passing the tenancy to the Router in web/src/Routes...",
459
455
  task: async (ctx, task) => {
460
456
  const routesPath = paths.web.routes;
461
457
  if (!routesPath || !fs.existsSync(routesPath)) {
462
- ctx.routerNeedsUseOrgRoles = true;
458
+ ctx.routerNeedsTenancy = true;
463
459
  task.skip("No web/src/Routes file found; skipping.");
464
460
  return;
465
461
  }
@@ -469,7 +465,7 @@ ${SIGNUP_SNIPPET}`;
469
465
  targetPaths: [routesPath]
470
466
  });
471
467
  if (transformResult.error) {
472
- ctx.routerNeedsUseOrgRoles = true;
468
+ ctx.routerNeedsTenancy = true;
473
469
  task.output = "Could not find the app's <Router> in web/src/Routes, so left it as it is. See the next steps for what to add.";
474
470
  } else if (fs.readFileSync(routesPath, "utf-8") !== source) {
475
471
  writtenFiles.push(routesPath);
@@ -542,24 +538,28 @@ ${SIGNUP_SNIPPET}`;
542
538
  schema.prisma, and switch its fields from ${c.highlight("@requireUserAuth")}
543
539
  to ${c.highlight("@requireOrgAuth")}. For each one that stays global, add it
544
540
  to the ${c.highlight("allExcept")} list in api/src/lib/db instead.
545
- 3. Wrap your organization routes with the generated OrgScope in
546
- web/src/Routes.tsx, in a PrivateSet, so a user who isn't signed
547
- in is sent to log in:
541
+ 3. Put your organization routes in a PrivateSet in web/src/Routes.tsx.
542
+ The Router renders the organization for it, using the generated
543
+ web/src/tenancy, so there is nothing to wrap them with. A user
544
+ who isn't signed in is sent to log in:
548
545
 
549
- <PrivateSet wrap={OrgScope} unauthenticated="login">
546
+ <PrivateSet wrap={OrgLayout} unauthenticated="login">
550
547
  <Route path="/org/{orgSlug}/projects" page={ProjectsPage} name="projects" />
551
548
  </PrivateSet>
552
549
 
553
550
  and add the invitation route outside it:
554
551
 
555
552
  <Route path="/invite/{token}" page={InvitePage} name="invite" />
556
- ${ctx.routerNeedsUseOrgRoles ? `
557
- Your ${c.highlight("<Router>")} couldn't be found, so pass it ${c.highlight("useOrgRoles")} by
558
- hand, for ${c.highlight("<PrivateSet orgRoles>")} below:
559
553
 
560
- import { useOrgRoles } from '@cedarjs/tenancy/web'
554
+ Routes with {orgSlug} outside a PrivateSet are public pages
555
+ with no organization context.
556
+ ${ctx.routerNeedsTenancy ? `
557
+ Your ${c.highlight("<Router>")} couldn't be found, so pass it ${c.highlight("tenancy")} by
558
+ hand, so it renders the organization for the routes below:
561
559
 
562
- <Router useAuth={useAuth} useOrgRoles={useOrgRoles}>
560
+ import { tenancy } from 'src/tenancy'
561
+
562
+ <Router useAuth={useAuth} tenancy={tenancy}>
563
563
  ` : ""}
564
564
  4. Wrap any plain api/src/functions/* handler that touches
565
565
  tenant-owned models with ${c.highlight("withTenancy")} from ${c.highlight(
@@ -585,13 +585,13 @@ ${ctx.routerNeedsUseOrgRoles ? `
585
585
  the organization. ${c.highlight("<PrivateSet roles>")} isn't available with
586
586
  tenancy: guard routes with ${c.highlight("<PrivateSet userRoles>")} for
587
587
  ${c.highlight("currentUser.roles")}, and with ${c.highlight("<PrivateSet orgRoles>")} for the
588
- role in the route's organization, which the router checks with
589
- the ${c.highlight("useOrgRoles")} passed to it:
588
+ role in the route's organization. A member who lacks it is sent
589
+ to ${c.highlight("unauthorized")}, and ${c.highlight("<PrivateSet orgPermission>")} checks a
590
+ permission of their membership the same way:
590
591
 
591
- <Router useAuth={useAuth} useOrgRoles={useOrgRoles}>
592
- <PrivateSet wrap={[OrgScope, OrgLayout]} unauthenticated="login" unauthorized="projects" orgRoles="owner">
593
- <Route path="/org/{orgSlug}/settings" page={SettingsPage} name="settings" />
594
- </PrivateSet>
592
+ <PrivateSet unauthenticated="login" unauthorized="projects" orgRoles="owner">
593
+ <Route path="/org/{orgSlug}/settings" page={SettingsPage} name="settings" />
594
+ </PrivateSet>
595
595
 
596
596
  ${keptFiles.length > 0 ? `
597
597
  Kept these existing files rather than overwriting them (run with
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cedarjs/cli",
3
- "version": "8.0.0-canary.3542",
3
+ "version": "8.0.0-canary.3546",
4
4
  "description": "The CedarJS Command Line",
5
5
  "repository": {
6
6
  "type": "git",
@@ -36,17 +36,17 @@
36
36
  "@babel/preset-typescript": "7.29.7",
37
37
  "@babel/traverse": "7.29.10",
38
38
  "@babel/types": "7.29.11",
39
- "@cedarjs/api-server": "8.0.0-canary.3542",
40
- "@cedarjs/babel-config": "8.0.0-canary.3542",
41
- "@cedarjs/cli-helpers": "8.0.0-canary.3542",
42
- "@cedarjs/internal": "8.0.0-canary.3542",
43
- "@cedarjs/prerender": "8.0.0-canary.3542",
44
- "@cedarjs/project-config": "8.0.0-canary.3542",
45
- "@cedarjs/structure": "8.0.0-canary.3542",
46
- "@cedarjs/telemetry": "8.0.0-canary.3542",
47
- "@cedarjs/utils": "8.0.0-canary.3542",
48
- "@cedarjs/vite": "8.0.0-canary.3542",
49
- "@cedarjs/web-server": "8.0.0-canary.3542",
39
+ "@cedarjs/api-server": "8.0.0-canary.3546",
40
+ "@cedarjs/babel-config": "8.0.0-canary.3546",
41
+ "@cedarjs/cli-helpers": "8.0.0-canary.3546",
42
+ "@cedarjs/internal": "8.0.0-canary.3546",
43
+ "@cedarjs/prerender": "8.0.0-canary.3546",
44
+ "@cedarjs/project-config": "8.0.0-canary.3546",
45
+ "@cedarjs/structure": "8.0.0-canary.3546",
46
+ "@cedarjs/telemetry": "8.0.0-canary.3546",
47
+ "@cedarjs/utils": "8.0.0-canary.3546",
48
+ "@cedarjs/vite": "8.0.0-canary.3546",
49
+ "@cedarjs/web-server": "8.0.0-canary.3546",
50
50
  "@listr2/prompt-adapter-enquirer": "4.4.3",
51
51
  "@opentelemetry/api": "1.9.1",
52
52
  "@opentelemetry/core": "2.12.0",
@@ -93,7 +93,7 @@
93
93
  "yargs": "18.2.0"
94
94
  },
95
95
  "devDependencies": {
96
- "@cedarjs/framework-tools": "8.0.0-canary.3542",
96
+ "@cedarjs/framework-tools": "8.0.0-canary.3546",
97
97
  "@prisma/dmmf": "7.10.1",
98
98
  "@types/archiver": "^7.0.0",
99
99
  "memfs": "4.80.0",
@@ -1,88 +0,0 @@
1
- import type { ReactNode } from 'react'
2
-
3
- import { OrgScope as TenancyOrgScope } from '@cedarjs/tenancy/web'
4
- import type { OrgScopeProps as TenancyOrgScopeProps } from '@cedarjs/tenancy/web'
5
-
6
- import { useAuth } from 'src/auth'
7
-
8
- export interface OrgScopeProps {
9
- /** Overrides the `orgSlug` route param. */
10
- orgSlug?: string
11
- /** Only members with one of these roles in the organization get in. */
12
- roles?: string | string[]
13
- /**
14
- * Only members whose role has this permission (api/src/lib/permissions)
15
- * get in.
16
- */
17
- permission?: TenancyOrgScopeProps['permission']
18
- /**
19
- * The name of the route a member without one of `roles`, or without
20
- * `permission`, is sent to. It gets the `orgSlug` param when its path has
21
- * one.
22
- */
23
- unauthorized?: string
24
- children: ReactNode
25
- }
26
-
27
- /**
28
- * Wraps organization routes with a per-organization Apollo client and a
29
- * membership check, using this app's `useAuth`. Use with the router's
30
- * `PrivateSet wrap`, so a user who isn't signed in is sent to log in:
31
- *
32
- * <PrivateSet wrap={OrgScope} unauthenticated="login">
33
- * <Route path="/org/{orgSlug}/projects" page={ProjectsPage} name="projects" />
34
- * </PrivateSet>
35
- *
36
- * Under it, `useAuth().hasPermission` and `useCurrentOrg().hasOrgRole`
37
- * check the user's membership in the organization, and
38
- * `useAuth().hasUserRole` checks `currentUser.roles`. For pages only some
39
- * roles in the organization may open, give the `PrivateSet` `orgRoles`. The
40
- * router checks the role in the route's organization with the
41
- * `useOrgRoles` passed to it, `<Router useAuth={useAuth}
42
- * useOrgRoles={useOrgRoles}>`:
43
- *
44
- * <PrivateSet wrap={[OrgScope, OrgLayout]} unauthenticated="login" unauthorized="projects" orgRoles="owner">
45
- * <Route path="/org/{orgSlug}/settings" page={SettingsPage} name="settings" />
46
- * </PrivateSet>
47
- *
48
- * For pages only members with a permission may open, wrap them in a
49
- * component that passes `permission`:
50
- *
51
- * const MemberManagers = ({ children }: { children: ReactNode }) => (
52
- * <OrgScope permission="members.manage" unauthorized="projects">
53
- * {children}
54
- * </OrgScope>
55
- * )
56
- *
57
- * <Set wrap={MemberManagers}>
58
- * <Route path="/org/{orgSlug}/members" page={MembersPage} name="members" />
59
- * </Set>
60
- *
61
- * Routes under `OrgScope` must not carry `prerender`: `OrgScope`'s nested
62
- * Apollo client shadows the per-route client the prerenderer injects above
63
- * `App`, so a prerendered query under here would not go through it.
64
- */
65
- export const OrgScope = ({
66
- orgSlug,
67
- roles,
68
- permission,
69
- unauthorized,
70
- children,
71
- }: OrgScopeProps) => {
72
- return (
73
- <TenancyOrgScope
74
- useAuth={useAuth}
75
- orgSlug={orgSlug}
76
- roles={roles}
77
- permission={permission}
78
- unauthorized={unauthorized}
79
- notAMember={
80
- <div>
81
- <p>You don&apos;t have access to this organization.</p>
82
- </div>
83
- }
84
- >
85
- {children}
86
- </TenancyOrgScope>
87
- )
88
- }