@beignet/cli 0.0.51 → 0.0.53

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 (117) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +182 -19
  3. package/dist/analysis/layers.d.ts +2 -0
  4. package/dist/analysis/layers.d.ts.map +1 -1
  5. package/dist/analysis/layers.js +9 -2
  6. package/dist/analysis/layers.js.map +1 -1
  7. package/dist/analysis/port-wiring.d.ts +4 -2
  8. package/dist/analysis/port-wiring.d.ts.map +1 -1
  9. package/dist/analysis/port-wiring.js +41 -11
  10. package/dist/analysis/port-wiring.js.map +1 -1
  11. package/dist/analysis/source-files.d.ts +15 -0
  12. package/dist/analysis/source-files.d.ts.map +1 -0
  13. package/dist/analysis/source-files.js +52 -0
  14. package/dist/analysis/source-files.js.map +1 -0
  15. package/dist/analysis/source-index.d.ts +2 -1
  16. package/dist/analysis/source-index.d.ts.map +1 -1
  17. package/dist/analysis/source-index.js +34 -5
  18. package/dist/analysis/source-index.js.map +1 -1
  19. package/dist/analysis/workspace-routes.d.ts +8 -0
  20. package/dist/analysis/workspace-routes.d.ts.map +1 -0
  21. package/dist/analysis/workspace-routes.js +291 -0
  22. package/dist/analysis/workspace-routes.js.map +1 -0
  23. package/dist/analysis/workspace.d.ts +24 -3
  24. package/dist/analysis/workspace.d.ts.map +1 -1
  25. package/dist/analysis/workspace.js +184 -43
  26. package/dist/analysis/workspace.js.map +1 -1
  27. package/dist/app-map-changes.d.ts.map +1 -1
  28. package/dist/app-map-changes.js +24 -14
  29. package/dist/app-map-changes.js.map +1 -1
  30. package/dist/app-map.d.ts.map +1 -1
  31. package/dist/app-map.js +87 -81
  32. package/dist/app-map.js.map +1 -1
  33. package/dist/check.d.ts +12 -0
  34. package/dist/check.d.ts.map +1 -1
  35. package/dist/check.js +92 -25
  36. package/dist/check.js.map +1 -1
  37. package/dist/config.d.ts +7 -0
  38. package/dist/config.d.ts.map +1 -1
  39. package/dist/config.js +19 -0
  40. package/dist/config.js.map +1 -1
  41. package/dist/git-changes.d.ts +17 -1
  42. package/dist/git-changes.d.ts.map +1 -1
  43. package/dist/git-changes.js +118 -84
  44. package/dist/git-changes.js.map +1 -1
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +23 -3
  47. package/dist/index.js.map +1 -1
  48. package/dist/inspect.d.ts.map +1 -1
  49. package/dist/inspect.js +424 -129
  50. package/dist/inspect.js.map +1 -1
  51. package/dist/lint.d.ts.map +1 -1
  52. package/dist/lint.js +108 -43
  53. package/dist/lint.js.map +1 -1
  54. package/dist/make/shared.js +1 -1
  55. package/dist/make.js +18 -1
  56. package/dist/make.js.map +1 -1
  57. package/dist/mcp.js +1 -1
  58. package/dist/mcp.js.map +1 -1
  59. package/dist/provider-add.js +2 -2
  60. package/dist/provider-add.js.map +1 -1
  61. package/dist/provider-audit.d.ts.map +1 -1
  62. package/dist/provider-audit.js +22 -6
  63. package/dist/provider-audit.js.map +1 -1
  64. package/dist/templates/agents.d.ts.map +1 -1
  65. package/dist/templates/agents.js +36 -13
  66. package/dist/templates/agents.js.map +1 -1
  67. package/dist/templates/base.js +5 -5
  68. package/dist/templates/db/mysql.d.ts.map +1 -1
  69. package/dist/templates/db/mysql.js +51 -22
  70. package/dist/templates/db/mysql.js.map +1 -1
  71. package/dist/templates/db/postgres.d.ts.map +1 -1
  72. package/dist/templates/db/postgres.js +60 -20
  73. package/dist/templates/db/postgres.js.map +1 -1
  74. package/dist/templates/db/sqlite.d.ts.map +1 -1
  75. package/dist/templates/db/sqlite.js +51 -23
  76. package/dist/templates/db/sqlite.js.map +1 -1
  77. package/dist/templates/shadcn.js +1 -1
  78. package/dist/templates/shadcn.js.map +1 -1
  79. package/dist/templates/shared.js +6 -6
  80. package/dist/templates/shared.js.map +1 -1
  81. package/dist/templates/testing.d.ts.map +1 -1
  82. package/dist/templates/testing.js +7 -19
  83. package/dist/templates/testing.js.map +1 -1
  84. package/dist/test-discovery.d.ts +9 -0
  85. package/dist/test-discovery.d.ts.map +1 -0
  86. package/dist/test-discovery.js +40 -0
  87. package/dist/test-discovery.js.map +1 -0
  88. package/package.json +8 -5
  89. package/skills/app-structure/SKILL.md +74 -7
  90. package/src/analysis/layers.ts +13 -2
  91. package/src/analysis/port-wiring.ts +68 -11
  92. package/src/analysis/source-files.ts +61 -0
  93. package/src/analysis/source-index.ts +56 -4
  94. package/src/analysis/workspace-routes.ts +385 -0
  95. package/src/analysis/workspace.ts +302 -59
  96. package/src/app-map-changes.ts +41 -13
  97. package/src/app-map.ts +114 -103
  98. package/src/check.ts +115 -25
  99. package/src/config.ts +33 -0
  100. package/src/git-changes.ts +218 -86
  101. package/src/index.ts +33 -5
  102. package/src/inspect.ts +608 -156
  103. package/src/lint.ts +152 -48
  104. package/src/make/shared.ts +1 -1
  105. package/src/make.ts +31 -1
  106. package/src/mcp.ts +1 -1
  107. package/src/provider-add.ts +2 -2
  108. package/src/provider-audit.ts +30 -10
  109. package/src/templates/agents.ts +36 -13
  110. package/src/templates/base.ts +5 -5
  111. package/src/templates/db/mysql.ts +51 -22
  112. package/src/templates/db/postgres.ts +60 -20
  113. package/src/templates/db/sqlite.ts +51 -23
  114. package/src/templates/shadcn.ts +1 -1
  115. package/src/templates/shared.ts +6 -6
  116. package/src/templates/testing.ts +12 -19
  117. package/src/test-discovery.ts +53 -0
package/src/config.ts CHANGED
@@ -67,6 +67,12 @@ export type BeignetProviderAuditConfig = {
67
67
  ignoreRequiredEnv?: string[];
68
68
  };
69
69
 
70
+ /** Source packages included in read-only application analysis. */
71
+ export type BeignetWorkspaceConfig = {
72
+ /** Package directories relative to this app, including transitive dependencies. */
73
+ packages: string[];
74
+ };
75
+
70
76
  /**
71
77
  * User-authored Beignet CLI config.
72
78
  */
@@ -75,6 +81,7 @@ export type BeignetConfig = {
75
81
  paths?: Partial<BeignetPaths>;
76
82
  database?: BeignetDatabaseConfig;
77
83
  providerAudit?: BeignetProviderAuditConfig;
84
+ workspace?: BeignetWorkspaceConfig;
78
85
  };
79
86
 
80
87
  /**
@@ -90,6 +97,7 @@ export type ResolvedBeignetConfig = {
90
97
  providerAudit: {
91
98
  ignoreRequiredEnv: string[];
92
99
  };
100
+ workspace: BeignetWorkspaceConfig;
93
101
  configFile?: string;
94
102
  };
95
103
 
@@ -134,6 +142,7 @@ export const defaultBeignetConfig: ResolvedBeignetConfig = {
134
142
  providerAudit: {
135
143
  ignoreRequiredEnv: [],
136
144
  },
145
+ workspace: { packages: [] },
137
146
  };
138
147
 
139
148
  /**
@@ -174,6 +183,7 @@ export function resolveConfig(
174
183
  paths: normalizeConfigPaths(paths),
175
184
  database: normalizeDatabaseConfig(database),
176
185
  providerAudit: normalizeProviderAuditConfig(providerAudit),
186
+ workspace: normalizeWorkspaceConfig(config.workspace),
177
187
  configFile,
178
188
  };
179
189
  }
@@ -453,6 +463,29 @@ function normalizeIgnoredRequiredEnv(index: number, value: string): string {
453
463
  return value.trim();
454
464
  }
455
465
 
466
+ function normalizeWorkspaceConfig(
467
+ workspace: BeignetWorkspaceConfig | undefined,
468
+ ): BeignetWorkspaceConfig {
469
+ if (workspace === undefined) return { packages: [] };
470
+ if (!isRecord(workspace) || !Array.isArray(workspace.packages)) {
471
+ throw new Error("Beignet config workspace.packages must be an array.");
472
+ }
473
+ const packages = workspace.packages.map((value, index) => {
474
+ if (
475
+ typeof value !== "string" ||
476
+ !value.trim() ||
477
+ path.isAbsolute(value.trim()) ||
478
+ path.win32.isAbsolute(value.trim())
479
+ ) {
480
+ throw new Error(
481
+ `Beignet config workspace.packages[${index}] must be a relative package directory.`,
482
+ );
483
+ }
484
+ return path.posix.normalize(normalizePath(value.trim())).replace(/\/$/, "");
485
+ });
486
+ return { packages: [...new Set(packages)].sort() };
487
+ }
488
+
456
489
  function isRecord(value: unknown): value is Record<string, unknown> {
457
490
  return typeof value === "object" && value !== null && !Array.isArray(value);
458
491
  }
@@ -1,7 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import type { Stats } from "node:fs";
3
3
  import { createReadStream } from "node:fs";
4
- import { lstat, readlink } from "node:fs/promises";
4
+ import { lstat, readlink, realpath } from "node:fs/promises";
5
5
  import path from "node:path";
6
6
  import { spawnCommand } from "./db.js";
7
7
 
@@ -58,17 +58,35 @@ export type GitChangeSet = {
58
58
  files: GitChangedFile[];
59
59
  };
60
60
 
61
+ /** One reproducible snapshot of the repository state around an app check. */
62
+ export type GitSourceSnapshot =
63
+ | {
64
+ kind: "git";
65
+ repositoryRoot: string;
66
+ comparison: GitChangeComparison;
67
+ repositoryChangeHash: string;
68
+ }
69
+ | {
70
+ kind: "unavailable";
71
+ reason: "not-a-git-worktree";
72
+ };
73
+
61
74
  export async function readGitChangeSet(options: {
62
75
  cwd: string;
63
76
  base?: string;
77
+ signal?: AbortSignal;
64
78
  }): Promise<GitChangeSet> {
65
79
  if (options.base !== undefined && options.base.length === 0) {
66
80
  throw new Error("Git base must not be empty.");
67
81
  }
68
82
  const repositoryRoot = path.resolve(
69
- await gitText(options.cwd, ["rev-parse", "--show-toplevel"]),
83
+ await gitText(
84
+ options.cwd,
85
+ ["rev-parse", "--show-toplevel"],
86
+ options.signal,
87
+ ),
70
88
  );
71
- const head = await resolveHeadCommit(repositoryRoot);
89
+ const head = await resolveHeadCommit(repositoryRoot, options.signal);
72
90
 
73
91
  let comparison: GitChangeComparison;
74
92
  let comparisonCommit: string;
@@ -78,21 +96,22 @@ export async function readGitChangeSet(options: {
78
96
  `Cannot compare --base ${JSON.stringify(options.base)} because this repository does not have a HEAD commit.`,
79
97
  );
80
98
  }
81
- const baseCommit = await gitText(repositoryRoot, [
82
- "rev-parse",
83
- "--verify",
84
- "--end-of-options",
85
- `${options.base}^{commit}`,
86
- ]).catch(() => {
99
+ const baseCommit = await gitText(
100
+ repositoryRoot,
101
+ ["rev-parse", "--verify", "--end-of-options", `${options.base}^{commit}`],
102
+ options.signal,
103
+ ).catch(() => {
104
+ options.signal?.throwIfAborted();
87
105
  throw new Error(
88
106
  `Could not resolve local Git base ${JSON.stringify(options.base)}. Fetch it before running Beignet; changed mapping never accesses the network.`,
89
107
  );
90
108
  });
91
- const mergeBase = await gitText(repositoryRoot, [
92
- "merge-base",
93
- baseCommit,
94
- head,
95
- ]).catch(() => {
109
+ const mergeBase = await gitText(
110
+ repositoryRoot,
111
+ ["merge-base", baseCommit, head],
112
+ options.signal,
113
+ ).catch(() => {
114
+ options.signal?.throwIfAborted();
96
115
  throw new Error(
97
116
  `Could not find a merge base between ${JSON.stringify(options.base)} and HEAD.`,
98
117
  );
@@ -106,7 +125,8 @@ export async function readGitChangeSet(options: {
106
125
  head,
107
126
  };
108
127
  } else {
109
- comparisonCommit = head ?? (await emptyTreeObjectId(repositoryRoot));
128
+ comparisonCommit =
129
+ head ?? (await emptyTreeObjectId(repositoryRoot, options.signal));
110
130
  comparison = {
111
131
  mode: "worktree",
112
132
  comparisonCommit,
@@ -116,22 +136,23 @@ export async function readGitChangeSet(options: {
116
136
 
117
137
  const committed =
118
138
  comparison.mode === "base"
119
- ? await gitDiffOutput(repositoryRoot, [
120
- comparison.mergeBase,
121
- comparison.head,
122
- ])
139
+ ? await gitDiffOutput(
140
+ repositoryRoot,
141
+ [comparison.mergeBase, comparison.head],
142
+ options.signal,
143
+ )
123
144
  : "";
124
- const staged = await gitDiffOutput(repositoryRoot, [
125
- "--cached",
126
- head ?? comparisonCommit,
127
- ]);
128
- const unstaged = await gitDiffOutput(repositoryRoot, []);
129
- const untracked = await gitOutput(repositoryRoot, [
130
- "ls-files",
131
- "--others",
132
- "--exclude-standard",
133
- "-z",
134
- ]);
145
+ const staged = await gitDiffOutput(
146
+ repositoryRoot,
147
+ ["--cached", head ?? comparisonCommit],
148
+ options.signal,
149
+ );
150
+ const unstaged = await gitDiffOutput(repositoryRoot, [], options.signal);
151
+ const untracked = await gitOutput(
152
+ repositoryRoot,
153
+ ["ls-files", "--others", "--exclude-standard", "-z"],
154
+ options.signal,
155
+ );
135
156
  const files = mergeChangedFiles(
136
157
  parseNameStatus(committed),
137
158
  parseNameStatus(staged),
@@ -142,12 +163,43 @@ export async function readGitChangeSet(options: {
142
163
  return { repositoryRoot, comparison, files };
143
164
  }
144
165
 
145
- async function emptyTreeObjectId(repositoryRoot: string): Promise<string> {
166
+ /** Capture Git-backed source state, or report that no worktree is available. */
167
+ export async function readGitSourceSnapshot(options: {
168
+ cwd: string;
169
+ signal?: AbortSignal;
170
+ }): Promise<GitSourceSnapshot> {
171
+ try {
172
+ const changeSet = await readGitChangeSet(options);
173
+ const repositoryChangeHash = await hashGitChanges(
174
+ changeSet,
175
+ changeSet.files,
176
+ options.signal,
177
+ );
178
+ options.signal?.throwIfAborted();
179
+ return {
180
+ kind: "git",
181
+ repositoryRoot: changeSet.repositoryRoot,
182
+ comparison: changeSet.comparison,
183
+ repositoryChangeHash,
184
+ };
185
+ } catch (error) {
186
+ options.signal?.throwIfAborted();
187
+ if (await hasGitMetadata(options.cwd, options.signal)) throw error;
188
+ options.signal?.throwIfAborted();
189
+ return { kind: "unavailable", reason: "not-a-git-worktree" };
190
+ }
191
+ }
192
+
193
+ async function emptyTreeObjectId(
194
+ repositoryRoot: string,
195
+ signal?: AbortSignal,
196
+ ): Promise<string> {
146
197
  const objectFormat =
147
- (await tryGitText(repositoryRoot, [
148
- "rev-parse",
149
- "--show-object-format=storage",
150
- ])) ?? "sha1";
198
+ (await tryGitText(
199
+ repositoryRoot,
200
+ ["rev-parse", "--show-object-format=storage"],
201
+ signal,
202
+ )) ?? "sha1";
151
203
  const objectId =
152
204
  emptyTreeObjectIds[objectFormat as keyof typeof emptyTreeObjectIds];
153
205
  if (!objectId) {
@@ -161,13 +213,18 @@ async function emptyTreeObjectId(repositoryRoot: string): Promise<string> {
161
213
  export async function hashGitChanges(
162
214
  changeSet: Pick<GitChangeSet, "comparison" | "files" | "repositoryRoot">,
163
215
  files = changeSet.files,
216
+ signal?: AbortSignal,
164
217
  ): Promise<string> {
165
218
  const hash = createHash("sha256");
166
- const indexEntries = await readGitIndexEntries(changeSet.repositoryRoot);
219
+ const indexEntries = await readGitIndexEntries(
220
+ changeSet.repositoryRoot,
221
+ signal,
222
+ );
167
223
  hash.update(JSON.stringify(changeSet.comparison));
168
224
  hash.update("\0");
169
225
 
170
226
  for (const file of [...files].sort(compareChangedFiles)) {
227
+ signal?.throwIfAborted();
171
228
  hash.update(
172
229
  JSON.stringify({
173
230
  status: file.status,
@@ -181,7 +238,12 @@ export async function hashGitChanges(
181
238
  hash.update("\0index\0");
182
239
  hash.update(indexEntries.get(relativePath) ?? "missing");
183
240
  hash.update("\0worktree\0");
184
- await hashCurrentPath(hash, changeSet.repositoryRoot, relativePath);
241
+ await hashCurrentPath(
242
+ hash,
243
+ changeSet.repositoryRoot,
244
+ relativePath,
245
+ signal,
246
+ );
185
247
  hash.update("\0");
186
248
  }
187
249
  }
@@ -191,8 +253,13 @@ export async function hashGitChanges(
191
253
 
192
254
  async function readGitIndexEntries(
193
255
  repositoryRoot: string,
256
+ signal?: AbortSignal,
194
257
  ): Promise<Map<string, string>> {
195
- const output = await gitOutput(repositoryRoot, ["ls-files", "--stage", "-z"]);
258
+ const output = await gitOutput(
259
+ repositoryRoot,
260
+ ["ls-files", "--stage", "-z"],
261
+ signal,
262
+ );
196
263
  const entries = new Map<string, string[]>();
197
264
  for (const field of output.split("\0").filter(Boolean)) {
198
265
  const separator = field.indexOf("\t");
@@ -214,39 +281,44 @@ async function hashCurrentPath(
214
281
  hash: ReturnType<typeof createHash>,
215
282
  repositoryRoot: string,
216
283
  relativePath: string,
284
+ signal?: AbortSignal,
217
285
  ): Promise<void> {
286
+ signal?.throwIfAborted();
218
287
  const absolutePath = path.join(repositoryRoot, relativePath);
219
288
  let stats: Stats;
220
289
  try {
221
290
  stats = await lstat(absolutePath);
222
291
  } catch (error) {
292
+ signal?.throwIfAborted();
223
293
  if ((error as NodeJS.ErrnoException).code === "ENOENT") {
224
294
  hash.update("missing");
225
295
  return;
226
296
  }
227
297
  throw error;
228
298
  }
299
+ signal?.throwIfAborted();
229
300
 
230
301
  if (stats.isSymbolicLink()) {
302
+ const target = await readlink(absolutePath);
303
+ signal?.throwIfAborted();
231
304
  hash.update("symlink\0");
232
305
  hash.update(String(stats.mode));
233
306
  hash.update("\0");
234
- hash.update(await readlink(absolutePath));
307
+ hash.update(target);
235
308
  return;
236
309
  }
237
310
  if (stats.isDirectory()) {
238
311
  hash.update("directory\0");
239
- const head = await tryGitText(absolutePath, [
240
- "rev-parse",
241
- "--verify",
242
- "HEAD^{commit}",
243
- ]);
244
- const status = await tryGitOutput(absolutePath, [
245
- "status",
246
- "--porcelain=v1",
247
- "-z",
248
- "--untracked-files=all",
249
- ]);
312
+ const head = await tryGitText(
313
+ absolutePath,
314
+ ["rev-parse", "--verify", "HEAD^{commit}"],
315
+ signal,
316
+ );
317
+ const status = await tryGitOutput(
318
+ absolutePath,
319
+ ["status", "--porcelain=v1", "-z", "--untracked-files=all"],
320
+ signal,
321
+ );
250
322
  hash.update(head ?? "unknown-head");
251
323
  hash.update("\0");
252
324
  hash.update(status ?? "unknown-status");
@@ -260,8 +332,47 @@ async function hashCurrentPath(
260
332
  hash.update("file\0");
261
333
  hash.update(String(stats.mode));
262
334
  hash.update("\0");
263
- for await (const chunk of createReadStream(absolutePath)) {
264
- hash.update(chunk as Buffer);
335
+ try {
336
+ for await (const chunk of createReadStream(absolutePath, { signal })) {
337
+ hash.update(chunk as Buffer);
338
+ }
339
+ } catch (error) {
340
+ signal?.throwIfAborted();
341
+ throw error;
342
+ }
343
+ signal?.throwIfAborted();
344
+ }
345
+
346
+ async function hasGitMetadata(
347
+ cwd: string,
348
+ signal?: AbortSignal,
349
+ ): Promise<boolean> {
350
+ signal?.throwIfAborted();
351
+ if (process.env.GIT_DIR || process.env.GIT_WORK_TREE) return true;
352
+ let root = path.resolve(cwd);
353
+ try {
354
+ root = await realpath(root);
355
+ } catch (error) {
356
+ signal?.throwIfAborted();
357
+ const code = (error as NodeJS.ErrnoException).code;
358
+ if (code !== "ENOENT" && code !== "ENOTDIR") throw error;
359
+ }
360
+
361
+ let current = root;
362
+ while (true) {
363
+ signal?.throwIfAborted();
364
+ try {
365
+ await lstat(path.join(current, ".git"));
366
+ signal?.throwIfAborted();
367
+ return true;
368
+ } catch (error) {
369
+ signal?.throwIfAborted();
370
+ const code = (error as NodeJS.ErrnoException).code;
371
+ if (code !== "ENOENT" && code !== "ENOTDIR") throw error;
372
+ }
373
+ const parent = path.dirname(current);
374
+ if (parent === current) return false;
375
+ current = parent;
265
376
  }
266
377
  }
267
378
 
@@ -397,36 +508,45 @@ function normalizeGitPath(file: string): string {
397
508
  return process.platform === "win32" ? file.replaceAll("\\", "/") : file;
398
509
  }
399
510
 
400
- async function gitText(cwd: string, args: readonly string[]): Promise<string> {
401
- return (await gitOutput(cwd, args)).trim();
511
+ async function gitText(
512
+ cwd: string,
513
+ args: readonly string[],
514
+ signal?: AbortSignal,
515
+ ): Promise<string> {
516
+ return stripFinalLineEnding(await gitOutput(cwd, args, signal));
517
+ }
518
+
519
+ function stripFinalLineEnding(output: string): string {
520
+ if (output.endsWith("\n")) return output.slice(0, -1);
521
+ return output;
402
522
  }
403
523
 
404
524
  async function resolveHeadCommit(
405
525
  repositoryRoot: string,
526
+ signal?: AbortSignal,
406
527
  ): Promise<string | undefined> {
407
- const head = await runGit(repositoryRoot, [
408
- "rev-parse",
409
- "--verify",
410
- "HEAD^{commit}",
411
- ]);
528
+ const head = await runGit(
529
+ repositoryRoot,
530
+ ["rev-parse", "--verify", "HEAD^{commit}"],
531
+ signal,
532
+ );
412
533
  if (head.exitCode === 0) return head.stdout.trim();
413
534
 
414
- const symbolicHead = await runGit(repositoryRoot, [
415
- "symbolic-ref",
416
- "--quiet",
417
- "HEAD",
418
- ]);
535
+ const symbolicHead = await runGit(
536
+ repositoryRoot,
537
+ ["symbolic-ref", "--quiet", "HEAD"],
538
+ signal,
539
+ );
419
540
  if (symbolicHead.exitCode !== 0) {
420
541
  throw gitCommandError(["rev-parse", "--verify", "HEAD^{commit}"], head);
421
542
  }
422
543
 
423
544
  const ref = symbolicHead.stdout.trim();
424
- const refExists = await runGit(repositoryRoot, [
425
- "show-ref",
426
- "--verify",
427
- "--quiet",
428
- ref,
429
- ]);
545
+ const refExists = await runGit(
546
+ repositoryRoot,
547
+ ["show-ref", "--verify", "--quiet", ref],
548
+ signal,
549
+ );
430
550
  if (refExists.exitCode === 1) return undefined;
431
551
  throw gitCommandError(["rev-parse", "--verify", "HEAD^{commit}"], head);
432
552
  }
@@ -434,10 +554,12 @@ async function resolveHeadCommit(
434
554
  async function tryGitText(
435
555
  cwd: string,
436
556
  args: readonly string[],
557
+ signal?: AbortSignal,
437
558
  ): Promise<string | undefined> {
438
559
  try {
439
- return await gitText(cwd, args);
560
+ return await gitText(cwd, args, signal);
440
561
  } catch {
562
+ signal?.throwIfAborted();
441
563
  return undefined;
442
564
  }
443
565
  }
@@ -445,10 +567,12 @@ async function tryGitText(
445
567
  async function tryGitOutput(
446
568
  cwd: string,
447
569
  args: readonly string[],
570
+ signal?: AbortSignal,
448
571
  ): Promise<string | undefined> {
449
572
  try {
450
- return await gitOutput(cwd, args);
573
+ return await gitOutput(cwd, args, signal);
451
574
  } catch {
575
+ signal?.throwIfAborted();
452
576
  return undefined;
453
577
  }
454
578
  }
@@ -456,8 +580,9 @@ async function tryGitOutput(
456
580
  async function gitOutput(
457
581
  cwd: string,
458
582
  args: readonly string[],
583
+ signal?: AbortSignal,
459
584
  ): Promise<string> {
460
- const result = await runGit(cwd, args);
585
+ const result = await runGit(cwd, args, signal);
461
586
  if (result.exitCode !== 0) throw gitCommandError(args, result);
462
587
  return result.stdout;
463
588
  }
@@ -465,14 +590,16 @@ async function gitOutput(
465
590
  async function runGit(
466
591
  cwd: string,
467
592
  args: readonly string[],
593
+ signal?: AbortSignal,
468
594
  ): Promise<GitCommandResult> {
469
595
  const result = await spawnCommand("git", args, cwd, {
470
596
  captureOutput: true,
471
597
  maxOutputBytes: maxGitOutputBytes,
598
+ signal,
472
599
  });
473
600
  if (result.outputTruncated) {
474
601
  throw new Error(
475
- `Git output exceeded ${maxGitOutputBytes} bytes; changed mapping cannot produce a complete report.`,
602
+ `Git output exceeded ${maxGitOutputBytes} bytes; source inspection cannot produce a complete report.`,
476
603
  );
477
604
  }
478
605
  return {
@@ -495,17 +622,22 @@ function gitCommandError(
495
622
  async function gitDiffOutput(
496
623
  repositoryRoot: string,
497
624
  comparisonArgs: readonly string[],
625
+ signal?: AbortSignal,
498
626
  ): Promise<string> {
499
- return gitOutput(repositoryRoot, [
500
- "diff",
501
- "--no-ext-diff",
502
- "--no-textconv",
503
- "--name-status",
504
- "-z",
505
- "--find-renames=50%",
506
- "--find-copies=50%",
507
- "--find-copies-harder",
508
- ...comparisonArgs,
509
- "--",
510
- ]);
627
+ return gitOutput(
628
+ repositoryRoot,
629
+ [
630
+ "diff",
631
+ "--no-ext-diff",
632
+ "--no-textconv",
633
+ "--name-status",
634
+ "-z",
635
+ "--find-renames=50%",
636
+ "--find-copies=50%",
637
+ "--find-copies-harder",
638
+ ...comparisonArgs,
639
+ "--",
640
+ ],
641
+ signal,
642
+ );
511
643
  }
package/src/index.ts CHANGED
@@ -1164,7 +1164,7 @@ const checkCommand = buildCommand<CheckFlags, [], CliContext>({
1164
1164
  docs: {
1165
1165
  brief: "Run the full app validation loop as one command.",
1166
1166
  fullDescription:
1167
- "Runs beignet lint, beignet doctor --strict, optional disconnected or connected preflight, and the app's lint, typecheck, and test package scripts through the detected package manager. Every step runs even when an earlier one fails, so one run reports everything. Missing package scripts are skipped, not failed.",
1167
+ "Runs beignet lint, beignet doctor --strict, optional disconnected or connected preflight, and the app's lint, typecheck, and test package scripts through the detected package manager. Every step runs even when an earlier one fails, so one run reports everything. Missing package scripts are skipped, not failed. Inside Git, the command fingerprints source before and after validation and fails stale results whose ending fingerprint differs from the baseline.",
1168
1168
  },
1169
1169
  parameters: {
1170
1170
  flags: {
@@ -1194,9 +1194,12 @@ const checkCommand = buildCommand<CheckFlags, [], CliContext>({
1194
1194
  } satisfies FlagParametersForType<CheckFlags, CliContext>,
1195
1195
  },
1196
1196
  loader: async () => {
1197
- const { checkApp, formatCheckStep, formatCheckSummary } = await import(
1198
- "./check.js"
1199
- );
1197
+ const {
1198
+ checkApp,
1199
+ formatCheckProvenance,
1200
+ formatCheckStep,
1201
+ formatCheckSummary,
1202
+ } = await import("./check.js");
1200
1203
 
1201
1204
  return async function runCheck(this: CliContext, flags: CheckFlags) {
1202
1205
  const color = useColor();
@@ -1218,7 +1221,7 @@ const checkCommand = buildCommand<CheckFlags, [], CliContext>({
1218
1221
  this,
1219
1222
  flags.json
1220
1223
  ? JSON.stringify(result, null, 2)
1221
- : `\n${formatCheckSummary(result, { color })}`,
1224
+ : `\n${formatCheckProvenance(result, { color })}\n${formatCheckSummary(result, { color })}`,
1222
1225
  );
1223
1226
  if (!result.ok) {
1224
1227
  this.process.exitCode = 1;
@@ -1850,6 +1853,30 @@ const scheduleRoutes = buildRouteMap({
1850
1853
  },
1851
1854
  });
1852
1855
 
1856
+ const encryptionKeyCommand = buildCommand<{ json?: boolean }, [], CliContext>({
1857
+ docs: {
1858
+ brief: "Print a new encryption key without changing environment files.",
1859
+ },
1860
+ parameters: {
1861
+ flags: { json: jsonFlag },
1862
+ },
1863
+ loader: async () => {
1864
+ const { generateEncryptionKey } = await import("@beignet/core/encryption");
1865
+ return async function runEncryptionKey(this: CliContext, flags) {
1866
+ const key = generateEncryptionKey();
1867
+ writeOutput(
1868
+ this,
1869
+ flags.json ? JSON.stringify({ schemaVersion: 1, key }) : key,
1870
+ );
1871
+ };
1872
+ },
1873
+ });
1874
+
1875
+ const encryptionRoutes = buildRouteMap({
1876
+ docs: { brief: "Manage application encryption." },
1877
+ routes: { key: encryptionKeyCommand },
1878
+ });
1879
+
1853
1880
  const completionShellFlagParameters = {
1854
1881
  shell: {
1855
1882
  kind: "enum",
@@ -2446,6 +2473,7 @@ Run npm create beignet@latest (or bun create beignet) to scaffold a new app.`,
2446
2473
  create: createCommand,
2447
2474
  db: dbRoutes,
2448
2475
  doctor: doctorCommand,
2476
+ encryption: encryptionRoutes,
2449
2477
  explain: explainRoutes,
2450
2478
  lint: lintCommand,
2451
2479
  map: mapCommand,