@builder.io/ai-utils 0.86.1 → 0.87.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.
package/src/projects.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
- import { EnvironmentVariableSchema, } from "./common-schemas";
2
+ import { EnvironmentVariableSchema, SetupDependencySchema, } from "./common-schemas";
3
+ import { AutoPushModeSchema, CustomInstructionSchema, CustomAgentDefinitionSchema, ExitStateSchema, FileOverrideSchema, } from "./codegen";
3
4
  /**
4
5
  * Detect git provider from a repository URL (HTTPS or SSH).
5
6
  * Used by both error diagnostics and connectivity checks for consistent provider labeling.
@@ -59,6 +60,82 @@ export const EXAMPLE_OR_STARTER_REPOS = [
59
60
  AGENT_NATIVE_STARTER_REPO,
60
61
  ];
61
62
  export const EXAMPLE_OR_STARTER_REPOS_URLS = EXAMPLE_OR_STARTER_REPOS.map((repo) => `https://github.com/${repo}`);
63
+ /** Fields shared by every {@link ProjectSkill} variant. */
64
+ const ProjectSkillBaseShape = {
65
+ key: z.string().meta({
66
+ description: "Stable identifier for the entry (used for logs/dedup, not by the CLI).",
67
+ }),
68
+ skills: z.array(z.string()).optional().meta({
69
+ description: "Specific skill names to install (--skill). Omit to install all.",
70
+ }),
71
+ agents: z.array(z.string()).optional().meta({
72
+ description: 'Target agents (--agent). Defaults to ["claude-code"].',
73
+ }),
74
+ };
75
+ /**
76
+ * A skill source passed directly to `skills add`. Supports GitHub shorthand,
77
+ * Git/GitHub URLs, local paths, npm: sources, etc.
78
+ */
79
+ export const ProjectSkillSourceSchema = z
80
+ .object({
81
+ ...ProjectSkillBaseShape,
82
+ source: z.string(),
83
+ })
84
+ .meta({ title: "ProjectSkillSource" });
85
+ /**
86
+ * A skill sourced from an npm package. The package must contain a SKILL.md at
87
+ * its root (or a skills/<name>/SKILL.md).
88
+ */
89
+ export const ProjectSkillNpmSchema = z
90
+ .object({
91
+ ...ProjectSkillBaseShape,
92
+ type: z.literal("npm"),
93
+ package: z.string(),
94
+ version: z.string().optional(),
95
+ })
96
+ .meta({ title: "ProjectSkillNpm" });
97
+ /**
98
+ * A skill sourced from a GitHub repository following the Claude Code plugin
99
+ * layout (skills/<name>/SKILL.md) or containing a SKILL.md at root.
100
+ */
101
+ export const ProjectSkillGithubSchema = z
102
+ .object({
103
+ ...ProjectSkillBaseShape,
104
+ type: z.literal("github"),
105
+ repo: z.string().meta({ description: '"owner/repo"' }),
106
+ ref: z
107
+ .string()
108
+ .optional()
109
+ .meta({ description: "Branch, tag, or commit SHA." }),
110
+ path: z.string().optional().meta({
111
+ description: "Sub-path inside the repo to treat as the plugin root (default: repo root).",
112
+ }),
113
+ })
114
+ .meta({ title: "ProjectSkillGithub" });
115
+ /**
116
+ * A skill sourced from a URL supported by `skills add`, such as a GitHub/GitLab
117
+ * tree URL, a direct git URL, or a well-known skills index URL.
118
+ */
119
+ export const ProjectSkillUrlSchema = z
120
+ .object({
121
+ ...ProjectSkillBaseShape,
122
+ type: z.literal("url"),
123
+ url: z.string(),
124
+ })
125
+ .meta({ title: "ProjectSkillUrl" });
126
+ /**
127
+ * A project-level skill to install during Fusion initialization. A plain
128
+ * `z.union` (not `discriminatedUnion`) because the `source` variant has no
129
+ * `type` tag to discriminate on.
130
+ */
131
+ export const ProjectSkillSchema = z
132
+ .union([
133
+ ProjectSkillSourceSchema,
134
+ ProjectSkillNpmSchema,
135
+ ProjectSkillGithubSchema,
136
+ ProjectSkillUrlSchema,
137
+ ])
138
+ .meta({ title: "ProjectSkill" });
62
139
  export const checkIsNewBranch = (branch) => {
63
140
  return "projectId" in branch;
64
141
  };
@@ -147,6 +224,524 @@ export const ListBranchesOptionsSchema = z.object({
147
224
  description: "Superuser-only: list branches for a different owner/org.",
148
225
  }),
149
226
  });
227
+ export const ProjectRolePermissionsSchema = z
228
+ .object({
229
+ view: z.boolean().optional(),
230
+ editCode: z.boolean().optional(),
231
+ sendPullRequests: z.boolean().optional(),
232
+ createBranches: z.boolean().optional(),
233
+ modifyMcpServers: z.boolean().optional(),
234
+ modifyWorkflowIntegrations: z.boolean().optional(),
235
+ modifyProjectSettings: z.boolean().optional(),
236
+ fusionHostingPublish: z.boolean().optional(),
237
+ })
238
+ .meta({ title: "ProjectRolePermissions" });
239
+ export const ProjectAccessControlSchema = z
240
+ .object({
241
+ roles: z.record(z.string(), ProjectRolePermissionsSchema.nullable()),
242
+ users: z.record(z.string(), ProjectRolePermissionsSchema),
243
+ })
244
+ .meta({ title: "ProjectAccessControl" });
245
+ /**
246
+ * Runtime schema for {@link Project.settings} — the full stored shape, including
247
+ * `@internal` (system/agent-managed) fields. Plain `z.object` strips unknown
248
+ * keys on parse.
249
+ *
250
+ * For client-writable payloads use {@link ProjectSettingsExternalSchema}, which
251
+ * is derived from this schema with the internal fields omitted so external
252
+ * callers can't set them through the public update API.
253
+ */
254
+ export const ProjectSettingsSchema = z
255
+ .object({
256
+ isNativeApp: z.boolean().optional().meta({
257
+ description: "When true, this is a native (mobile/desktop) app with no web dev server.",
258
+ }),
259
+ autoDetectDevServer: z.boolean().optional().meta({
260
+ description: "When true, automatically detect the dev server URL from command output (Desktop app only).",
261
+ }),
262
+ autoDetectDevServerPatterns: z.array(z.string()).optional().meta({
263
+ description: "Regex patterns matched against dev command output to detect the server URL.",
264
+ }),
265
+ fusionEnvironment: z
266
+ .enum(["containerized", "container-less", "cloud", "cloud-v2"])
267
+ .optional()
268
+ .meta({
269
+ description: "Execution environment for the Fusion runtime (cloud, containerized, container-less).",
270
+ }),
271
+ devServerPort: z.number().optional().meta({
272
+ description: "Port the dev server listens on inside the container. Use 0 for auto-assign. Default: 3000.",
273
+ }),
274
+ devServerUrl: z.string().optional().meta({
275
+ description: "@internal Explicit dev server URL; overrides auto-detection. Set by the system, not user-configurable.",
276
+ }),
277
+ refreshPreview: z.boolean().optional().meta({
278
+ description: "@internal Whether to reload the preview when files change. Managed by the app UI.",
279
+ }),
280
+ installCommand: z.string().optional().meta({
281
+ description: 'Commands to install dependencies before starting the dev server (e.g. "npm install").',
282
+ }),
283
+ installNpmrc: z.string().nullish().meta({
284
+ description: "Contents of an .npmrc file written before installCommand runs (e.g. private registry auth).",
285
+ }),
286
+ validateCommand: z.string().optional().meta({
287
+ description: "@internal Command to run validation checks. Used internally by the setup agent.",
288
+ }),
289
+ proxyOrigin: z.string().optional().meta({
290
+ description: "@internal URL origin used for the dev server proxy. Related to Electron proxy configuration. Exposed via specific UI; not a free-text field.",
291
+ }),
292
+ proxyDefaultOrigin: z.string().optional().meta({
293
+ description: "@internal Default proxy origin, resolved at runtime.",
294
+ }),
295
+ defaultAutoPush: AutoPushModeSchema.optional().meta({
296
+ description: "@internal Mode for automatically pushing AI commits. Managed by the app.",
297
+ }),
298
+ gitBranchNamingStrategy: z
299
+ .enum(["ai-session", "branch-name", "custom"])
300
+ .optional()
301
+ .meta({
302
+ description: "How branch names are generated: ai-session ID, descriptive name, or a custom pattern.",
303
+ }),
304
+ setupDependencies: z.array(SetupDependencySchema).optional().meta({
305
+ description: "@internal Mise tools and custom scripts run before the setup command. Managed via builder.config.json.",
306
+ }),
307
+ hostRequirements: z
308
+ .array(z.object({
309
+ name: z.string().meta({ description: "Requirement name." }),
310
+ checkScript: z.string().meta({
311
+ description: "Script that checks whether it's satisfied.",
312
+ }),
313
+ installScript: z.string().nullish().meta({
314
+ description: "Script that installs the requirement, if available.",
315
+ }),
316
+ installLink: z.string().nullish().meta({
317
+ description: "Link with install instructions, if available.",
318
+ }),
319
+ platform: z
320
+ .enum(["macos", "windows", "linux"])
321
+ .nullish()
322
+ .meta({ description: "Platform this requirement applies to." }),
323
+ description: z
324
+ .string()
325
+ .optional()
326
+ .meta({ description: "Human-readable description." }),
327
+ }))
328
+ .optional()
329
+ .meta({
330
+ description: "Host tool requirements checked/installed for desktop-app projects.",
331
+ }),
332
+ skills: z.array(ProjectSkillSchema).optional().meta({
333
+ description: "Project-level skills to install during Fusion initialization.",
334
+ }),
335
+ gitBranchNamingCustom: z.string().optional(),
336
+ askUserForBranchName: z.boolean().optional().meta({
337
+ description: "When true, the user is prompted to provide a branch name before a new branch is created.",
338
+ }),
339
+ allowBranchFromRemote: z.boolean().optional().meta({
340
+ description: "When true, the user is prompted to branch from a remote branch when creating a new branch.",
341
+ }),
342
+ devServerCommand: z.string().optional().meta({
343
+ description: 'Command to start the dev server (e.g. "npm run dev").',
344
+ }),
345
+ screenshotCommand: z.string().optional().meta({
346
+ description: "Command used to capture a screenshot of the running app.",
347
+ }),
348
+ interactCommand: z.string().optional().meta({
349
+ description: "Command used to drive interact mode against the running app.",
350
+ }),
351
+ serverTimeout: z.number().optional().meta({
352
+ description: "Max time (ms) to wait for the dev server to become ready. Default: 600000.",
353
+ }),
354
+ cpuKind: z.enum(["performance", "standard", "shared"]).optional().meta({
355
+ description: 'CPU tier for the container: "performance", "standard", or "shared".',
356
+ }),
357
+ cpus: z
358
+ .union([z.literal(1), z.literal(2), z.literal(4)])
359
+ .optional()
360
+ .meta({
361
+ description: "Number of vCPUs allocated to the container: 1, 2, or 4.",
362
+ }),
363
+ memory: z
364
+ .union([
365
+ z.literal(2048),
366
+ z.literal(4096),
367
+ z.literal(8192),
368
+ z.literal(16384),
369
+ ])
370
+ .optional()
371
+ .meta({
372
+ description: "RAM allocated to the container in MB. Higher values (8192/16384) are enterprise-only. Default: 4096.",
373
+ }),
374
+ memoryLimit: z
375
+ .union([
376
+ z.literal(1024),
377
+ z.literal(2048),
378
+ z.literal(4096),
379
+ z.literal(8192),
380
+ z.literal(16384),
381
+ ])
382
+ .optional()
383
+ .meta({ description: "@deprecated Use {@link memory} instead." }),
384
+ autoStop: z.enum(["stop", "off", "suspend"]).optional().meta({
385
+ description: 'Idle behavior for the container: stop, suspend, or stay on. Default: "suspend".',
386
+ }),
387
+ mainBranchName: z.string().optional().meta({
388
+ description: 'Base branch used when cloning and creating new branches. Default: "main".',
389
+ }),
390
+ minMachinesRunning: z.number().optional().meta({
391
+ description: "Minimum number of machines kept alive at all times. Default: 0.",
392
+ }),
393
+ volumeSize: z
394
+ .union([
395
+ z.literal(5),
396
+ z.literal(10),
397
+ z.literal(12),
398
+ z.literal(15),
399
+ z.literal(20),
400
+ z.literal(25),
401
+ z.literal(30),
402
+ z.literal(50),
403
+ ])
404
+ .optional()
405
+ .meta({ description: "Persistent disk size in GB. Default: 5." }),
406
+ includePath: z.string().optional().meta({
407
+ description: "@internal Subpath within the repository to use as the working root.",
408
+ }),
409
+ includePatterns: z.array(z.string()).optional().meta({
410
+ description: "Glob patterns limiting which paths are included when cloning or processing.",
411
+ }),
412
+ errorIgnorePatterns: z.array(z.string()).optional().meta({
413
+ description: "Patterns for client-side runtime errors the preview should ignore.",
414
+ }),
415
+ environmentVariables: z.array(EnvironmentVariableSchema).optional().meta({
416
+ description: "Environment variables injected into the dev container.",
417
+ }),
418
+ requiredEnvironmentVariables: z
419
+ .array(z.object({
420
+ key: z.string().meta({ description: "Env var name." }),
421
+ description: z
422
+ .string()
423
+ .optional()
424
+ .meta({ description: "Human-readable purpose of the variable." }),
425
+ optional: z.boolean().nullish().meta({
426
+ description: "When true, the variable is not required to be set.",
427
+ }),
428
+ }))
429
+ .optional()
430
+ .meta({
431
+ description: "Declared env vars the project expects, surfaced in the UI for the user to fill in.",
432
+ }),
433
+ fileOverrides: z.array(FileOverrideSchema).optional().meta({
434
+ description: "Files injected into the container during setup, written before installCommand runs.",
435
+ }),
436
+ customInstructions: z.array(CustomInstructionSchema).optional().meta({
437
+ description: "Structured instruction rules applied to AI sessions (name, content, scope, tool allowlists).",
438
+ }),
439
+ customAgents: z.array(CustomAgentDefinitionSchema).optional().meta({
440
+ description: "Definitions for specialized sub-agents with custom system prompts, tools, and model overrides.",
441
+ }),
442
+ mcpServers: z
443
+ .record(z.string(),
444
+ // Mirrors the client-writable subset of `MCPServerDefinition`. `scope`
445
+ // and `pluginName` are deliberately excluded: the loader sets them from
446
+ // where a server was discovered, so they aren't config a caller writes.
447
+ z.object({
448
+ command: z.string().meta({ description: "Executable to launch." }),
449
+ args: z
450
+ .array(z.string())
451
+ .optional()
452
+ .meta({ description: "Arguments passed to the command." }),
453
+ env: z
454
+ .record(z.string(), z.string())
455
+ .optional()
456
+ .meta({ description: "Environment variables for the server." }),
457
+ envFile: z.string().optional().meta({
458
+ description: "Path to a .env file to load environment variables from, resolved against the project root when relative.",
459
+ }),
460
+ retries: z.number().optional().meta({
461
+ description: "How many times to retry connecting to the server before giving up. Default: 0.",
462
+ }),
463
+ }))
464
+ .optional()
465
+ .meta({
466
+ description: "MCP servers to make available to AI sessions, keyed by server name.",
467
+ }),
468
+ commitMode: z.enum(["commits", "draft-prs", "prs"]).optional().meta({
469
+ description: 'How the AI commits changes: direct commits, draft PRs, or ready-for-review PRs. Default: "draft-prs".',
470
+ }),
471
+ autoCloseBranchOnMerge: z.boolean().optional().meta({
472
+ description: "@internal Default for new branches' autoCloseBranchOnMerge. When false, merging a PR does not archive the branch. Read only at branch-creation time. Default: true.",
473
+ }),
474
+ enableMergePR: z.boolean().optional().meta({
475
+ description: "Whether merging the PR from the editor is enabled.",
476
+ }),
477
+ minRequiredApprovals: z.number().optional().meta({
478
+ description: "Minimum number of approvals required before a PR can be sent/merged. Default: 0.",
479
+ }),
480
+ requireApprovalBeforePR: z.boolean().optional().meta({
481
+ description: "When true, block sending a PR until the required approvals are met.",
482
+ }),
483
+ requiredApproverRoleIds: z.array(z.string()).optional().meta({
484
+ description: "Role IDs whose members can satisfy the approval requirement.",
485
+ }),
486
+ requiredApproverRoleId: z.string().optional().meta({
487
+ description: "@deprecated Use {@link requiredApproverRoleIds}. Single approver role ID.",
488
+ }),
489
+ defaultBranchType: z.enum(["shared", "private"]).optional().meta({
490
+ description: 'Default visibility for new branches: shared (team-visible) or private. Default: "private".',
491
+ }),
492
+ dockerImagePath: z.string().optional().meta({
493
+ description: "Custom container image path (e.g. ghcr.io/org/image:tag). Overrides nodeVersion when set.",
494
+ }),
495
+ ghcrUsername: z.string().nullish().meta({
496
+ description: "GitHub Container Registry username used to pull a private dockerImagePath.",
497
+ }),
498
+ ghcrToken: z.string().nullish().meta({
499
+ description: "GitHub Container Registry token/password used to pull a private dockerImagePath. Sensitive.",
500
+ }),
501
+ nodeVersion: z.string().optional().meta({
502
+ description: 'Node.js version to use in the container (e.g. "22"). Ignored when dockerImagePath is set.',
503
+ }),
504
+ designSystems: z.array(z.string()).optional().meta({
505
+ description: "Named design systems to expose for AI assistance.",
506
+ }),
507
+ designTokenStrictMode: z.boolean().optional().meta({
508
+ description: "When true, enforce strict design-token usage during code generation.",
509
+ }),
510
+ designModeSelector: z.string().optional().meta({
511
+ description: "CSS selector marking the region editable in design mode.",
512
+ }),
513
+ useNI: z.boolean().optional().meta({
514
+ description: "@internal Use @antfu/ni for package management instead of direct npm/yarn/pnpm commands.",
515
+ }),
516
+ folders: z
517
+ .array(z.object({
518
+ name: z.string().meta({
519
+ description: "Identifier for this workspace folder (kebab/snake case recommended).",
520
+ }),
521
+ remoteUrl: z.string().meta({
522
+ description: "Full clone URL of the repository (must end in .git for GitHub/GitLab).",
523
+ }),
524
+ mainBranchName: z
525
+ .string()
526
+ .optional()
527
+ .meta({ description: 'Base branch to clone. Default: "main".' }),
528
+ includePath: z.string().optional().meta({
529
+ description: "Subpath within this repo to use as the working root.",
530
+ }),
531
+ addedBy: z
532
+ .string()
533
+ .optional()
534
+ .meta({ description: "User ID of whoever added this folder." }),
535
+ repoProvider: z
536
+ .string()
537
+ .optional()
538
+ .meta({ description: "Detected or specified git provider." }),
539
+ repoProtocol: z
540
+ .string()
541
+ .optional()
542
+ .meta({ description: 'Clone protocol: "https" or "ssh".' }),
543
+ enableGit: z.boolean().optional().meta({
544
+ description: "Whether to track git history for this folder.",
545
+ }),
546
+ }))
547
+ .optional()
548
+ .meta({
549
+ description: "Additional repositories cloned alongside the main repo for extra AI context.",
550
+ }),
551
+ agentsMD: z.string().optional().meta({
552
+ description: "Freeform workspace-level AI instructions, equivalent to an AGENTS.md file.",
553
+ }),
554
+ initializationCommand: z.string().optional().meta({
555
+ description: "One-time command run after the repo is first cloned, before installCommand.",
556
+ }),
557
+ repoSubpath: z.string().optional().meta({
558
+ description: "@internal Subpath within the repo recommended by auto-detection as the project root.",
559
+ }),
560
+ recommendedRoot: z.string().optional().meta({
561
+ description: "@internal Workspace root recommended by auto-setup analysis; not user-configurable.",
562
+ }),
563
+ https: z.boolean().optional().meta({
564
+ description: "Enable HTTPS for the dev server proxy. Default: false.",
565
+ }),
566
+ localHttpsDomain: z.string().optional().meta({
567
+ description: 'Custom local domain for HTTPS (e.g. "myapp.local").',
568
+ }),
569
+ browserAutomation: z
570
+ .object({
571
+ backgroundAgents: z.boolean().optional().meta({
572
+ description: "Run browser automation tasks as background agents.",
573
+ }),
574
+ builderApp: z.boolean().optional().meta({
575
+ description: "Use the Builder desktop app for browser automation.",
576
+ }),
577
+ instructions: z
578
+ .string()
579
+ .optional()
580
+ .meta({ description: "Custom instructions for the browser agent." }),
581
+ authUser: z.string().optional().meta({
582
+ description: "Username for sites requiring authentication.",
583
+ }),
584
+ authPassword: z.string().optional().meta({
585
+ description: "Password for sites requiring authentication.",
586
+ }),
587
+ })
588
+ .optional()
589
+ .meta({
590
+ description: "Browser automation configuration for AI-driven end-to-end testing and visual verification.",
591
+ }),
592
+ prReviewer: z
593
+ .object({
594
+ enabled: z
595
+ .boolean()
596
+ .meta({ description: "Whether the automated reviewer is enabled." }),
597
+ instructions: z
598
+ .string()
599
+ .optional()
600
+ .meta({ description: "Custom review guidelines for the reviewer." }),
601
+ reviewEffort: z.enum(["medium", "high", "low"]).optional().meta({
602
+ description: 'How deeply to review: "low", "medium" (default), or "high".',
603
+ }),
604
+ model: z
605
+ .string()
606
+ .optional()
607
+ .meta({ description: "Model override for the automated reviewer." }),
608
+ })
609
+ .optional()
610
+ .meta({ description: "Automated PR reviewer configuration." }),
611
+ enableSnapshots: z.boolean().optional().meta({
612
+ description: "Enable volume snapshots so session state can be saved and restored.",
613
+ }),
614
+ postMergeMemories: z.boolean().optional().meta({
615
+ description: "When true, the AI captures memories after a branch is merged for use in future sessions.",
616
+ }),
617
+ commitInstructions: z.string().optional().meta({
618
+ description: "@internal Custom instructions for AI-generated commit messages.",
619
+ }),
620
+ maxAgentCompletions: z.number().optional().meta({
621
+ description: "Maximum number of AI completions allowed per agent session.",
622
+ }),
623
+ enableAgentMode: z.boolean().optional().meta({
624
+ description: "When true, the agent-mode tab is enabled in the editor.",
625
+ }),
626
+ allowPreviewAutoSubmit: z.boolean().optional().meta({
627
+ description: "When true, the preview may auto-submit forms/prompts.",
628
+ }),
629
+ enforceDesktopAppOnly: z.boolean().optional().meta({
630
+ description: "When true, the project can only be opened in the desktop app.",
631
+ }),
632
+ disableDesktopProxying: z.boolean().optional().meta({
633
+ description: "When true, disable the desktop app's dev-server proxy.",
634
+ }),
635
+ previewUrl: z.string().nullish().meta({
636
+ description: "Template/preview URL shown for the project.",
637
+ }),
638
+ recentUrls: z
639
+ .array(z.object({
640
+ url: z.string().meta({ description: "Navigated preview URL." }),
641
+ timestamp: z
642
+ .number()
643
+ .meta({ description: "Last-visited time (ms since epoch)." }),
644
+ starred: z
645
+ .boolean()
646
+ .meta({ description: "Whether the user starred this URL." }),
647
+ }))
648
+ .optional()
649
+ .meta({
650
+ description: "Recently navigated preview URLs (history + starred), maintained by the editor.",
651
+ }),
652
+ skipCommandSecurity: z.boolean().optional().meta({
653
+ description: "@internal Bypass command security restrictions. Admin use only.",
654
+ }),
655
+ httpsServerKeyPath: z
656
+ .string()
657
+ .optional()
658
+ .meta({ description: "File path to the HTTPS private key." }),
659
+ httpsServerCertPath: z
660
+ .string()
661
+ .optional()
662
+ .meta({ description: "File path to the HTTPS certificate." }),
663
+ httpsServerCaPath: z
664
+ .string()
665
+ .optional()
666
+ .meta({ description: "File path to the HTTPS CA certificate." }),
667
+ httpsServerKeyContent: z.string().optional().meta({
668
+ description: "HTTPS private key content as a string (alternative to httpsServerKeyPath).",
669
+ }),
670
+ httpsServerCertContent: z.string().optional().meta({
671
+ description: "HTTPS certificate content as a string (alternative to httpsServerCertPath).",
672
+ }),
673
+ httpsServerCaContent: z.string().optional().meta({
674
+ description: "HTTPS CA certificate content as a string (alternative to httpsServerCaPath).",
675
+ }),
676
+ httpsServerPfx: z
677
+ .string()
678
+ .optional()
679
+ .meta({ description: "PFX/PKCS12 bundle for HTTPS." }),
680
+ httpsServerPassphrase: z.string().optional().meta({
681
+ description: "Passphrase for an encrypted HTTPS private key or PFX.",
682
+ }),
683
+ httpsServerSecureProtocol: z.string().optional().meta({
684
+ description: 'Secure protocol string passed to Node.js https.createServer (e.g. "TLSv1_2_method").',
685
+ }),
686
+ httpsServerSecureOptions: z.number().optional().meta({
687
+ description: "Numeric TLS options bitmask passed to Node.js https.createServer.",
688
+ }),
689
+ httpsServerCiphers: z.string().optional().meta({
690
+ description: "Cipher suite string (OpenSSL format) for the HTTPS server.",
691
+ }),
692
+ httpsServerHonorCipherOrder: z.boolean().optional().meta({
693
+ description: "When true, the server's cipher order takes precedence over the client's.",
694
+ }),
695
+ httpsServerRequestCert: z.boolean().optional().meta({
696
+ description: "When true, the server requests a client certificate.",
697
+ }),
698
+ httpsServerRejectUnauthorized: z.boolean().optional().meta({
699
+ description: "When false, self-signed or untrusted client certificates are accepted.",
700
+ }),
701
+ isPreferredForRepo: z.boolean().optional().meta({
702
+ description: "When true, this project is the preferred project for its connected repository. Agents will pick this project when asked to do something with the connected repository.",
703
+ }),
704
+ singleTenancyConfig: z.string().optional().meta({
705
+ description: "Single tenancy config ID for this project. Overrides the space-level defaultSingleTenancyConfig. References a document in the singleTenancyConfigurations collection.",
706
+ }),
707
+ previewPasswordProtection: z
708
+ .object({
709
+ enabled: z.boolean(),
710
+ password: z.string().optional(),
711
+ })
712
+ .optional()
713
+ .meta({ description: "Password-protect the preview URL." }),
714
+ })
715
+ .meta({ title: "ProjectSettings" });
716
+ /**
717
+ * Mask of internal, system/agent-managed settings keys. These fields live in
718
+ * {@link ProjectSettingsSchema} but are omitted from
719
+ * {@link ProjectSettingsExternalSchema} so external callers can't set them
720
+ * through the public update API.
721
+ */
722
+ const INTERNAL_PROJECT_SETTINGS_MASK = {
723
+ devServerUrl: true,
724
+ refreshPreview: true,
725
+ validateCommand: true,
726
+ proxyOrigin: true,
727
+ proxyDefaultOrigin: true,
728
+ defaultAutoPush: true,
729
+ setupDependencies: true,
730
+ includePath: true,
731
+ useNI: true,
732
+ repoSubpath: true,
733
+ recommendedRoot: true,
734
+ commitInstructions: true,
735
+ skipCommandSecurity: true,
736
+ autoCloseBranchOnMerge: true,
737
+ };
738
+ /**
739
+ * Runtime schema for the client-writable subset of {@link Project.settings},
740
+ * derived from {@link ProjectSettingsSchema} by omitting the `@internal`
741
+ * (system/agent-managed) fields. Use this for public update/PATCH payloads so
742
+ * external callers can't set internal-only fields.
743
+ */
744
+ export const ProjectSettingsExternalSchema = ProjectSettingsSchema.omit(INTERNAL_PROJECT_SETTINGS_MASK).meta({ title: "ProjectSettingsExternal" });
150
745
  /** Grace period before scheduled Neon deletions run (reconciler + schedule APIs). */
151
746
  export const NEON_DELETION_GRACE_PERIOD_MS = 30 * 24 * 60 * 60 * 1000;
152
747
  /**
@@ -437,19 +1032,37 @@ export const ProjectHostingSchema = z.object({
437
1032
  * and this field, so they never disagree.
438
1033
  */
439
1034
  unpublishTrigger: z.enum(["system", "user"]).optional().meta({
440
- description: "Who last unpublished the site. Absent === user. 'system' lets the reconciler auto-restore a usage-cap takedown; 'user' does not.",
1035
+ description: "Who last unpublished the site. Absent === user. 'system' lets the reconciler auto-restore a credit-based takedown; 'user' does not.",
441
1036
  }),
442
1037
  /**
443
1038
  * Why the system unpublished the site. Set iff `unpublishTrigger === "system"`;
444
1039
  * drives the projects-grid "suspended" card and takedown observability.
445
1040
  */
446
- systemUnpublishReason: z
447
- .enum(["bandwidth-cap", "web-request-cap"])
448
- .optional(),
1041
+ systemUnpublishReason: z.enum(["zero-credits"]).optional(),
449
1042
  domainIds: z.array(z.string()).optional().meta({
450
1043
  description: "Doc keys (normalized domains) in `hosting-domains` attached to this project. The domain docs are the source of truth; this is only the back-pointer.",
451
1044
  }),
452
1045
  });
1046
+ /**
1047
+ * Mask of the hosting sub-fields a client may write via `PATCH`. Everything
1048
+ * else on {@link ProjectHostingSchema} is owned by the slug-reservation /
1049
+ * publish / deploy / custom-domain flows.
1050
+ *
1051
+ * Deliberately an allowlist rather than a denylist of system-owned fields: a
1052
+ * new field added to `ProjectHostingSchema` must be opted *in* to be writable,
1053
+ * so the default for anything we add later is "not client-writable".
1054
+ */
1055
+ const PROJECT_UPDATABLE_HOSTING_FIELDS_MASK = {
1056
+ enabled: true,
1057
+ buildConfig: true,
1058
+ environment: true,
1059
+ autoDeploy: true,
1060
+ };
1061
+ /**
1062
+ * Client-writable subset of {@link ProjectHostingSchema}. Used for `PATCH`
1063
+ * payloads so external callers can't write system-owned hosting fields.
1064
+ */
1065
+ export const ProjectHostingExternalSchema = ProjectHostingSchema.pick(PROJECT_UPDATABLE_HOSTING_FIELDS_MASK).meta({ title: "ProjectHostingExternal" });
453
1066
  /**
454
1067
  * Canonical "is this project hostable" predicate. Keys off the explicit
455
1068
  * `hosting.enabled` flag rather than inferring from build-config presence.
@@ -457,6 +1070,82 @@ export const ProjectHostingSchema = z.object({
457
1070
  export const isProjectHostable = (hosting) => {
458
1071
  return (hosting === null || hosting === void 0 ? void 0 : hosting.enabled) === true;
459
1072
  };
1073
+ /**
1074
+ * Validates the body of `PATCH /projects/:projectId`. The schema's field set is
1075
+ * the allowlist of client-writable fields: unknown keys are stripped on parse,
1076
+ * so callers can't write arbitrary `Project` fields. `settings` and `hosting`
1077
+ * are partial — a PATCH can touch a subset of sub-fields.
1078
+ */
1079
+ export const UpdateProjectOptionsSchema = z.object({
1080
+ projectId: z.string(),
1081
+ name: z.string().optional(),
1082
+ settings: z
1083
+ .object({
1084
+ ...ProjectSettingsExternalSchema.partial().shape,
1085
+ // Deliberately looser than the stored shape, which requires `enabled`.
1086
+ previewPasswordProtection: z
1087
+ .object({
1088
+ enabled: z.boolean().optional(),
1089
+ password: z.string().optional(),
1090
+ })
1091
+ .optional()
1092
+ .meta({
1093
+ description: 'Password protection for the preview URL. A PATCH may change the password without resending `enabled`: a missing `enabled` means "preserve the existing value" rather than "disable".',
1094
+ }),
1095
+ })
1096
+ .optional(),
1097
+ hosting: ProjectHostingExternalSchema.partial().nullish(),
1098
+ deleteEnvironmentVariableKeys: z.array(z.string()).optional().meta({
1099
+ description: "Keys to delete from `settings.environmentVariables`. Without this list, keys missing from the incoming payload are preserved (additive merge) to protect against stale clients overwriting concurrent edits.",
1100
+ }),
1101
+ deleteHostingEnvironmentVariableKeys: z
1102
+ .object({
1103
+ build: z.array(z.string()).optional(),
1104
+ prod: z.array(z.string()).optional(),
1105
+ })
1106
+ .optional()
1107
+ .meta({
1108
+ description: "Keys to delete from hosting env vars, per scope. Without an entry for a scope, keys missing from that scope are preserved (additive merge).",
1109
+ }),
1110
+ snapshotVolume: z
1111
+ .object({
1112
+ volumeId: z.string(),
1113
+ appName: z.string(),
1114
+ createdAt: z.number(),
1115
+ })
1116
+ .optional(),
1117
+ pinned: z.boolean().optional(),
1118
+ pinOrder: z.number().optional(),
1119
+ archived: z.boolean().optional(),
1120
+ needSetup: z.boolean().optional(),
1121
+ repoFullName: z.string().optional(),
1122
+ repoUrl: z.string().optional(),
1123
+ repoAddedBy: z.string().optional().meta({
1124
+ description: "User ID of whoever added the repository connection. Only changeable alongside `repoUrl` — it records who connected the current repo, so re-attributing it on its own would be inaccurate.",
1125
+ }),
1126
+ templateRepoUrl: z.string().optional(),
1127
+ repoProvider: z.string().optional(),
1128
+ repoPrivate: z.boolean().optional(),
1129
+ repoDescription: z.string().optional(),
1130
+ screenshot: z.string().nullish(),
1131
+ isExample: z.boolean().optional(),
1132
+ isPublic: z.boolean().optional(),
1133
+ localPath: z.string().nullish(),
1134
+ domains: z.array(z.string()).optional(),
1135
+ accessMode: z.enum(["public", "private"]).optional(),
1136
+ projectAccess: ProjectAccessControlSchema.optional(),
1137
+ codeOnlyMode: z.boolean().optional(),
1138
+ codeOnlyReason: ExitStateSchema.exclude(["verified"]).nullish().meta({
1139
+ description: 'Setup-agent exit state that put the project in code-only mode. `null` clears it. Excludes "verified", which by definition is not a code-only outcome.',
1140
+ }),
1141
+ mobileOnlyDetected: z.boolean().optional(),
1142
+ autoSetupStatus: z
1143
+ .enum(["idle", "running", "needs-input", "completed", "error"])
1144
+ .optional(),
1145
+ userInitiatedAutoSetup: z.boolean().optional(),
1146
+ autoApplySetup: z.boolean().optional(),
1147
+ hidden: z.boolean().optional(),
1148
+ });
460
1149
  /**
461
1150
  * A single deploy record, stored at `deploys/{deployId}`. The Deploy.id is the document key.
462
1151
  */