@beignet/cli 0.0.43 → 0.0.45

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 (81) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +61 -8
  3. package/dist/choices.d.ts +4 -0
  4. package/dist/choices.d.ts.map +1 -1
  5. package/dist/choices.js +9 -0
  6. package/dist/choices.js.map +1 -1
  7. package/dist/db.d.ts.map +1 -1
  8. package/dist/db.js +8 -4
  9. package/dist/db.js.map +1 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +13 -13
  12. package/dist/index.js.map +1 -1
  13. package/dist/inspect.js +78 -21
  14. package/dist/inspect.js.map +1 -1
  15. package/dist/lib.d.ts +4 -1
  16. package/dist/lib.d.ts.map +1 -1
  17. package/dist/lib.js +1 -1
  18. package/dist/lib.js.map +1 -1
  19. package/dist/mcp.d.ts +2 -1
  20. package/dist/mcp.d.ts.map +1 -1
  21. package/dist/mcp.js +354 -3
  22. package/dist/mcp.js.map +1 -1
  23. package/dist/operational-lifecycle.d.ts +12 -0
  24. package/dist/operational-lifecycle.d.ts.map +1 -0
  25. package/dist/operational-lifecycle.js +39 -0
  26. package/dist/operational-lifecycle.js.map +1 -0
  27. package/dist/operational-path.d.ts +3 -0
  28. package/dist/operational-path.d.ts.map +1 -0
  29. package/dist/operational-path.js +26 -0
  30. package/dist/operational-path.js.map +1 -0
  31. package/dist/operational-process.d.ts +78 -0
  32. package/dist/operational-process.d.ts.map +1 -0
  33. package/dist/operational-process.js +228 -0
  34. package/dist/operational-process.js.map +1 -0
  35. package/dist/operational-runner.d.ts +2 -0
  36. package/dist/operational-runner.d.ts.map +1 -0
  37. package/dist/operational-runner.js +168 -0
  38. package/dist/operational-runner.js.map +1 -0
  39. package/dist/outbox.d.ts +14 -6
  40. package/dist/outbox.d.ts.map +1 -1
  41. package/dist/outbox.js +140 -124
  42. package/dist/outbox.js.map +1 -1
  43. package/dist/schedule.d.ts +2 -1
  44. package/dist/schedule.d.ts.map +1 -1
  45. package/dist/schedule.js +79 -81
  46. package/dist/schedule.js.map +1 -1
  47. package/dist/task.d.ts +2 -1
  48. package/dist/task.d.ts.map +1 -1
  49. package/dist/task.js +57 -61
  50. package/dist/task.js.map +1 -1
  51. package/dist/templates/agents.d.ts.map +1 -1
  52. package/dist/templates/agents.js +21 -8
  53. package/dist/templates/agents.js.map +1 -1
  54. package/dist/templates/base.d.ts.map +1 -1
  55. package/dist/templates/base.js +2 -0
  56. package/dist/templates/base.js.map +1 -1
  57. package/dist/templates/server.d.ts.map +1 -1
  58. package/dist/templates/server.js +9 -2
  59. package/dist/templates/server.js.map +1 -1
  60. package/dist/templates/shadcn.d.ts.map +1 -1
  61. package/dist/templates/shadcn.js +3 -1
  62. package/dist/templates/shadcn.js.map +1 -1
  63. package/package.json +2 -2
  64. package/skills/app-structure/SKILL.md +19 -6
  65. package/src/choices.ts +19 -0
  66. package/src/db.ts +8 -4
  67. package/src/index.ts +19 -16
  68. package/src/inspect.ts +114 -26
  69. package/src/lib.ts +27 -1
  70. package/src/mcp.ts +457 -2
  71. package/src/operational-lifecycle.ts +56 -0
  72. package/src/operational-path.ts +35 -0
  73. package/src/operational-process.ts +454 -0
  74. package/src/operational-runner.ts +221 -0
  75. package/src/outbox.ts +154 -128
  76. package/src/schedule.ts +84 -89
  77. package/src/task.ts +58 -62
  78. package/src/templates/agents.ts +21 -8
  79. package/src/templates/base.ts +2 -0
  80. package/src/templates/server.ts +9 -2
  81. package/src/templates/shadcn.ts +3 -1
package/src/mcp.ts CHANGED
@@ -1,5 +1,9 @@
1
+ import { open, realpath } from "node:fs/promises";
1
2
  import path from "node:path";
2
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import {
4
+ McpServer,
5
+ ResourceTemplate,
6
+ } from "@modelcontextprotocol/sdk/server/mcp.js";
3
7
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
8
  import { z } from "zod";
5
9
  import { mapApp, projectAppMap } from "./app-map.js";
@@ -10,6 +14,8 @@ import {
10
14
  databaseSchemaDialectChoices,
11
15
  databaseSchemaTableChoices,
12
16
  doctorFixOperationIds,
17
+ outboxMessageKindChoices,
18
+ outboxMessageStatusChoices,
13
19
  type ProviderPresetName,
14
20
  providerPresetChoices,
15
21
  } from "./choices.js";
@@ -54,6 +60,12 @@ import {
54
60
  makeUpload,
55
61
  makeUseCase,
56
62
  } from "./make.js";
63
+ import {
64
+ defaultOperationalCommandTimeoutMs,
65
+ defaultOperationalResultMaxBytes,
66
+ isAppRelativeOperationalModulePath,
67
+ runIsolatedOperationalCommand,
68
+ } from "./operational-process.js";
57
69
  import {
58
70
  type AddProviderPresetResult,
59
71
  addProviderPreset,
@@ -92,6 +104,8 @@ const makeArtifactChoices = [
92
104
  ] as const;
93
105
 
94
106
  const defaultMcpCheckTimeoutMs = 10 * 60 * 1_000;
107
+ const maxMcpOperationalTimeoutMs = 60 * 60 * 1_000;
108
+ const maxMcpGuidanceBytes = 64 * 1024;
95
109
 
96
110
  type MakeArtifact = (typeof makeArtifactChoices)[number];
97
111
 
@@ -185,6 +199,123 @@ const providerAddInputSchema = {
185
199
 
186
200
  const providerPresetDescription = providerPresetChoices.join(", ");
187
201
 
202
+ const operationalTimeoutSchema = z
203
+ .number()
204
+ .int()
205
+ .min(1_000)
206
+ .max(maxMcpOperationalTimeoutMs)
207
+ .optional()
208
+ .describe(
209
+ "Maximum operation duration in milliseconds. Defaults to 600000 (10 minutes).",
210
+ );
211
+
212
+ const operationalModuleSchema = z
213
+ .string()
214
+ .min(1)
215
+ .refine(isAppRelativeOperationalModulePath, {
216
+ message: "Registry modules must stay inside the Beignet app directory.",
217
+ })
218
+ .optional()
219
+ .describe("Override the configured app-relative central registry module.");
220
+
221
+ const taskRunInputSchema = {
222
+ name: z.string().min(1).describe("Registered task name."),
223
+ input: z.unknown().optional().describe("Input validated by the task schema."),
224
+ tenant: z
225
+ .string()
226
+ .min(1)
227
+ .optional()
228
+ .describe(
229
+ "Tenant id or slug passed to createTaskContext, separate from task input.",
230
+ ),
231
+ module: operationalModuleSchema,
232
+ timeoutMs: operationalTimeoutSchema,
233
+ };
234
+
235
+ const scheduleRunInputSchema = {
236
+ name: z.string().min(1).describe("Registered schedule name."),
237
+ payload: z
238
+ .unknown()
239
+ .optional()
240
+ .describe("Payload validated by the schedule schema."),
241
+ runId: z.string().min(1).optional().describe("Provider or app run ID."),
242
+ attempt: z
243
+ .number()
244
+ .int()
245
+ .positive()
246
+ .optional()
247
+ .describe("One-based provider attempt number."),
248
+ scheduledAt: z
249
+ .string()
250
+ .min(1)
251
+ .optional()
252
+ .describe("Provider scheduled timestamp."),
253
+ triggeredAt: z
254
+ .string()
255
+ .min(1)
256
+ .optional()
257
+ .describe("Schedule trigger timestamp."),
258
+ source: z
259
+ .string()
260
+ .min(1)
261
+ .optional()
262
+ .describe("Provider or app source label."),
263
+ module: operationalModuleSchema,
264
+ timeoutMs: operationalTimeoutSchema,
265
+ };
266
+
267
+ const outboxInspectInputSchema = z.discriminatedUnion("operation", [
268
+ z.object({
269
+ operation: z.literal("list"),
270
+ status: z.enum(outboxMessageStatusChoices).optional(),
271
+ kind: z.enum(outboxMessageKindChoices).optional(),
272
+ name: z.string().min(1).optional(),
273
+ limit: z.number().int().positive().optional(),
274
+ module: operationalModuleSchema,
275
+ timeoutMs: operationalTimeoutSchema,
276
+ }),
277
+ z.object({
278
+ operation: z.literal("show"),
279
+ id: z.string().min(1),
280
+ module: operationalModuleSchema,
281
+ timeoutMs: operationalTimeoutSchema,
282
+ }),
283
+ ]);
284
+
285
+ const outboxRunInputSchema = z.discriminatedUnion("operation", [
286
+ z.object({
287
+ operation: z.literal("drain"),
288
+ batchSize: z.number().int().positive().optional(),
289
+ module: operationalModuleSchema,
290
+ timeoutMs: operationalTimeoutSchema,
291
+ }),
292
+ z.object({
293
+ operation: z.literal("requeue"),
294
+ id: z.string().min(1),
295
+ availableAt: z.string().min(1).optional(),
296
+ resetAttempts: z.boolean().optional(),
297
+ module: operationalModuleSchema,
298
+ timeoutMs: operationalTimeoutSchema,
299
+ }),
300
+ z.object({
301
+ operation: z.literal("purge"),
302
+ before: z.string().min(1).optional(),
303
+ all: z.boolean().optional(),
304
+ limit: z.number().int().positive().optional(),
305
+ dryRun: z.boolean().optional(),
306
+ module: operationalModuleSchema,
307
+ timeoutMs: operationalTimeoutSchema,
308
+ }),
309
+ z.object({
310
+ operation: z.literal("prune"),
311
+ before: z.string().min(1),
312
+ limit: z.number().int().positive().optional(),
313
+ dryRun: z.boolean().optional(),
314
+ module: operationalModuleSchema,
315
+ timeoutMs: operationalTimeoutSchema,
316
+ }),
317
+ ]);
318
+
188
319
  type MakeToolInput = {
189
320
  artifact: MakeArtifact;
190
321
  name?: string;
@@ -219,6 +350,17 @@ function jsonResult(value: unknown): McpToolResult {
219
350
  };
220
351
  }
221
352
 
353
+ function operationalJsonResult(value: unknown): McpToolResult {
354
+ const formatted = JSON.stringify(value, null, 2);
355
+ const text =
356
+ Buffer.byteLength(formatted) <= defaultOperationalResultMaxBytes
357
+ ? formatted
358
+ : JSON.stringify(value);
359
+ return {
360
+ content: [{ type: "text", text }],
361
+ };
362
+ }
363
+
222
364
  function errorResult(error: unknown): McpToolResult {
223
365
  return {
224
366
  isError: true,
@@ -350,7 +492,8 @@ async function runProviderAddTool(
350
492
  }
351
493
 
352
494
  /**
353
- * Build the Beignet MCP server with app-map, validation, and generator tools.
495
+ * Build the Beignet MCP server with app context, validation, generation, and
496
+ * operational tools.
354
497
  * Every tool resolves against the app directory captured at launch.
355
498
  */
356
499
  export function buildBeignetMcpServer(options: McpServerOptions): McpServer {
@@ -360,6 +503,80 @@ export function buildBeignetMcpServer(options: McpServerOptions): McpServer {
360
503
  version: options.version,
361
504
  });
362
505
 
506
+ server.registerResource(
507
+ "app-guidance",
508
+ "beignet://app/guidance",
509
+ {
510
+ title: "Beignet app guidance",
511
+ description:
512
+ "The app-local AGENTS.md instructions for working in this Beignet app.",
513
+ mimeType: "text/markdown",
514
+ },
515
+ async (uri) => ({
516
+ contents: [
517
+ {
518
+ uri: uri.href,
519
+ mimeType: "text/markdown",
520
+ text: await readAppGuidance(cwd),
521
+ },
522
+ ],
523
+ }),
524
+ );
525
+
526
+ const featureResourceTemplate = new ResourceTemplate(
527
+ "beignet://app/features/{feature}",
528
+ {
529
+ list: async () => {
530
+ const result = await mapApp({ cwd, strict: true });
531
+ return {
532
+ resources: appMapFeatureNames(result).map((feature) => ({
533
+ name: `${feature} app map`,
534
+ title: `Beignet feature: ${feature}`,
535
+ uri: `beignet://app/features/${encodeURIComponent(feature)}`,
536
+ description:
537
+ "Source-backed feature projection with direct relationships and diagnostics.",
538
+ mimeType: "application/json",
539
+ })),
540
+ };
541
+ },
542
+ complete: {
543
+ feature: async (value) => {
544
+ const result = await mapApp({ cwd, strict: true });
545
+ return appMapFeatureNames(result).filter((feature) =>
546
+ feature.startsWith(value),
547
+ );
548
+ },
549
+ },
550
+ },
551
+ );
552
+
553
+ server.registerResource(
554
+ "feature-app-map",
555
+ featureResourceTemplate,
556
+ {
557
+ title: "Beignet feature app map",
558
+ description:
559
+ "A focused source-backed app-map projection for one feature.",
560
+ mimeType: "application/json",
561
+ },
562
+ async (uri, variables) => {
563
+ const feature = resourceVariable(variables.feature, "feature");
564
+ const result = await mapApp({ cwd, strict: true });
565
+ if (!appMapFeatureNames(result).includes(feature)) {
566
+ throw new Error(`Unknown Beignet feature "${feature}".`);
567
+ }
568
+ return {
569
+ contents: [
570
+ {
571
+ uri: uri.href,
572
+ mimeType: "application/json",
573
+ text: JSON.stringify(projectAppMap(result, { feature }), null, 2),
574
+ },
575
+ ],
576
+ };
577
+ },
578
+ );
579
+
363
580
  server.registerTool(
364
581
  "app_map",
365
582
  {
@@ -561,6 +778,171 @@ export function buildBeignetMcpServer(options: McpServerOptions): McpServer {
561
778
  ),
562
779
  );
563
780
 
781
+ server.registerTool(
782
+ "task_run",
783
+ {
784
+ description:
785
+ "Run one registered app-owned operational task in an isolated process and return exactly the versioned beignet task run <name> --json report. The task input is schema-validated, output is bounded, and cancellation or timeout stops the complete process tree.",
786
+ inputSchema: taskRunInputSchema,
787
+ annotations: {
788
+ readOnlyHint: false,
789
+ destructiveHint: true,
790
+ idempotentHint: false,
791
+ openWorldHint: true,
792
+ },
793
+ },
794
+ async (input, extra) =>
795
+ safeToolResult(async () =>
796
+ operationalJsonResult(
797
+ await runIsolatedOperationalCommand({
798
+ cwd,
799
+ request: {
800
+ command: "task:run",
801
+ name: input.name,
802
+ input: input.input,
803
+ tenant: input.tenant,
804
+ modulePath: input.module,
805
+ },
806
+ signal: extra.signal,
807
+ timeoutMs: input.timeoutMs ?? defaultOperationalCommandTimeoutMs,
808
+ }),
809
+ ),
810
+ ),
811
+ );
812
+
813
+ server.registerTool(
814
+ "schedule_run",
815
+ {
816
+ description:
817
+ "Run one registered app-owned schedule in an isolated process and return exactly the versioned beignet schedule run <name> --json report. Payload and run metadata match the CLI; cancellation or timeout stops the complete process tree.",
818
+ inputSchema: scheduleRunInputSchema,
819
+ annotations: {
820
+ readOnlyHint: false,
821
+ destructiveHint: true,
822
+ idempotentHint: false,
823
+ openWorldHint: true,
824
+ },
825
+ },
826
+ async (input, extra) =>
827
+ safeToolResult(async () =>
828
+ operationalJsonResult(
829
+ await runIsolatedOperationalCommand({
830
+ cwd,
831
+ request: {
832
+ command: "schedule:run",
833
+ name: input.name,
834
+ payload: input.payload,
835
+ id: input.runId,
836
+ attempt: input.attempt,
837
+ scheduledAt: input.scheduledAt,
838
+ triggeredAt: input.triggeredAt,
839
+ source: input.source,
840
+ modulePath: input.module,
841
+ },
842
+ signal: extra.signal,
843
+ timeoutMs: input.timeoutMs ?? defaultOperationalCommandTimeoutMs,
844
+ }),
845
+ ),
846
+ ),
847
+ );
848
+
849
+ server.registerTool(
850
+ "outbox_inspect",
851
+ {
852
+ description:
853
+ "Read outbox messages with list or show and return exactly the matching versioned beignet outbox <operation> --json report. Runs in an isolated process with bounded structured output, cancellation, and a timeout.",
854
+ inputSchema: outboxInspectInputSchema,
855
+ annotations: {
856
+ readOnlyHint: true,
857
+ destructiveHint: false,
858
+ idempotentHint: true,
859
+ openWorldHint: true,
860
+ },
861
+ },
862
+ async (input, extra) =>
863
+ safeToolResult(async () =>
864
+ operationalJsonResult(
865
+ await runIsolatedOperationalCommand({
866
+ cwd,
867
+ request:
868
+ input.operation === "list"
869
+ ? {
870
+ command: "outbox:list",
871
+ status: input.status,
872
+ kind: input.kind,
873
+ name: input.name,
874
+ limit: input.limit,
875
+ modulePath: input.module,
876
+ }
877
+ : {
878
+ command: "outbox:show",
879
+ id: input.id,
880
+ modulePath: input.module,
881
+ },
882
+ signal: extra.signal,
883
+ timeoutMs: input.timeoutMs ?? defaultOperationalCommandTimeoutMs,
884
+ }),
885
+ ),
886
+ ),
887
+ );
888
+
889
+ server.registerTool(
890
+ "outbox_run",
891
+ {
892
+ description:
893
+ "Run a state-changing outbox drain, requeue, purge, or prune operation and return exactly the matching versioned beignet outbox <operation> --json report. Purge and prune support dryRun. Runs in an isolated process with bounded structured output, cancellation, and a timeout.",
894
+ inputSchema: outboxRunInputSchema,
895
+ annotations: {
896
+ readOnlyHint: false,
897
+ destructiveHint: true,
898
+ idempotentHint: false,
899
+ openWorldHint: true,
900
+ },
901
+ },
902
+ async (input, extra) =>
903
+ safeToolResult(async () => {
904
+ const request =
905
+ input.operation === "drain"
906
+ ? {
907
+ command: "outbox:drain" as const,
908
+ batchSize: input.batchSize,
909
+ modulePath: input.module,
910
+ }
911
+ : input.operation === "requeue"
912
+ ? {
913
+ command: "outbox:requeue" as const,
914
+ id: input.id,
915
+ availableAt: input.availableAt,
916
+ resetAttempts: input.resetAttempts,
917
+ modulePath: input.module,
918
+ }
919
+ : input.operation === "purge"
920
+ ? {
921
+ command: "outbox:purge" as const,
922
+ before: input.before,
923
+ all: input.all,
924
+ limit: input.limit,
925
+ dryRun: input.dryRun,
926
+ modulePath: input.module,
927
+ }
928
+ : {
929
+ command: "outbox:prune" as const,
930
+ before: input.before,
931
+ limit: input.limit,
932
+ dryRun: input.dryRun,
933
+ modulePath: input.module,
934
+ };
935
+ return operationalJsonResult(
936
+ await runIsolatedOperationalCommand({
937
+ cwd,
938
+ request,
939
+ signal: extra.signal,
940
+ timeoutMs: input.timeoutMs ?? defaultOperationalCommandTimeoutMs,
941
+ }),
942
+ );
943
+ }),
944
+ );
945
+
564
946
  server.registerTool(
565
947
  "routes",
566
948
  {
@@ -729,6 +1111,79 @@ export function buildBeignetMcpServer(options: McpServerOptions): McpServer {
729
1111
  return server;
730
1112
  }
731
1113
 
1114
+ async function readAppGuidance(cwd: string): Promise<string> {
1115
+ const guidancePath = await resolveAppGuidancePath(cwd);
1116
+ if (!guidancePath) {
1117
+ return "# Beignet app guidance\n\nNo app-local AGENTS.md was found. Use the app_map and explain tools before editing, prefer Beignet generators, and run check after changes.\n";
1118
+ }
1119
+
1120
+ let file: Awaited<ReturnType<typeof open>>;
1121
+ file = await open(guidancePath, "r");
1122
+
1123
+ try {
1124
+ const source = Buffer.alloc(maxMcpGuidanceBytes + 1);
1125
+ let bytesRead = 0;
1126
+ while (bytesRead < source.byteLength) {
1127
+ const chunk = await file.read(
1128
+ source,
1129
+ bytesRead,
1130
+ source.byteLength - bytesRead,
1131
+ bytesRead,
1132
+ );
1133
+ if (chunk.bytesRead === 0) break;
1134
+ bytesRead += chunk.bytesRead;
1135
+ }
1136
+ if (bytesRead <= maxMcpGuidanceBytes) {
1137
+ return source.subarray(0, bytesRead).toString("utf8");
1138
+ }
1139
+ return `${source.subarray(0, maxMcpGuidanceBytes).toString("utf8")}\n\n[Guidance truncated at ${maxMcpGuidanceBytes} bytes. Read AGENTS.md directly for the complete file.]\n`;
1140
+ } finally {
1141
+ await file.close();
1142
+ }
1143
+ }
1144
+
1145
+ async function resolveAppGuidancePath(
1146
+ cwd: string,
1147
+ ): Promise<string | undefined> {
1148
+ let guidancePath: string;
1149
+ try {
1150
+ guidancePath = await realpath(path.join(cwd, "AGENTS.md"));
1151
+ } catch (error) {
1152
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
1153
+ throw error;
1154
+ }
1155
+
1156
+ const appPath = await realpath(cwd);
1157
+ const relativePath = path.relative(appPath, guidancePath);
1158
+ if (
1159
+ relativePath === ".." ||
1160
+ relativePath.startsWith(`..${path.sep}`) ||
1161
+ path.isAbsolute(relativePath)
1162
+ ) {
1163
+ throw new Error("App guidance must stay inside the Beignet app directory.");
1164
+ }
1165
+ return guidancePath;
1166
+ }
1167
+
1168
+ function appMapFeatureNames(
1169
+ result: Awaited<ReturnType<typeof mapApp>>,
1170
+ ): string[] {
1171
+ return result.nodes
1172
+ .filter((node) => node.kind === "feature")
1173
+ .map((node) => node.name)
1174
+ .sort();
1175
+ }
1176
+
1177
+ function resourceVariable(
1178
+ value: string | string[] | undefined,
1179
+ name: string,
1180
+ ): string {
1181
+ if (typeof value !== "string" || value.length === 0) {
1182
+ throw new Error(`MCP resource variable "${name}" must be one value.`);
1183
+ }
1184
+ return value;
1185
+ }
1186
+
732
1187
  /**
733
1188
  * Run the Beignet MCP server over stdio until the client disconnects.
734
1189
  * Stdout belongs to the transport; diagnostics must go to stderr.
@@ -0,0 +1,56 @@
1
+ export class OperationalCleanupAfterCompletionError extends Error {
2
+ readonly operationCompleted = true;
3
+
4
+ constructor(operation: string, cleanup: string, cause: unknown) {
5
+ const cleanupError =
6
+ cause instanceof Error ? cause : new Error(String(cause));
7
+ super(
8
+ `${operation} completed, but ${cleanup} failed: ${cleanupError.message}`,
9
+ { cause: cleanupError },
10
+ );
11
+ this.name = "OperationalCleanupAfterCompletionError";
12
+ }
13
+ }
14
+
15
+ export function isOperationalCleanupAfterCompletionError(
16
+ error: unknown,
17
+ ): error is OperationalCleanupAfterCompletionError {
18
+ return error instanceof OperationalCleanupAfterCompletionError;
19
+ }
20
+
21
+ export async function runWithOperationalCleanup<Result>(options: {
22
+ operation: string;
23
+ cleanupLabel: string;
24
+ run: () => Promise<Result>;
25
+ cleanup: () => Promise<void>;
26
+ }): Promise<Result> {
27
+ let operationFailed = false;
28
+ let operationError: unknown;
29
+ let result!: Result;
30
+
31
+ try {
32
+ result = await options.run();
33
+ } catch (error) {
34
+ operationFailed = true;
35
+ operationError = error;
36
+ }
37
+
38
+ let cleanupFailed = false;
39
+ let cleanupError: unknown;
40
+ try {
41
+ await options.cleanup();
42
+ } catch (error) {
43
+ cleanupFailed = true;
44
+ cleanupError = error;
45
+ }
46
+
47
+ if (operationFailed) throw operationError;
48
+ if (cleanupFailed) {
49
+ throw new OperationalCleanupAfterCompletionError(
50
+ options.operation,
51
+ options.cleanupLabel,
52
+ cleanupError,
53
+ );
54
+ }
55
+ return result;
56
+ }
@@ -0,0 +1,35 @@
1
+ import { realpath } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ export function isAppRelativeOperationalModulePath(value: string): boolean {
5
+ if (path.isAbsolute(value) || path.win32.isAbsolute(value)) return false;
6
+ const normalized = path.posix.normalize(value.replaceAll("\\", "/"));
7
+ return normalized !== ".." && !normalized.startsWith("../");
8
+ }
9
+
10
+ export async function resolveAppOperationalModulePath(
11
+ cwd: string,
12
+ modulePath: string,
13
+ ): Promise<string> {
14
+ if (!isAppRelativeOperationalModulePath(modulePath)) {
15
+ throw operationalModuleOutsideAppError();
16
+ }
17
+
18
+ const appPath = await realpath(cwd);
19
+ const resolvedModulePath = await realpath(path.resolve(cwd, modulePath));
20
+ const relativePath = path.relative(appPath, resolvedModulePath);
21
+ if (
22
+ relativePath === ".." ||
23
+ relativePath.startsWith(`..${path.sep}`) ||
24
+ path.isAbsolute(relativePath)
25
+ ) {
26
+ throw operationalModuleOutsideAppError();
27
+ }
28
+ return resolvedModulePath;
29
+ }
30
+
31
+ function operationalModuleOutsideAppError(): Error {
32
+ return new Error(
33
+ "Registry modules must stay inside the Beignet app directory.",
34
+ );
35
+ }