@donkeylabs/cli 1.1.16 → 1.1.18

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 (29) hide show
  1. package/package.json +1 -1
  2. package/src/commands/generate.ts +153 -3
  3. package/templates/sveltekit-app/package.json +3 -3
  4. package/templates/sveltekit-app/src/lib/permissions.ts +213 -0
  5. package/templates/sveltekit-app/src/routes/+page.server.ts +1 -1
  6. package/templates/sveltekit-app/src/routes/workflows/+page.server.ts +1 -1
  7. package/templates/sveltekit-app/src/server/index.ts +46 -1
  8. package/templates/sveltekit-app/src/server/plugins/auth/auth.test.ts +377 -0
  9. package/templates/sveltekit-app/src/server/plugins/auth/index.ts +7 -7
  10. package/templates/sveltekit-app/src/server/plugins/auth/schema.ts +65 -0
  11. package/templates/sveltekit-app/src/server/plugins/email/email.test.ts +369 -0
  12. package/templates/sveltekit-app/src/server/plugins/email/schema.ts +24 -0
  13. package/templates/sveltekit-app/src/server/plugins/permissions/index.ts +1048 -0
  14. package/templates/sveltekit-app/src/server/plugins/permissions/migrations/001_create_tenants.ts +63 -0
  15. package/templates/sveltekit-app/src/server/plugins/permissions/migrations/002_create_roles.ts +90 -0
  16. package/templates/sveltekit-app/src/server/plugins/permissions/migrations/003_create_resource_grants.ts +50 -0
  17. package/templates/sveltekit-app/src/server/plugins/permissions/permissions.test.ts +566 -0
  18. package/templates/sveltekit-app/src/server/plugins/permissions/schema.ts +67 -0
  19. package/templates/sveltekit-app/src/server/plugins/workflow-demo/index.ts +3 -2
  20. package/templates/sveltekit-app/src/server/routes/auth/handlers/login.handler.ts +4 -6
  21. package/templates/sveltekit-app/src/server/routes/auth/handlers/logout.handler.ts +5 -8
  22. package/templates/sveltekit-app/src/server/routes/auth/handlers/me.handler.ts +4 -7
  23. package/templates/sveltekit-app/src/server/routes/auth/handlers/refresh.handler.ts +4 -6
  24. package/templates/sveltekit-app/src/server/routes/auth/handlers/register.handler.ts +4 -6
  25. package/templates/sveltekit-app/src/server/routes/auth/handlers/update-profile.handler.ts +5 -8
  26. package/templates/sveltekit-app/src/server/routes/auth/index.ts +6 -7
  27. package/templates/sveltekit-app/src/server/routes/example/handlers/greet.handler.ts +3 -5
  28. package/templates/sveltekit-app/src/server/routes/permissions/index.ts +248 -0
  29. package/templates/sveltekit-app/src/server/routes/tenants/index.ts +339 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@donkeylabs/cli",
3
- "version": "1.1.16",
3
+ "version": "1.1.18",
4
4
  "type": "module",
5
5
  "description": "CLI for @donkeylabs/server - project scaffolding and code generation",
6
6
  "main": "./src/index.ts",
@@ -83,6 +83,110 @@ async function extractMiddlewareNames(pluginPath: string): Promise<string[]> {
83
83
  }
84
84
  }
85
85
 
86
+ interface ServiceDefinitionInfo {
87
+ name: string;
88
+ exportName: string;
89
+ filePath: string;
90
+ returnType: string | null;
91
+ }
92
+
93
+ /**
94
+ * Extract defineService calls from source files.
95
+ * Looks for patterns like: export const myService = defineService("name", ...)
96
+ */
97
+ async function extractServiceDefinitions(filePath: string): Promise<ServiceDefinitionInfo[]> {
98
+ try {
99
+ const content = await readFile(filePath, "utf-8");
100
+ const services: ServiceDefinitionInfo[] = [];
101
+
102
+ // Match: export const serviceName = defineService("name", ...)
103
+ // Also try to capture return type annotation if present
104
+ const pattern = /export\s+const\s+(\w+)\s*=\s*defineService\s*\(\s*["'](\w+)["']/g;
105
+
106
+ let match;
107
+ while ((match = pattern.exec(content)) !== null) {
108
+ const exportName = match[1] || "";
109
+ const serviceName = match[2] || "";
110
+
111
+ // Try to find return type from explicit annotation or factory return
112
+ // Look for patterns like: defineService<"name", NVRService>(...) or ): Promise<NVR> =>
113
+ let returnType: string | null = null;
114
+
115
+ // Check for generic type parameter: defineService<"name", ReturnType>
116
+ const genericMatch = content.slice(match.index).match(
117
+ /defineService\s*<\s*["']\w+["']\s*,\s*([^>]+)>/
118
+ );
119
+ if (genericMatch?.[1]) {
120
+ returnType = genericMatch[1].trim();
121
+ }
122
+
123
+ // Check for return type annotation on the factory: (ctx): Promise<Type> =>
124
+ if (!returnType) {
125
+ const factoryMatch = content.slice(match.index, match.index + 500).match(
126
+ /,\s*(?:async\s*)?\([^)]*\)\s*(?::\s*(?:Promise\s*<\s*)?([A-Z]\w+))?/
127
+ );
128
+ if (factoryMatch?.[1]) {
129
+ returnType = factoryMatch[1].trim();
130
+ }
131
+ }
132
+
133
+ services.push({
134
+ name: serviceName,
135
+ exportName,
136
+ filePath,
137
+ returnType,
138
+ });
139
+ }
140
+
141
+ return services;
142
+ } catch {
143
+ return [];
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Find all service definitions in the project.
149
+ * Scans common locations: server entry, services directory, etc.
150
+ */
151
+ async function findServiceDefinitions(entryPath: string, servicesPattern?: string): Promise<ServiceDefinitionInfo[]> {
152
+ const allServices: ServiceDefinitionInfo[] = [];
153
+
154
+ // Scan the entry file
155
+ const entryFullPath = join(process.cwd(), entryPath);
156
+ if (existsSync(entryFullPath)) {
157
+ const entryServices = await extractServiceDefinitions(entryFullPath);
158
+ allServices.push(...entryServices);
159
+ }
160
+
161
+ // Scan services directory if it exists
162
+ const servicesDir = join(process.cwd(), "src/server/services");
163
+ if (existsSync(servicesDir)) {
164
+ const entries = await readdir(servicesDir, { withFileTypes: true });
165
+ for (const entry of entries) {
166
+ if (entry.isFile() && entry.name.endsWith(".ts")) {
167
+ const filePath = join(servicesDir, entry.name);
168
+ const services = await extractServiceDefinitions(filePath);
169
+ allServices.push(...services);
170
+ }
171
+ }
172
+ }
173
+
174
+ // Also check src/lib/services for SvelteKit projects
175
+ const libServicesDir = join(process.cwd(), "src/lib/services");
176
+ if (existsSync(libServicesDir)) {
177
+ const entries = await readdir(libServicesDir, { withFileTypes: true });
178
+ for (const entry of entries) {
179
+ if (entry.isFile() && entry.name.endsWith(".ts")) {
180
+ const filePath = join(libServicesDir, entry.name);
181
+ const services = await extractServiceDefinitions(filePath);
182
+ allServices.push(...services);
183
+ }
184
+ }
185
+ }
186
+
187
+ return allServices;
188
+ }
189
+
86
190
  interface ExtractedRoute {
87
191
  name: string;
88
192
  handler: string;
@@ -519,9 +623,18 @@ export async function generateCommand(_args: string[]): Promise<void> {
519
623
  const entryPath = config.entry || "./src/index.ts";
520
624
  const serverRoutes = await extractRoutesFromServer(entryPath);
521
625
 
626
+ // Find custom service definitions
627
+ const services = await findServiceDefinitions(entryPath);
628
+ if (services.length > 0) {
629
+ console.log(
630
+ pc.green("Found services:"),
631
+ services.map((s) => pc.dim(s.name)).join(", ")
632
+ );
633
+ }
634
+
522
635
  // Generate all files
523
636
  await generateRegistry(plugins, outPath);
524
- await generateContext(plugins, outPath);
637
+ await generateContext(plugins, services, outPath);
525
638
  await generateRouteTypes(fileRoutes, outPath);
526
639
 
527
640
  const generated = ["registry", "context", "routes"];
@@ -668,22 +781,57 @@ ${middlewareBuilderMethods}
668
781
 
669
782
  async function generateContext(
670
783
  plugins: { name: string; path: string; exportName: string }[],
784
+ services: ServiceDefinitionInfo[],
671
785
  outPath: string
672
786
  ) {
673
787
  const schemaIntersection =
674
788
  plugins.map((p) => `PluginRegistry["${p.name}"]["schema"]`).join(" & ") ||
675
789
  "{}";
676
790
 
791
+ // Generate service imports and type entries
792
+ const serviceImports: string[] = [];
793
+ const serviceEntries: string[] = [];
794
+
795
+ for (const service of services) {
796
+ // Calculate relative path from outPath to service file
797
+ const serviceAbsPath = service.filePath.replace(/\.ts$/, "");
798
+ const relativePath = relative(outPath, serviceAbsPath);
799
+ const importPath = relativePath.startsWith(".") ? relativePath : `./${relativePath}`;
800
+
801
+ serviceImports.push(`import type { ${service.exportName} } from "${importPath}";`);
802
+
803
+ // Use the inferred return type if available, otherwise use Awaited<ReturnType<factory>>
804
+ if (service.returnType) {
805
+ serviceEntries.push(` ${service.name}: ${service.returnType};`);
806
+ } else {
807
+ // Infer type from the service definition
808
+ serviceEntries.push(` ${service.name}: Awaited<ReturnType<typeof ${service.exportName}["factory"]>>;`);
809
+ }
810
+ }
811
+
812
+ const serviceImportsBlock = serviceImports.length > 0 ? serviceImports.join("\n") + "\n" : "";
813
+ const servicesType = serviceEntries.length > 0
814
+ ? `{\n${serviceEntries.join("\n")}\n }`
815
+ : "Record<string, never>";
816
+
677
817
  const content = `// Auto-generated by donkeylabs generate
678
818
  // App context - import as: import type { AppContext } from ".@donkeylabs/server/context";
679
819
 
680
820
  /// <reference path="./registry.d.ts" />
681
- import type { PluginRegistry, CoreServices, Errors } from "@donkeylabs/server";
821
+ import type { PluginRegistry, CoreServices, Errors, ServiceRegistry } from "@donkeylabs/server";
682
822
  import type { Kysely } from "kysely";
683
-
823
+ ${serviceImportsBlock}
684
824
  /** Merged database schema from all plugins */
685
825
  export type DatabaseSchema = ${schemaIntersection};
686
826
 
827
+ /** Custom services registered via defineService() */
828
+ export interface AppServices ${servicesType}
829
+
830
+ // Augment the ServiceRegistry for type inference in ctx.services
831
+ declare module "@donkeylabs/server" {
832
+ interface ServiceRegistry extends AppServices {}
833
+ }
834
+
687
835
  /**
688
836
  * Fully typed application context.
689
837
  * Use this instead of ServerContext for typed plugin access.
@@ -701,6 +849,8 @@ export interface AppContext {
701
849
  errors: Errors;
702
850
  /** Application config */
703
851
  config: Record<string, any>;
852
+ /** Custom user-registered services */
853
+ services: AppServices;
704
854
  /** Client IP address */
705
855
  ip: string;
706
856
  /** Unique request ID */
@@ -24,9 +24,9 @@
24
24
  "vite": "^7.2.6"
25
25
  },
26
26
  "dependencies": {
27
- "@donkeylabs/cli": "file:/Users/franciscosainzwilliams/Documents/GitHub/server/packages/cli",
28
- "@donkeylabs/adapter-sveltekit": "file:/Users/franciscosainzwilliams/Documents/GitHub/server/packages/adapter-sveltekit",
29
- "@donkeylabs/server": "file:/Users/franciscosainzwilliams/Documents/GitHub/server/packages/server",
27
+ "@donkeylabs/cli": "^1.1.18",
28
+ "@donkeylabs/adapter-sveltekit": "^1.1.18",
29
+ "@donkeylabs/server": "^1.1.18",
30
30
  "bits-ui": "^2.15.4",
31
31
  "clsx": "^2.1.1",
32
32
  "kysely": "^0.27.6",
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Permissions Helper for Svelte UI
3
+ *
4
+ * Provides reactive permission checking for UI locking.
5
+ *
6
+ * Usage in +page.server.ts:
7
+ * ```ts
8
+ * export const load = async ({ locals }) => {
9
+ * const api = createApi({ locals });
10
+ * const permissions = await api.permissions.context({ tenantId });
11
+ * return { permissions };
12
+ * };
13
+ * ```
14
+ *
15
+ * Usage in +page.svelte:
16
+ * ```svelte
17
+ * <script>
18
+ * import { createPermissions } from "$lib/permissions";
19
+ * let { data } = $props();
20
+ * const can = createPermissions(data.permissions);
21
+ * </script>
22
+ *
23
+ * {#if can.has("documents.create")}
24
+ * <Button>Create Document</Button>
25
+ * {/if}
26
+ * ```
27
+ */
28
+
29
+ // Note: createApi is imported dynamically to avoid compile-time dependency
30
+ // on the generated API file which may not have permissions routes yet
31
+
32
+ export interface PermissionContext {
33
+ tenantId: string;
34
+ roles: Array<{ id: string; name: string }>;
35
+ permissions: string[];
36
+ }
37
+
38
+ export interface PermissionsHelper {
39
+ /** Check if user has a static permission */
40
+ has: (permission: string) => boolean;
41
+
42
+ /** Check if user has all of the specified permissions */
43
+ hasAll: (...permissions: string[]) => boolean;
44
+
45
+ /** Check if user has any of the specified permissions */
46
+ hasAny: (...permissions: string[]) => boolean;
47
+
48
+ /** Check if user has a specific role */
49
+ hasRole: (roleName: string) => boolean;
50
+
51
+ /** Get all permissions */
52
+ all: () => string[];
53
+
54
+ /** Get all roles */
55
+ roles: () => Array<{ id: string; name: string }>;
56
+
57
+ /** Get tenant ID */
58
+ tenantId: () => string;
59
+
60
+ /** Check resource access (async - makes API call) */
61
+ canAccess: (
62
+ resourceType: string,
63
+ resourceId: string,
64
+ action: "create" | "read" | "write" | "delete" | "admin",
65
+ ownerId?: string
66
+ ) => Promise<boolean>;
67
+
68
+ /** Batch check resource access (async - makes single API call) */
69
+ canAccessMany: (
70
+ checks: Array<{
71
+ resourceType: string;
72
+ resourceId: string;
73
+ action: "create" | "read" | "write" | "delete" | "admin";
74
+ ownerId?: string;
75
+ }>
76
+ ) => Promise<Record<string, boolean>>;
77
+ }
78
+
79
+ /**
80
+ * Create a permissions helper from context
81
+ */
82
+ export function createPermissions(context: PermissionContext, api?: any): PermissionsHelper {
83
+ const permissionSet = new Set(context.permissions);
84
+
85
+ // Lazy load API if not provided
86
+ const getApi = () => {
87
+ if (api) return api;
88
+ // Dynamic import to avoid compile-time dependency
89
+ const { createApi } = require("./api");
90
+ return createApi();
91
+ };
92
+
93
+ return {
94
+ has(permission: string): boolean {
95
+ // Exact match
96
+ if (permissionSet.has(permission)) return true;
97
+
98
+ // Wildcard match
99
+ if (permissionSet.has("*")) return true;
100
+
101
+ // Resource wildcard (e.g., "documents.*")
102
+ const [resource] = permission.split(".");
103
+ if (permissionSet.has(`${resource}.*`)) return true;
104
+
105
+ return false;
106
+ },
107
+
108
+ hasAll(...permissions: string[]): boolean {
109
+ return permissions.every((p) => this.has(p));
110
+ },
111
+
112
+ hasAny(...permissions: string[]): boolean {
113
+ return permissions.some((p) => this.has(p));
114
+ },
115
+
116
+ hasRole(roleName: string): boolean {
117
+ return context.roles.some(
118
+ (r) => r.name.toLowerCase() === roleName.toLowerCase()
119
+ );
120
+ },
121
+
122
+ all(): string[] {
123
+ return context.permissions;
124
+ },
125
+
126
+ roles(): Array<{ id: string; name: string }> {
127
+ return context.roles;
128
+ },
129
+
130
+ tenantId(): string {
131
+ return context.tenantId;
132
+ },
133
+
134
+ async canAccess(
135
+ resourceType: string,
136
+ resourceId: string,
137
+ action: "create" | "read" | "write" | "delete" | "admin",
138
+ ownerId?: string
139
+ ): Promise<boolean> {
140
+ const apiClient = getApi();
141
+ const result = await apiClient.permissions.canAccess({
142
+ tenantId: context.tenantId,
143
+ checks: [{ resourceType, resourceId, action, ownerId }],
144
+ });
145
+ return result[`${resourceType}:${resourceId}:${action}`] ?? false;
146
+ },
147
+
148
+ async canAccessMany(
149
+ checks: Array<{
150
+ resourceType: string;
151
+ resourceId: string;
152
+ action: "create" | "read" | "write" | "delete" | "admin";
153
+ ownerId?: string;
154
+ }>
155
+ ): Promise<Record<string, boolean>> {
156
+ const apiClient = getApi();
157
+ return apiClient.permissions.canAccess({
158
+ tenantId: context.tenantId,
159
+ checks,
160
+ });
161
+ },
162
+ };
163
+ }
164
+
165
+ /**
166
+ * Svelte component helper - use in templates
167
+ *
168
+ * Usage:
169
+ * ```svelte
170
+ * <script>
171
+ * import { Can } from "$lib/permissions";
172
+ * let { data } = $props();
173
+ * </script>
174
+ *
175
+ * <Can permission="documents.create" context={data.permissions}>
176
+ * <Button>Create</Button>
177
+ * </Can>
178
+ * ```
179
+ */
180
+ export function canCheck(
181
+ context: PermissionContext,
182
+ permission: string
183
+ ): boolean {
184
+ const helper = createPermissions(context);
185
+ return helper.has(permission);
186
+ }
187
+
188
+ /**
189
+ * Type helper for defining permissions in your app
190
+ *
191
+ * Usage:
192
+ * ```ts
193
+ * const PERMISSIONS = definePermissions({
194
+ * documents: ["create", "read", "write", "delete", "admin"],
195
+ * users: ["invite", "remove", "manage"],
196
+ * billing: ["view", "manage"],
197
+ * } as const);
198
+ *
199
+ * // Type: "documents.create" | "documents.read" | ...
200
+ * type Permission = typeof PERMISSIONS[number];
201
+ * ```
202
+ */
203
+ export function definePermissions<
204
+ T extends Record<string, readonly string[]>
205
+ >(permissions: T): Array<`${Extract<keyof T, string>}.${T[keyof T][number]}`> {
206
+ const result: string[] = [];
207
+ for (const [resource, actions] of Object.entries(permissions)) {
208
+ for (const action of actions) {
209
+ result.push(`${resource}.${action}`);
210
+ }
211
+ }
212
+ return result as any;
213
+ }
@@ -8,7 +8,7 @@ export const load: PageServerLoad = async ({ locals }) => {
8
8
 
9
9
  try {
10
10
  // Direct service call through typed client
11
- const result = await client.api.counter.get({});
11
+ const result = await (client as any).demo.counter.get({});
12
12
  return {
13
13
  count: result.count,
14
14
  loadedAt: new Date().toISOString(),
@@ -7,7 +7,7 @@ export const load: PageServerLoad = async ({ locals }) => {
7
7
 
8
8
  try {
9
9
  // Load initial workflow instances
10
- const result = await client.api.workflow.list({});
10
+ const result = await (client as any).demo.workflow.list({});
11
11
  return {
12
12
  instances: result.instances || [],
13
13
  loadedAt: new Date().toISOString(),
@@ -7,9 +7,12 @@ import { demoPlugin } from "./plugins/demo";
7
7
  import { workflowDemoPlugin } from "./plugins/workflow-demo";
8
8
  import { authPlugin } from "./plugins/auth";
9
9
  import { emailPlugin } from "./plugins/email";
10
+ import { permissionsPlugin } from "./plugins/permissions";
10
11
  import demoRoutes from "./routes/demo";
11
12
  import { exampleRouter } from "./routes/example";
12
13
  import { authRouter } from "./routes/auth";
14
+ import { permissionsRouter } from "./routes/permissions";
15
+ import { tenantsRouter } from "./routes/tenants";
13
16
 
14
17
  // Simple in-memory database
15
18
  const db = new Kysely<{}>({
@@ -56,7 +59,7 @@ export const server = new AppServer({
56
59
  // =============================================================================
57
60
 
58
61
  // Using default session strategy for this template
59
- server.registerPlugin(authPlugin());
62
+ server.registerPlugin(authPlugin({}));
60
63
 
61
64
  // Email plugin - supports Resend or console (for development)
62
65
  // Configure with process.env.RESEND_API_KEY for production
@@ -67,11 +70,53 @@ server.registerPlugin(emailPlugin({
67
70
  baseUrl: process.env.PUBLIC_BASE_URL || "http://localhost:5173",
68
71
  }));
69
72
 
73
+ // =============================================================================
74
+ // PERMISSIONS PLUGIN - Multi-tenant RBAC
75
+ // =============================================================================
76
+ // Define your app's permissions here for type-safe checking.
77
+ // Client can use: api.permissions.check({ permissions: ["documents.create"] })
78
+ //
79
+ server.registerPlugin(permissionsPlugin({
80
+ permissions: {
81
+ // Documents
82
+ documents: ["create", "read", "write", "delete", "admin"],
83
+ // Members & Roles
84
+ members: ["invite", "remove", "list"],
85
+ roles: ["create", "assign", "manage"],
86
+ // Billing (example)
87
+ billing: ["view", "manage"],
88
+ } as const,
89
+ defaultRoles: [
90
+ {
91
+ name: "Admin",
92
+ permissions: ["*"], // Full access
93
+ isDefault: false,
94
+ },
95
+ {
96
+ name: "Member",
97
+ permissions: [
98
+ "documents.create",
99
+ "documents.read",
100
+ "documents.write",
101
+ "members.list",
102
+ ],
103
+ isDefault: true, // Auto-assigned to new members
104
+ },
105
+ {
106
+ name: "Viewer",
107
+ permissions: ["documents.read", "members.list"],
108
+ isDefault: false,
109
+ },
110
+ ],
111
+ }));
112
+
70
113
  server.registerPlugin(demoPlugin);
71
114
  server.registerPlugin(workflowDemoPlugin);
72
115
 
73
116
  // Register routes
74
117
  server.use(authRouter);
118
+ server.use(permissionsRouter);
119
+ server.use(tenantsRouter);
75
120
  server.use(demoRoutes);
76
121
  server.use(exampleRouter);
77
122