@checkstack/command-backend 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,64 @@
1
+ # @checkstack/command-backend
2
+
3
+ ## 0.0.2
4
+
5
+ ### Patch Changes
6
+
7
+ - d20d274: Initial release of all @checkstack packages. Rebranded from Checkmate to Checkstack with new npm organization @checkstack and domain checkstack.dev.
8
+ - Updated dependencies [d20d274]
9
+ - @checkstack/backend-api@0.0.2
10
+ - @checkstack/command-common@0.0.2
11
+ - @checkstack/common@0.0.2
12
+
13
+ ## 0.1.0
14
+
15
+ ### Minor Changes
16
+
17
+ - a65e002: Add command palette commands and deep-linking support
18
+
19
+ **Backend Changes:**
20
+
21
+ - `healthcheck-backend`: Add "Manage Health Checks" (⇧⌘H) and "Create Health Check" commands
22
+ - `catalog-backend`: Add "Manage Systems" (⇧⌘S) and "Create System" commands
23
+ - `integration-backend`: Add "Manage Integrations" (⇧⌘G), "Create Integration Subscription", and "View Integration Logs" commands
24
+ - `auth-backend`: Add "Manage Users" (⇧⌘U), "Create User", "Manage Roles", and "Manage Applications" commands
25
+ - `command-backend`: Auto-cleanup command registrations when plugins are deregistered
26
+
27
+ **Frontend Changes:**
28
+
29
+ - `HealthCheckConfigPage`: Handle `?action=create` URL parameter
30
+ - `CatalogConfigPage`: Handle `?action=create` URL parameter
31
+ - `IntegrationsPage`: Handle `?action=create` URL parameter
32
+ - `AuthSettingsPage`: Handle `?tab=` and `?action=create` URL parameters
33
+
34
+ ### Patch Changes
35
+
36
+ - a65e002: Add compile-time type safety for Lucide icon names
37
+
38
+ - Add `LucideIconName` type and `lucideIconSchema` Zod schema to `@checkstack/common`
39
+ - Update backend interfaces (`AuthStrategy`, `NotificationStrategy`, `IntegrationProvider`, `CommandDefinition`) to use `LucideIconName`
40
+ - Update RPC contracts to use `lucideIconSchema` for proper type inference across RPC boundaries
41
+ - Simplify `SocialProviderButton` to use `DynamicIcon` directly (removes 30+ lines of pascalCase conversion)
42
+ - Replace static `iconMap` in `SearchDialog` with `DynamicIcon` for dynamic icon rendering
43
+ - Add fallback handling in `DynamicIcon` when icon name isn't found
44
+ - Fix legacy kebab-case icon names to PascalCase: `mail`→`Mail`, `send`→`Send`, `github`→`Github`, `key-round`→`KeyRound`, `network`→`Network`, `AlertCircle`→`CircleAlert`
45
+
46
+ - Updated dependencies [b4eb432]
47
+ - Updated dependencies [a65e002]
48
+ - @checkstack/backend-api@1.1.0
49
+ - @checkstack/common@0.2.0
50
+ - @checkstack/command-common@0.0.3
51
+
52
+ ## 0.0.2
53
+
54
+ ### Patch Changes
55
+
56
+ - Updated dependencies [ffc28f6]
57
+ - Updated dependencies [71275dd]
58
+ - Updated dependencies [ae19ff6]
59
+ - Updated dependencies [b55fae6]
60
+ - Updated dependencies [b354ab3]
61
+ - Updated dependencies [81f3f85]
62
+ - @checkstack/common@0.1.0
63
+ - @checkstack/backend-api@1.0.0
64
+ - @checkstack/command-common@0.0.2
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "@checkstack/command-backend",
3
+ "version": "0.0.2",
4
+ "type": "module",
5
+ "main": "src/index.ts",
6
+ "scripts": {
7
+ "typecheck": "tsc --noEmit",
8
+ "lint": "bun run lint:code",
9
+ "lint:code": "eslint . --max-warnings 0"
10
+ },
11
+ "dependencies": {
12
+ "@checkstack/backend-api": "workspace:*",
13
+ "@checkstack/command-common": "workspace:*",
14
+ "@checkstack/common": "workspace:*",
15
+ "@orpc/server": "^1.13.2",
16
+ "zod": "^4.2.1"
17
+ },
18
+ "devDependencies": {
19
+ "@checkstack/scripts": "workspace:*",
20
+ "@checkstack/tsconfig": "workspace:*",
21
+ "@types/bun": "^1.3.5",
22
+ "@types/node": "^20.0.0",
23
+ "typescript": "^5.0.0"
24
+ }
25
+ }
package/src/index.ts ADDED
@@ -0,0 +1,58 @@
1
+ import {
2
+ createBackendPlugin,
3
+ coreServices,
4
+ coreHooks,
5
+ } from "@checkstack/backend-api";
6
+ import {
7
+ pluginMetadata,
8
+ commandContract,
9
+ } from "@checkstack/command-common";
10
+ import { createCommandRouter } from "./router";
11
+ import { unregisterProvidersByPluginId } from "./registry";
12
+
13
+ // Re-export registry functions for other plugins to use
14
+ export {
15
+ registerSearchProvider,
16
+ unregisterSearchProvider,
17
+ clearRegistry,
18
+ type BackendSearchProvider,
19
+ type SearchContext,
20
+ type CommandDefinition,
21
+ type RegisterSearchProviderOptions,
22
+ } from "./registry";
23
+
24
+ export default createBackendPlugin({
25
+ metadata: pluginMetadata,
26
+ register(env) {
27
+ env.registerInit({
28
+ deps: {
29
+ rpc: coreServices.rpc,
30
+ logger: coreServices.logger,
31
+ },
32
+ init: async ({ rpc, logger }) => {
33
+ logger.debug("Initializing Command Backend...");
34
+
35
+ // Register oRPC router
36
+ const commandRouter = createCommandRouter();
37
+ rpc.registerRouter(commandRouter, commandContract);
38
+
39
+ logger.debug("✅ Command Backend initialized.");
40
+ },
41
+ afterPluginsReady: async ({ logger, onHook }) => {
42
+ // Subscribe to plugin deregistration to clean up their commands
43
+ onHook(
44
+ coreHooks.pluginDeregistering,
45
+ async ({ pluginId }) => {
46
+ const removed = unregisterProvidersByPluginId(pluginId);
47
+ if (removed > 0) {
48
+ logger.debug(
49
+ `[command-backend] Unregistered ${removed} search provider(s) for plugin: ${pluginId}`
50
+ );
51
+ }
52
+ },
53
+ { mode: "instance-local" }
54
+ );
55
+ },
56
+ });
57
+ },
58
+ });
@@ -0,0 +1,274 @@
1
+ import type { SearchResult } from "@checkstack/command-common";
2
+ import {
3
+ qualifyPermissionId,
4
+ type PluginMetadata,
5
+ type Permission,
6
+ type LucideIconName,
7
+ } from "@checkstack/common";
8
+
9
+ // =============================================================================
10
+ // TYPES
11
+ // =============================================================================
12
+
13
+ /**
14
+ * Context provided to search providers during search operations.
15
+ */
16
+ export interface SearchContext {
17
+ /** The user's permission IDs for filtering results */
18
+ userPermissions: string[];
19
+ }
20
+
21
+ /**
22
+ * A command definition for simple registration.
23
+ * Permissions will be automatically qualified using the plugin's metadata.
24
+ */
25
+ export interface CommandDefinition {
26
+ /** Unique command ID (will be prefixed with plugin ID) */
27
+ id: string;
28
+ title: string;
29
+ subtitle?: string;
30
+ /** Icon name (Lucide PascalCase, e.g., 'AlertCircle') */
31
+ iconName?: LucideIconName;
32
+ /** Keyboard shortcuts, e.g. ["meta+shift+i", "ctrl+shift+i"] */
33
+ shortcuts?: string[];
34
+ /** Route to navigate to when executed */
35
+ route: string;
36
+ /** Permissions required (will be auto-qualified with plugin ID) */
37
+ requiredPermissions?: Permission[];
38
+ }
39
+
40
+ /**
41
+ * A backend search provider that contributes results to the command palette.
42
+ */
43
+ export interface BackendSearchProvider {
44
+ /** Unique identifier for this provider */
45
+ id: string;
46
+ /** Display name for the provider category */
47
+ name: string;
48
+ /** Higher priority = appears first in results (default: 0) */
49
+ priority?: number;
50
+ /**
51
+ * Search function that returns results matching the query.
52
+ * Results should NOT be pre-filtered by permissions - the registry handles that.
53
+ */
54
+ search: (
55
+ query: string,
56
+ context: SearchContext
57
+ ) => Promise<SearchResult[]> | SearchResult[];
58
+ }
59
+
60
+ /**
61
+ * Options for registering a search provider.
62
+ */
63
+ export interface RegisterSearchProviderOptions {
64
+ /** The plugin's metadata - used to qualify permission IDs */
65
+ pluginMetadata: PluginMetadata;
66
+
67
+ /**
68
+ * Simple command definitions. These will be automatically:
69
+ * - Converted to a search provider
70
+ * - Have permissions qualified with pluginId
71
+ * - Be searchable by title, subtitle, and category
72
+ */
73
+ commands?: CommandDefinition[];
74
+
75
+ /**
76
+ * Custom search provider for more complex search logic.
77
+ * Use this for entity search (e.g., catalog systems).
78
+ * Permissions in returned results will be auto-qualified.
79
+ */
80
+ provider?: Omit<BackendSearchProvider, "id"> & {
81
+ /** Provider ID (will be prefixed with plugin ID) */
82
+ id: string;
83
+ };
84
+ }
85
+
86
+ // =============================================================================
87
+ // INTERNAL REGISTRY
88
+ // =============================================================================
89
+
90
+ const searchProviders = new Map<string, BackendSearchProvider>();
91
+
92
+ /**
93
+ * Register a search provider with the command palette.
94
+ *
95
+ * This is the main API for plugins to contribute to command palette search.
96
+ * Permissions are automatically qualified with the plugin's ID.
97
+ *
98
+ * @example Simple command registration:
99
+ * ```ts
100
+ * registerSearchProvider({
101
+ * pluginMetadata,
102
+ * commands: [
103
+ * {
104
+ * id: "create",
105
+ * title: "Create Incident",
106
+ * subtitle: "Report a new incident",
107
+ * iconName: "AlertCircle",
108
+ * shortcuts: ["meta+shift+i", "ctrl+shift+i"],
109
+ * route: "/incidents?action=create",
110
+ * requiredPermissions: [permissions.incidentManage],
111
+ * },
112
+ * ],
113
+ * });
114
+ * ```
115
+ *
116
+ * @example Custom entity provider:
117
+ * ```ts
118
+ * registerSearchProvider({
119
+ * pluginMetadata,
120
+ * provider: {
121
+ * id: "systems",
122
+ * name: "Systems",
123
+ * priority: 100,
124
+ * search: async (query) => {
125
+ * const systems = await db.select().from(schema.systems);
126
+ * return systems.filter(s => s.name.includes(query)).map(s => ({
127
+ * id: s.id,
128
+ * type: "entity",
129
+ * title: s.name,
130
+ * category: "Systems",
131
+ * route: `/catalog/systems/${s.id}`,
132
+ * }));
133
+ * },
134
+ * },
135
+ * });
136
+ * ```
137
+ */
138
+ export function registerSearchProvider(
139
+ options: RegisterSearchProviderOptions
140
+ ): void {
141
+ const { pluginMetadata, commands, provider } = options;
142
+
143
+ // Register commands as a search provider
144
+ if (commands && commands.length > 0) {
145
+ const commandProvider = createCommandProvider(pluginMetadata, commands);
146
+ searchProviders.set(commandProvider.id, commandProvider);
147
+ }
148
+
149
+ // Register custom provider with auto-qualified permissions
150
+ if (provider) {
151
+ const qualifiedProvider = createQualifiedProvider(pluginMetadata, provider);
152
+ searchProviders.set(qualifiedProvider.id, qualifiedProvider);
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Create a search provider from command definitions.
158
+ */
159
+ function createCommandProvider(
160
+ pluginMetadata: PluginMetadata,
161
+ commands: CommandDefinition[]
162
+ ): BackendSearchProvider {
163
+ // Pre-qualify all permissions and build SearchResult objects
164
+ const searchableCommands: SearchResult[] = commands.map((cmd) => ({
165
+ id: `${pluginMetadata.pluginId}.${cmd.id}`,
166
+ type: "command" as const,
167
+ title: cmd.title,
168
+ subtitle: cmd.subtitle,
169
+ iconName: cmd.iconName,
170
+ category: capitalize(pluginMetadata.pluginId),
171
+ shortcuts: cmd.shortcuts,
172
+ route: cmd.route,
173
+ requiredPermissions: cmd.requiredPermissions?.map((perm) =>
174
+ qualifyPermissionId(pluginMetadata, perm)
175
+ ),
176
+ }));
177
+
178
+ return {
179
+ id: `${pluginMetadata.pluginId}.commands`,
180
+ name: `${capitalize(pluginMetadata.pluginId)} Commands`,
181
+ priority: 80, // Commands have medium-high priority
182
+ search: (query) => {
183
+ const q = query.toLowerCase();
184
+
185
+ // Return all if no query
186
+ if (!q) return searchableCommands;
187
+
188
+ // Filter by title, subtitle, or category
189
+ return searchableCommands.filter(
190
+ (cmd) =>
191
+ cmd.title.toLowerCase().includes(q) ||
192
+ cmd.subtitle?.toLowerCase().includes(q) ||
193
+ cmd.category.toLowerCase().includes(q)
194
+ );
195
+ },
196
+ };
197
+ }
198
+
199
+ /**
200
+ * Wrap a custom provider to auto-qualify permissions in results.
201
+ */
202
+ function createQualifiedProvider(
203
+ pluginMetadata: PluginMetadata,
204
+ provider: BackendSearchProvider
205
+ ): BackendSearchProvider {
206
+ return {
207
+ ...provider,
208
+ id: `${pluginMetadata.pluginId}.${provider.id}`,
209
+ search: async (query, context) => {
210
+ const results = await provider.search(query, context);
211
+
212
+ // Auto-qualify permission IDs in results
213
+ return results.map((result) => ({
214
+ ...result,
215
+ id: result.id.includes(".")
216
+ ? result.id
217
+ : `${pluginMetadata.pluginId}.${result.id}`,
218
+ requiredPermissions: result.requiredPermissions?.map((permId) =>
219
+ // If already qualified (contains the plugin ID prefix), leave it
220
+ permId.startsWith(`${pluginMetadata.pluginId}.`)
221
+ ? permId
222
+ : `${pluginMetadata.pluginId}.${permId}`
223
+ ),
224
+ }));
225
+ },
226
+ };
227
+ }
228
+
229
+ /**
230
+ * Capitalize first letter of a string.
231
+ */
232
+ function capitalize(str: string): string {
233
+ return str.charAt(0).toUpperCase() + str.slice(1);
234
+ }
235
+
236
+ /**
237
+ * Unregister a search provider.
238
+ */
239
+ export function unregisterSearchProvider(providerId: string): void {
240
+ searchProviders.delete(providerId);
241
+ }
242
+
243
+ /**
244
+ * Unregister all search providers for a given plugin ID.
245
+ * Called automatically when a plugin is deregistered.
246
+ * @returns The number of providers removed
247
+ */
248
+ export function unregisterProvidersByPluginId(pluginId: string): number {
249
+ let removed = 0;
250
+ for (const [id] of searchProviders) {
251
+ if (id.startsWith(`${pluginId}.`)) {
252
+ searchProviders.delete(id);
253
+ removed++;
254
+ }
255
+ }
256
+ return removed;
257
+ }
258
+
259
+ /**
260
+ * Get all registered search providers, sorted by priority (descending).
261
+ * @internal Used by the command router
262
+ */
263
+ export function getSearchProviders(): BackendSearchProvider[] {
264
+ return [...searchProviders.values()].toSorted(
265
+ (a, b) => (b.priority ?? 0) - (a.priority ?? 0)
266
+ );
267
+ }
268
+
269
+ /**
270
+ * Clear all registrations. Useful for testing.
271
+ */
272
+ export function clearRegistry(): void {
273
+ searchProviders.clear();
274
+ }
package/src/router.ts ADDED
@@ -0,0 +1,105 @@
1
+ import { implement } from "@orpc/server";
2
+ import {
3
+ autoAuthMiddleware,
4
+ type RpcContext,
5
+ } from "@checkstack/backend-api";
6
+ import {
7
+ commandContract,
8
+ filterByPermissions,
9
+ type SearchResult,
10
+ } from "@checkstack/command-common";
11
+ import { getSearchProviders } from "./registry";
12
+
13
+ /**
14
+ * Creates the command router using contract-based implementation.
15
+ *
16
+ * Auth and permissions are automatically enforced via autoAuthMiddleware
17
+ * based on the contract's meta.userType and meta.permissions.
18
+ */
19
+ const os = implement(commandContract)
20
+ .$context<RpcContext>()
21
+ .use(autoAuthMiddleware);
22
+
23
+ /**
24
+ * Extract permissions from the context user.
25
+ * Only RealUser and ApplicationUser have permissions; ServiceUser doesn't.
26
+ */
27
+ function getUserPermissions(context: RpcContext): string[] {
28
+ const user = context.user;
29
+ if (!user) return [];
30
+ if (user.type === "user" || user.type === "application") {
31
+ return user.permissions ?? [];
32
+ }
33
+ // ServiceUser has no permissions array - treated as having all permissions
34
+ // but for search filtering, return empty (no filtering applied)
35
+ return [];
36
+ }
37
+
38
+ export const createCommandRouter = () => {
39
+ /**
40
+ * Search across all registered search providers.
41
+ * Results are aggregated from all providers, filtered by permissions,
42
+ * and returned in priority order.
43
+ */
44
+ const search = os.search.handler(async ({ input, context }) => {
45
+ const providers = getSearchProviders();
46
+ const query = input.query.toLowerCase().trim();
47
+
48
+ // Get user permissions for filtering
49
+ const userPermissions = getUserPermissions(context);
50
+
51
+ // Execute all provider searches in parallel
52
+ const providerResults = await Promise.all(
53
+ providers.map(async (provider) => {
54
+ try {
55
+ const results = await provider.search(query, { userPermissions });
56
+ return results;
57
+ } catch (error) {
58
+ // Log but don't fail - one failing provider shouldn't break search
59
+ console.error(`Search provider ${provider.id} failed:`, error);
60
+ return [];
61
+ }
62
+ })
63
+ );
64
+
65
+ // Flatten and filter by permissions
66
+ const allResults = providerResults.flat();
67
+ return filterByPermissions(allResults, userPermissions);
68
+ });
69
+
70
+ /**
71
+ * Get all registered commands for browsing.
72
+ * Returns commands filtered by user permissions.
73
+ */
74
+ const getCommands = os.getCommands.handler(async ({ context }) => {
75
+ const providers = getSearchProviders();
76
+ const userPermissions = getUserPermissions(context);
77
+
78
+ // Get all results with empty query (commands return all when query is empty)
79
+ const providerResults = await Promise.all(
80
+ providers.map(async (provider) => {
81
+ try {
82
+ // Empty query = return all items
83
+ const results = await provider.search("", { userPermissions });
84
+ // Filter to only commands for this endpoint
85
+ return results.filter(
86
+ (r): r is SearchResult & { type: "command" } => r.type === "command"
87
+ );
88
+ } catch (error) {
89
+ console.error(`Search provider ${provider.id} failed:`, error);
90
+ return [];
91
+ }
92
+ })
93
+ );
94
+
95
+ const allCommands = providerResults.flat();
96
+ return filterByPermissions(allCommands, userPermissions);
97
+ });
98
+
99
+ return os.router({
100
+ search,
101
+ getCommands,
102
+ });
103
+ };
104
+
105
+ export type CommandRouter = ReturnType<typeof createCommandRouter>;
package/tsconfig.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "extends": "@checkstack/tsconfig/backend.json",
3
+ "include": [
4
+ "src"
5
+ ]
6
+ }