@gmickel/gno 1.24.0 → 1.26.0

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 (96) hide show
  1. package/README.md +18 -4
  2. package/assets/skill/SKILL.md +40 -17
  3. package/assets/skill/recipes/capture-and-file.md +20 -5
  4. package/browser-extension/artifacts/gno-browser-clipper-v1.26.0.zip +0 -0
  5. package/browser-extension/artifacts/gno-browser-clipper-v1.26.0.zip.sha256 +1 -0
  6. package/browser-extension/dist/PRIVACY.md +55 -0
  7. package/browser-extension/dist/chunk-vn5f663b.js +50 -0
  8. package/browser-extension/dist/chunk-ydfx5d7p.css +1 -0
  9. package/browser-extension/dist/content.js +1 -0
  10. package/browser-extension/dist/manifest.json +25 -0
  11. package/browser-extension/dist/preview.html +13 -0
  12. package/browser-extension/dist/service-worker.js +40 -0
  13. package/package.json +13 -3
  14. package/spec/cli.md +196 -4
  15. package/spec/db/schema.sql +102 -1
  16. package/spec/mcp.md +10 -0
  17. package/spec/output-schemas/browser-clip-preview.schema.json +83 -0
  18. package/spec/output-schemas/browser-clip.schema.json +586 -0
  19. package/spec/output-schemas/capture-receipt.schema.json +22 -1
  20. package/spec/output-schemas/clipper-csrf.schema.json +12 -0
  21. package/spec/output-schemas/clipper-error.schema.json +46 -0
  22. package/spec/output-schemas/clipper-pair-approval.schema.json +17 -0
  23. package/spec/output-schemas/clipper-pair-start.schema.json +26 -0
  24. package/spec/output-schemas/clipper-pair-status.schema.json +46 -0
  25. package/spec/output-schemas/clipper-revoke.schema.json +28 -0
  26. package/spec/output-schemas/mcp-capture-result.schema.json +12 -1
  27. package/spec/output-schemas/project-profile-apply.schema.json +209 -0
  28. package/spec/output-schemas/project-profile-command.schema.json +160 -0
  29. package/spec/output-schemas/query-diagnose.schema.json +7 -1
  30. package/spec/output-schemas/setup-profile-result.schema.json +87 -0
  31. package/spec/project-profile.schema.json +301 -0
  32. package/src/cli/commands/collection/add.ts +39 -45
  33. package/src/cli/commands/collection/remove.ts +28 -28
  34. package/src/cli/commands/collection/rename.ts +55 -73
  35. package/src/cli/commands/context/add.ts +37 -26
  36. package/src/cli/commands/context/rm.ts +47 -20
  37. package/src/cli/commands/init.ts +55 -125
  38. package/src/cli/commands/models/use.ts +43 -38
  39. package/src/cli/commands/profile-apply.ts +334 -0
  40. package/src/cli/commands/profile.ts +409 -0
  41. package/src/cli/commands/setup-activation.ts +205 -54
  42. package/src/cli/commands/setup-profile.ts +223 -0
  43. package/src/cli/commands/setup.ts +3 -0
  44. package/src/cli/program.ts +110 -9
  45. package/src/config/index.ts +3 -0
  46. package/src/config/project-profile.ts +367 -0
  47. package/src/config/saver.ts +16 -7
  48. package/src/config/types.ts +42 -0
  49. package/src/core/browser-clip-provenance.ts +139 -0
  50. package/src/core/browser-clip.ts +473 -0
  51. package/src/core/capture-write.ts +5 -0
  52. package/src/core/capture.ts +75 -18
  53. package/src/core/config-mutation.ts +138 -76
  54. package/src/core/config-write-lock.ts +89 -0
  55. package/src/core/context-identity.ts +16 -0
  56. package/src/core/context-resolver.ts +2 -12
  57. package/src/core/file-lock.ts +20 -6
  58. package/src/core/folder-setup-planning.ts +6 -21
  59. package/src/core/folder-setup.ts +30 -2
  60. package/src/core/path-rules.ts +53 -0
  61. package/src/core/project-affinity-surface.ts +102 -7
  62. package/src/core/project-profile-apply-state.ts +268 -0
  63. package/src/core/project-profile-apply-validation.ts +95 -0
  64. package/src/core/project-profile-apply.ts +408 -0
  65. package/src/core/project-profile-canonical.ts +71 -0
  66. package/src/core/project-profile-diff.ts +302 -0
  67. package/src/core/project-profile-discovery.ts +519 -0
  68. package/src/core/project-profile-file.ts +37 -0
  69. package/src/core/project-profile-parser.ts +98 -0
  70. package/src/core/project-profile.ts +490 -0
  71. package/src/ingestion/walker.ts +85 -44
  72. package/src/llm/cache.ts +21 -0
  73. package/src/serve/capture-service.ts +420 -0
  74. package/src/serve/clipper-body.ts +62 -0
  75. package/src/serve/clipper-capture.ts +248 -0
  76. package/src/serve/clipper-contract.ts +57 -0
  77. package/src/serve/clipper-idempotency.ts +35 -0
  78. package/src/serve/clipper-pairing.ts +297 -0
  79. package/src/serve/clipper-security-errors.ts +23 -0
  80. package/src/serve/clipper-security.ts +449 -0
  81. package/src/serve/config-sync.ts +2 -2
  82. package/src/serve/public/app.tsx +8 -1
  83. package/src/serve/public/globals.built.css +1 -1
  84. package/src/serve/public/index.html +1 -0
  85. package/src/serve/public/lib/clipper-approval.ts +206 -0
  86. package/src/serve/public/pages/ClipperPairing.tsx +210 -0
  87. package/src/serve/resident-runtime.ts +1 -0
  88. package/src/serve/routes/api.ts +20 -115
  89. package/src/serve/routes/clipper.ts +394 -0
  90. package/src/serve/server.ts +22 -0
  91. package/src/store/migrations/020-browser-clipper-security.ts +128 -0
  92. package/src/store/migrations/021-multi-context-identity.ts +37 -0
  93. package/src/store/migrations/index.ts +4 -0
  94. package/src/store/sqlite/adapter.ts +83 -0
  95. package/src/store/sqlite/clipper-store-types.ts +104 -0
  96. package/src/store/sqlite/clipper-store.ts +496 -0
@@ -0,0 +1,519 @@
1
+ /**
2
+ * Local-only discovery for repository-owned `.gno/index.yml` profiles.
3
+ *
4
+ * Absolute paths stay inside this module. Callers must project the public,
5
+ * redacted discovery summary before crossing a CLI/API boundary.
6
+ *
7
+ * @module src/core/project-profile-discovery
8
+ */
9
+
10
+ // node:fs/promises provides directory metadata, lstat, and realpath; Bun has no
11
+ // equivalent directory/symlink inspection API.
12
+ import {
13
+ lstat as defaultLstat,
14
+ realpath as defaultRealpath,
15
+ stat as defaultStat,
16
+ } from "node:fs/promises";
17
+ // node:path provides cross-platform path operations; Bun has no path utilities.
18
+ import { dirname, join, resolve } from "node:path";
19
+
20
+ import { isCanonicalPathContained } from "./validation";
21
+
22
+ export const PROJECT_PROFILE_RELATIVE_PATH = ".gno/index.yml" as const;
23
+
24
+ export type ProjectProfileDiscoveryDiagnosticCode =
25
+ | "PROFILE_NOT_FOUND"
26
+ | "PROFILE_NOT_REGULAR_FILE"
27
+ | "PROFILE_SYMLINK_ESCAPE"
28
+ | "PROFILE_DISCOVERY_FAILED"
29
+ | "PROFILE_DISCOVERY_REMOTE_DISABLED"
30
+ | "SHADOWED_PROFILE";
31
+
32
+ export interface ProjectProfileDiscoveryDiagnostic {
33
+ code: ProjectProfileDiscoveryDiagnosticCode;
34
+ severity: "error" | "warning";
35
+ path: string;
36
+ message: string;
37
+ remediation: string;
38
+ }
39
+
40
+ export interface ProjectProfileDiscoverySummary {
41
+ status: "found" | "not_found" | "disabled" | "error";
42
+ source: "cwd" | "override" | "remote";
43
+ boundary: "repository" | "filesystem" | "explicit" | "remote";
44
+ profile: typeof PROJECT_PROFILE_RELATIVE_PATH | null;
45
+ ambiguous: boolean;
46
+ shadowedProfiles: number;
47
+ }
48
+
49
+ export interface ProjectProfileDiscoveryResult {
50
+ summary: ProjectProfileDiscoverySummary;
51
+ profilePath: string | null;
52
+ profileRoot: string | null;
53
+ diagnostics: ProjectProfileDiscoveryDiagnostic[];
54
+ }
55
+
56
+ interface FileMetadata {
57
+ dev: number;
58
+ isDirectory(): boolean;
59
+ isFile(): boolean;
60
+ }
61
+
62
+ export interface ProjectProfileDiscoveryDependencies {
63
+ lstat(path: string): Promise<FileMetadata>;
64
+ realpath(path: string): Promise<string>;
65
+ stat(path: string): Promise<FileMetadata>;
66
+ }
67
+
68
+ export interface DiscoverProjectProfileOptions {
69
+ channel: "local" | "remote";
70
+ cwd?: string;
71
+ rootOverride?: string;
72
+ }
73
+
74
+ const defaultDependencies: ProjectProfileDiscoveryDependencies = {
75
+ lstat: defaultLstat,
76
+ realpath: defaultRealpath,
77
+ stat: defaultStat,
78
+ };
79
+
80
+ const diagnostic = (
81
+ code: ProjectProfileDiscoveryDiagnosticCode,
82
+ severity: "error" | "warning",
83
+ message: string,
84
+ remediation: string
85
+ ): ProjectProfileDiscoveryDiagnostic => ({
86
+ code,
87
+ severity,
88
+ path: PROJECT_PROFILE_RELATIVE_PATH,
89
+ message,
90
+ remediation,
91
+ });
92
+
93
+ type PathProbe = "missing" | "present" | "unreadable";
94
+
95
+ const isMissingFsError = (error: unknown): boolean => {
96
+ const code =
97
+ error && typeof error === "object" && "code" in error
98
+ ? String(error.code)
99
+ : "";
100
+ return code === "ENOENT" || code === "ENOTDIR";
101
+ };
102
+
103
+ const probePath = async (
104
+ path: string,
105
+ dependencies: ProjectProfileDiscoveryDependencies
106
+ ): Promise<PathProbe> => {
107
+ try {
108
+ await dependencies.lstat(path);
109
+ return "present";
110
+ } catch (error) {
111
+ return isMissingFsError(error) ? "missing" : "unreadable";
112
+ }
113
+ };
114
+
115
+ type ProfileCandidateResult =
116
+ | { status: "missing" }
117
+ | { status: "found"; profilePath: string }
118
+ | { status: "error"; diagnostic: ProjectProfileDiscoveryDiagnostic };
119
+
120
+ const inspectProfileCandidate = async (
121
+ profileRoot: string,
122
+ dependencies: ProjectProfileDiscoveryDependencies
123
+ ): Promise<ProfileCandidateResult> => {
124
+ const candidate = join(profileRoot, PROJECT_PROFILE_RELATIVE_PATH);
125
+ const candidateProbe = await probePath(candidate, dependencies);
126
+ if (candidateProbe === "missing") {
127
+ return { status: "missing" };
128
+ }
129
+ if (candidateProbe === "unreadable") {
130
+ return {
131
+ status: "error",
132
+ diagnostic: diagnostic(
133
+ "PROFILE_DISCOVERY_FAILED",
134
+ "error",
135
+ "The selected project profile could not be inspected safely.",
136
+ "Repair directory permissions or select another exact profile root."
137
+ ),
138
+ };
139
+ }
140
+
141
+ let canonicalProfile: string;
142
+ let metadata: FileMetadata;
143
+ try {
144
+ canonicalProfile = await dependencies.realpath(candidate);
145
+ metadata = await dependencies.stat(canonicalProfile);
146
+ } catch {
147
+ return {
148
+ status: "error",
149
+ diagnostic: diagnostic(
150
+ "PROFILE_DISCOVERY_FAILED",
151
+ "error",
152
+ "The selected project profile could not be resolved safely.",
153
+ "Repair the profile file or select another exact profile root."
154
+ ),
155
+ };
156
+ }
157
+
158
+ if (!metadata.isFile()) {
159
+ return {
160
+ status: "error",
161
+ diagnostic: diagnostic(
162
+ "PROFILE_NOT_REGULAR_FILE",
163
+ "error",
164
+ "The selected project profile is not a regular file.",
165
+ "Replace .gno/index.yml with a regular YAML file."
166
+ ),
167
+ };
168
+ }
169
+ if (!isCanonicalPathContained(profileRoot, canonicalProfile)) {
170
+ return {
171
+ status: "error",
172
+ diagnostic: diagnostic(
173
+ "PROFILE_SYMLINK_ESCAPE",
174
+ "error",
175
+ "The selected project profile resolves outside its trusted profile root.",
176
+ "Replace the escaping symlink with a profile stored inside the project root."
177
+ ),
178
+ };
179
+ }
180
+ return { status: "found", profilePath: canonicalProfile };
181
+ };
182
+
183
+ const probeRepositoryMarker = (
184
+ directory: string,
185
+ dependencies: ProjectProfileDiscoveryDependencies
186
+ ): Promise<PathProbe> => probePath(join(directory, ".git"), dependencies);
187
+
188
+ const remoteDisabledResult = (): ProjectProfileDiscoveryResult => ({
189
+ summary: {
190
+ status: "disabled",
191
+ source: "remote",
192
+ boundary: "remote",
193
+ profile: null,
194
+ ambiguous: false,
195
+ shadowedProfiles: 0,
196
+ },
197
+ profilePath: null,
198
+ profileRoot: null,
199
+ diagnostics: [
200
+ diagnostic(
201
+ "PROFILE_DISCOVERY_REMOTE_DISABLED",
202
+ "error",
203
+ "Project profile discovery is disabled for remote callers.",
204
+ "Run gno profile from a trusted local CLI working directory."
205
+ ),
206
+ ],
207
+ });
208
+
209
+ const errorResult = (
210
+ source: "cwd" | "override",
211
+ boundary: "filesystem" | "explicit",
212
+ issue: ProjectProfileDiscoveryDiagnostic
213
+ ): ProjectProfileDiscoveryResult => ({
214
+ summary: {
215
+ status: "error",
216
+ source,
217
+ boundary,
218
+ profile: null,
219
+ ambiguous: false,
220
+ shadowedProfiles: 0,
221
+ },
222
+ profilePath: null,
223
+ profileRoot: null,
224
+ diagnostics: [issue],
225
+ });
226
+
227
+ const notFoundResult = (
228
+ source: "cwd" | "override",
229
+ boundary: "repository" | "filesystem" | "explicit"
230
+ ): ProjectProfileDiscoveryResult => ({
231
+ summary: {
232
+ status: "not_found",
233
+ source,
234
+ boundary,
235
+ profile: null,
236
+ ambiguous: false,
237
+ shadowedProfiles: 0,
238
+ },
239
+ profilePath: null,
240
+ profileRoot: null,
241
+ diagnostics: [
242
+ diagnostic(
243
+ "PROFILE_NOT_FOUND",
244
+ "error",
245
+ "No .gno/index.yml project profile was found inside the trusted discovery boundary.",
246
+ source === "override"
247
+ ? "Create .gno/index.yml at the selected root or choose another exact root."
248
+ : "Create .gno/index.yml in this project or pass an exact profile root."
249
+ ),
250
+ ],
251
+ });
252
+
253
+ const explicitProfileRoot = async (
254
+ input: string,
255
+ dependencies: ProjectProfileDiscoveryDependencies
256
+ ): Promise<
257
+ | { ok: true; profileRoot: string; exactProfilePath: string | null }
258
+ | { ok: false; diagnostic: ProjectProfileDiscoveryDiagnostic }
259
+ > => {
260
+ const absoluteInput = resolve(input);
261
+ let inputMetadata: FileMetadata;
262
+ try {
263
+ inputMetadata = await dependencies.stat(absoluteInput);
264
+ } catch (error) {
265
+ const missing = isMissingFsError(error);
266
+ return {
267
+ ok: false,
268
+ diagnostic: diagnostic(
269
+ missing ? "PROFILE_NOT_FOUND" : "PROFILE_DISCOVERY_FAILED",
270
+ "error",
271
+ missing
272
+ ? "The explicit project profile path does not exist."
273
+ : "The explicit project profile path could not be inspected.",
274
+ missing
275
+ ? "Pass an existing directory or its .gno/index.yml file."
276
+ : "Repair local filesystem permissions or I/O and retry."
277
+ ),
278
+ };
279
+ }
280
+
281
+ try {
282
+ if (inputMetadata.isDirectory()) {
283
+ return {
284
+ ok: true,
285
+ profileRoot: await dependencies.realpath(absoluteInput),
286
+ exactProfilePath: null,
287
+ };
288
+ }
289
+ if (!inputMetadata.isFile()) {
290
+ return {
291
+ ok: false,
292
+ diagnostic: diagnostic(
293
+ "PROFILE_NOT_REGULAR_FILE",
294
+ "error",
295
+ "The explicit project profile path is neither a directory nor a regular file.",
296
+ "Pass an existing directory or its .gno/index.yml file."
297
+ ),
298
+ };
299
+ }
300
+
301
+ const lexicalRoot = dirname(dirname(absoluteInput));
302
+ const profileRoot = await dependencies.realpath(lexicalRoot);
303
+ const exactProfilePath = await dependencies.realpath(absoluteInput);
304
+ if (
305
+ join(profileRoot, PROJECT_PROFILE_RELATIVE_PATH) !== exactProfilePath ||
306
+ !isCanonicalPathContained(profileRoot, exactProfilePath)
307
+ ) {
308
+ return {
309
+ ok: false,
310
+ diagnostic: diagnostic(
311
+ "PROFILE_SYMLINK_ESCAPE",
312
+ "error",
313
+ "The explicit file is not the canonical .gno/index.yml inside its trusted root.",
314
+ "Pass the project root or its non-escaping .gno/index.yml file."
315
+ ),
316
+ };
317
+ }
318
+ return { ok: true, profileRoot, exactProfilePath };
319
+ } catch {
320
+ return {
321
+ ok: false,
322
+ diagnostic: diagnostic(
323
+ "PROFILE_DISCOVERY_FAILED",
324
+ "error",
325
+ "The explicit project profile path could not be resolved safely.",
326
+ "Repair the path or select another exact profile root."
327
+ ),
328
+ };
329
+ }
330
+ };
331
+
332
+ const discoverExplicitProfile = async (
333
+ input: string,
334
+ dependencies: ProjectProfileDiscoveryDependencies
335
+ ): Promise<ProjectProfileDiscoveryResult> => {
336
+ const resolved = await explicitProfileRoot(input, dependencies);
337
+ if (!resolved.ok) {
338
+ return errorResult("override", "explicit", resolved.diagnostic);
339
+ }
340
+ const candidate = await inspectProfileCandidate(
341
+ resolved.profileRoot,
342
+ dependencies
343
+ );
344
+ if (candidate.status === "error") {
345
+ return errorResult("override", "explicit", candidate.diagnostic);
346
+ }
347
+ if (candidate.status === "missing") {
348
+ return notFoundResult("override", "explicit");
349
+ }
350
+ if (
351
+ resolved.exactProfilePath &&
352
+ resolved.exactProfilePath !== candidate.profilePath
353
+ ) {
354
+ return errorResult(
355
+ "override",
356
+ "explicit",
357
+ diagnostic(
358
+ "PROFILE_SYMLINK_ESCAPE",
359
+ "error",
360
+ "The explicit profile identity changed during discovery.",
361
+ "Repair the profile symlink and retry."
362
+ )
363
+ );
364
+ }
365
+ return {
366
+ summary: {
367
+ status: "found",
368
+ source: "override",
369
+ boundary: "explicit",
370
+ profile: PROJECT_PROFILE_RELATIVE_PATH,
371
+ ambiguous: false,
372
+ shadowedProfiles: 0,
373
+ },
374
+ profilePath: candidate.profilePath,
375
+ profileRoot: resolved.profileRoot,
376
+ diagnostics: [],
377
+ };
378
+ };
379
+
380
+ const startingDirectory = async (
381
+ input: string,
382
+ dependencies: ProjectProfileDiscoveryDependencies
383
+ ): Promise<{ path: string; dev: number }> => {
384
+ const canonical = await dependencies.realpath(resolve(input));
385
+ const metadata = await dependencies.stat(canonical);
386
+ const path = metadata.isDirectory() ? canonical : dirname(canonical);
387
+ const directoryMetadata = metadata.isDirectory()
388
+ ? metadata
389
+ : await dependencies.stat(path);
390
+ return { path, dev: directoryMetadata.dev };
391
+ };
392
+
393
+ const discoverFromCwd = async (
394
+ cwd: string,
395
+ dependencies: ProjectProfileDiscoveryDependencies
396
+ ): Promise<ProjectProfileDiscoveryResult> => {
397
+ let start: { path: string; dev: number };
398
+ try {
399
+ start = await startingDirectory(cwd, dependencies);
400
+ } catch {
401
+ return errorResult(
402
+ "cwd",
403
+ "filesystem",
404
+ diagnostic(
405
+ "PROFILE_DISCOVERY_FAILED",
406
+ "error",
407
+ "The local discovery starting path could not be resolved.",
408
+ "Run the command from an existing readable directory."
409
+ )
410
+ );
411
+ }
412
+
413
+ const candidates: Array<{ profilePath: string; profileRoot: string }> = [];
414
+ let current = start.path;
415
+ let boundary: "repository" | "filesystem" = "filesystem";
416
+
417
+ while (true) {
418
+ const candidate = await inspectProfileCandidate(current, dependencies);
419
+ if (candidate.status === "error") {
420
+ return errorResult("cwd", boundary, candidate.diagnostic);
421
+ }
422
+ if (candidate.status === "found") {
423
+ candidates.push({
424
+ profilePath: candidate.profilePath,
425
+ profileRoot: current,
426
+ });
427
+ }
428
+
429
+ const repositoryMarker = await probeRepositoryMarker(current, dependencies);
430
+ if (repositoryMarker === "unreadable") {
431
+ return errorResult(
432
+ "cwd",
433
+ boundary,
434
+ diagnostic(
435
+ "PROFILE_DISCOVERY_FAILED",
436
+ "error",
437
+ "The repository boundary could not be inspected safely.",
438
+ "Repair directory permissions or pass an exact profile root."
439
+ )
440
+ );
441
+ }
442
+ if (repositoryMarker === "present") {
443
+ boundary = "repository";
444
+ break;
445
+ }
446
+ const parent = dirname(current);
447
+ if (parent === current) {
448
+ break;
449
+ }
450
+ try {
451
+ const parentMetadata = await dependencies.stat(parent);
452
+ if (!parentMetadata.isDirectory() || parentMetadata.dev !== start.dev) {
453
+ break;
454
+ }
455
+ } catch {
456
+ return errorResult(
457
+ "cwd",
458
+ boundary,
459
+ diagnostic(
460
+ "PROFILE_DISCOVERY_FAILED",
461
+ "error",
462
+ "The discovery boundary could not be inspected safely.",
463
+ "Repair directory permissions or pass an exact profile root."
464
+ )
465
+ );
466
+ }
467
+ current = parent;
468
+ }
469
+
470
+ const selected = candidates[0];
471
+ if (!selected) {
472
+ return notFoundResult("cwd", boundary);
473
+ }
474
+ const shadowedProfiles = candidates.length - 1;
475
+ return {
476
+ summary: {
477
+ status: "found",
478
+ source: "cwd",
479
+ boundary,
480
+ profile: PROJECT_PROFILE_RELATIVE_PATH,
481
+ ambiguous: shadowedProfiles > 0,
482
+ shadowedProfiles,
483
+ },
484
+ profilePath: selected.profilePath,
485
+ profileRoot: selected.profileRoot,
486
+ diagnostics:
487
+ shadowedProfiles > 0
488
+ ? [
489
+ diagnostic(
490
+ "SHADOWED_PROFILE",
491
+ "warning",
492
+ `The nearest profile was selected; ${shadowedProfiles} ancestor profile${shadowedProfiles === 1 ? " was" : "s were"} shadowed and not merged.`,
493
+ "Pass an explicit profile root to inspect an ancestor profile."
494
+ ),
495
+ ]
496
+ : [],
497
+ };
498
+ };
499
+
500
+ /**
501
+ * Discover one trusted local profile. Remote requests return before touching
502
+ * dependency functions, proving they cannot trigger filesystem probes.
503
+ */
504
+ export async function discoverProjectProfile(
505
+ options: DiscoverProjectProfileOptions,
506
+ overrides: Partial<ProjectProfileDiscoveryDependencies> = {}
507
+ ): Promise<ProjectProfileDiscoveryResult> {
508
+ if (options.channel === "remote") {
509
+ return remoteDisabledResult();
510
+ }
511
+ const dependencies: ProjectProfileDiscoveryDependencies = {
512
+ ...defaultDependencies,
513
+ ...overrides,
514
+ };
515
+ if (options.rootOverride) {
516
+ return discoverExplicitProfile(options.rootOverride, dependencies);
517
+ }
518
+ return discoverFromCwd(options.cwd ?? process.cwd(), dependencies);
519
+ }
@@ -0,0 +1,37 @@
1
+ /** Bounded, UTF-8-only reads for the tracked project profile source. */
2
+
3
+ export const PROJECT_PROFILE_FILE_MAX_BYTES = 1_048_576;
4
+
5
+ export class ProjectProfileFileError extends Error {
6
+ readonly code: "PROFILE_FILE_INVALID" | "PROFILE_FILE_TOO_LARGE";
7
+
8
+ constructor(
9
+ code: "PROFILE_FILE_INVALID" | "PROFILE_FILE_TOO_LARGE",
10
+ message: string
11
+ ) {
12
+ super(message);
13
+ this.code = code;
14
+ }
15
+ }
16
+
17
+ export async function readProjectProfileFile(path: string): Promise<string> {
18
+ const bytes = new Uint8Array(
19
+ await Bun.file(path)
20
+ .slice(0, PROJECT_PROFILE_FILE_MAX_BYTES + 1)
21
+ .arrayBuffer()
22
+ );
23
+ if (bytes.byteLength > PROJECT_PROFILE_FILE_MAX_BYTES) {
24
+ throw new ProjectProfileFileError(
25
+ "PROFILE_FILE_TOO_LARGE",
26
+ `Project profile exceeds ${PROJECT_PROFILE_FILE_MAX_BYTES} bytes.`
27
+ );
28
+ }
29
+ try {
30
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
31
+ } catch {
32
+ throw new ProjectProfileFileError(
33
+ "PROFILE_FILE_INVALID",
34
+ "Project profile is not valid UTF-8."
35
+ );
36
+ }
37
+ }
@@ -0,0 +1,98 @@
1
+ import type { ProjectProfile } from "../config/project-profile";
2
+ import type {
3
+ CompileProjectProfileResult,
4
+ ProjectProfileDiagnostic,
5
+ } from "./project-profile";
6
+
7
+ import {
8
+ PROJECT_PROFILE_SCHEMA_VERSION,
9
+ ProjectProfileSchema,
10
+ } from "../config/project-profile";
11
+
12
+ const zodPath = (segments: PropertyKey[]): string => {
13
+ let path = "";
14
+ for (const segment of segments) {
15
+ const value = String(segment);
16
+ path =
17
+ typeof segment === "number"
18
+ ? `${path}[${value}]`
19
+ : path
20
+ ? `${path}.${value}`
21
+ : value;
22
+ }
23
+ return path;
24
+ };
25
+
26
+ const versionDiagnostic = (
27
+ parsed: unknown
28
+ ): ProjectProfileDiagnostic | null => {
29
+ if (parsed === null || typeof parsed !== "object") return null;
30
+ if (!("schemaVersion" in parsed)) {
31
+ return {
32
+ code: "MIGRATION_REQUIRED",
33
+ severity: "error",
34
+ path: "schemaVersion",
35
+ message: `Project profile has no schemaVersion; migrate it to version ${PROJECT_PROFILE_SCHEMA_VERSION}.`,
36
+ };
37
+ }
38
+ const found = String(parsed.schemaVersion);
39
+ const match = /^(\d+)\.(\d+)$/.exec(found);
40
+ if (!match || match[1] !== "1") {
41
+ return {
42
+ code: "UNSUPPORTED_SCHEMA_MAJOR",
43
+ severity: "error",
44
+ path: "schemaVersion",
45
+ message: `Unsupported project profile major version "${found}"; expected 1.x.`,
46
+ };
47
+ }
48
+ if (found !== PROJECT_PROFILE_SCHEMA_VERSION) {
49
+ return {
50
+ code: "UNSUPPORTED_SCHEMA_MINOR",
51
+ severity: "error",
52
+ path: "schemaVersion",
53
+ message: `Project profile version "${found}" requires migration to supported version ${PROJECT_PROFILE_SCHEMA_VERSION}.`,
54
+ };
55
+ }
56
+ return null;
57
+ };
58
+
59
+ export const parseProjectProfile = (
60
+ yaml: string
61
+ ): CompileProjectProfileResult | ProjectProfile => {
62
+ let parsed: unknown;
63
+ try {
64
+ parsed = Bun.YAML.parse(yaml);
65
+ } catch {
66
+ return {
67
+ ok: false,
68
+ diagnostics: [
69
+ {
70
+ code: "INVALID_PROFILE",
71
+ severity: "error",
72
+ path: "",
73
+ message: "Project profile is not valid YAML.",
74
+ },
75
+ ],
76
+ };
77
+ }
78
+
79
+ const versionIssue = versionDiagnostic(parsed);
80
+ if (versionIssue) return { ok: false, diagnostics: [versionIssue] };
81
+
82
+ const result = ProjectProfileSchema.safeParse(parsed);
83
+ if (result.success) return result.data;
84
+ return {
85
+ ok: false,
86
+ diagnostics: result.error.issues.map((issue) => {
87
+ const unsafePath = issue.message.startsWith("UNSAFE_PATH:");
88
+ return {
89
+ code: unsafePath ? "UNSAFE_PATH" : "INVALID_PROFILE",
90
+ severity: "error",
91
+ path: zodPath(issue.path),
92
+ message: unsafePath
93
+ ? issue.message.slice("UNSAFE_PATH:".length).trim()
94
+ : issue.message,
95
+ };
96
+ }),
97
+ };
98
+ };