@openchamber/sdk 2.5.0 → 2.5.1

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/dist/manifest.js CHANGED
@@ -87,11 +87,37 @@ export const resolveIntegrationApi = (integration) => {
87
87
  };
88
88
  /**
89
89
  * Host roles a service can stand in for. `browser` answers the agent's
90
- * `browser.*` actions in place of the in-app browser view; the contract is in
91
- * `service-providers.ts`. A service that provides a role needs no panel or
92
- * background entry: the host starts it on the first action.
90
+ * `browser.*` actions in place of the in-app browser view. `source-control`
91
+ * makes the extension a Git host next to GitHub, GitLab and Gitea, described
92
+ * by `contributes.sourceControl`. Both contracts are in `service-providers.ts`.
93
+ * A service that provides a role needs no panel or background entry: the host
94
+ * starts it on the first call.
93
95
  */
94
- export const GUEST_SERVICE_PROVIDES = ['browser'];
96
+ export const GUEST_SERVICE_PROVIDES = ['browser', 'source-control'];
97
+ /**
98
+ * The host names an extension's Git host `ext-<panel.id>`, and provider ids
99
+ * are at most 32 characters, so a guest taking the role has an id of at most
100
+ * this many.
101
+ */
102
+ export const SOURCE_CONTROL_GUEST_ID_MAX = 28;
103
+ /** The provider id OpenChamber gives the Git host an extension provides. */
104
+ export const sourceControlProviderIdOf = (guestId) => `ext-${guestId}`;
105
+ /** Default instances a Git host extension may name. */
106
+ export const SOURCE_CONTROL_DEFAULT_INSTANCES_MAX = 4;
107
+ /** URL templates per kind (`presentation.urls.changeRequest` / `.issue`). */
108
+ export const SOURCE_CONTROL_URL_TEMPLATES_MAX = 8;
109
+ /** Characters in one URL template. */
110
+ export const SOURCE_CONTROL_URL_TEMPLATE_MAX = 256;
111
+ /**
112
+ * The placeholders a URL template may use, each a whole path segment.
113
+ * `{namespace+}` is one or more segments (GitLab groups); `{owner}` exactly one.
114
+ */
115
+ export const SOURCE_CONTROL_URL_TOKENS = ['{owner}', '{namespace+}', '{repo}', '{number}'];
116
+ export const toPublicSourceControl = (contribution) => ({
117
+ label: contribution.label,
118
+ defaultInstances: [...contribution.instances.defaultInstances],
119
+ custom: contribution.instances.custom,
120
+ });
95
121
  export const serviceProvides = (service, role) => Boolean(service?.provides?.includes(role));
96
122
  /**
97
123
  * What a guest may do beyond drawing its own panel. The user approves the
package/dist/parse.d.ts CHANGED
@@ -114,6 +114,7 @@ export declare const openChamberManifestSchema: z.ZodObject<{
114
114
  }>>>;
115
115
  provides: z.ZodOptional<z.ZodArray<z.ZodEnum<{
116
116
  browser: "browser";
117
+ "source-control": "source-control";
117
118
  }>>>;
118
119
  surface: z.ZodOptional<z.ZodLiteral<true>>;
119
120
  }, z.core.$strip>>;
@@ -170,6 +171,115 @@ export declare const openChamberManifestSchema: z.ZodObject<{
170
171
  text: "text";
171
172
  }>>;
172
173
  }, z.core.$strip>>>;
174
+ sourceControl: z.ZodOptional<z.ZodObject<{
175
+ wire: z.ZodLiteral<1>;
176
+ label: z.ZodString;
177
+ terminology: z.ZodObject<{
178
+ changeRequest: z.ZodEnum<{
179
+ change: "change";
180
+ mergeRequest: "mergeRequest";
181
+ pullRequest: "pullRequest";
182
+ }>;
183
+ ci: z.ZodEnum<{
184
+ checks: "checks";
185
+ pipeline: "pipeline";
186
+ }>;
187
+ }, z.core.$strict>;
188
+ referencePrefix: z.ZodObject<{
189
+ changeRequest: z.ZodString;
190
+ issue: z.ZodString;
191
+ }, z.core.$strict>;
192
+ repositoryPath: z.ZodEnum<{
193
+ "namespace/name": "namespace/name";
194
+ "owner/name": "owner/name";
195
+ }>;
196
+ instances: z.ZodObject<{
197
+ defaultInstances: z.ZodArray<z.ZodString>;
198
+ custom: z.ZodBoolean;
199
+ allowLoopbackHttp: z.ZodBoolean;
200
+ allowPort: z.ZodBoolean;
201
+ placeholder: z.ZodString;
202
+ }, z.core.$strict>;
203
+ capabilities: z.ZodObject<{
204
+ projects: z.ZodDefault<z.ZodBoolean>;
205
+ issues: z.ZodDefault<z.ZodBoolean>;
206
+ changeRequests: z.ZodDefault<z.ZodBoolean>;
207
+ draftChangeRequests: z.ZodDefault<z.ZodBoolean>;
208
+ merge: z.ZodDefault<z.ZodUnion<readonly [z.ZodLiteral<false>, z.ZodObject<{
209
+ methods: z.ZodArray<z.ZodEnum<{
210
+ merge: "merge";
211
+ rebase: "rebase";
212
+ squash: "squash";
213
+ submit: "submit";
214
+ }>>;
215
+ }, z.core.$strict>]>>;
216
+ ci: z.ZodDefault<z.ZodBoolean>;
217
+ comments: z.ZodDefault<z.ZodBoolean>;
218
+ reviews: z.ZodDefault<z.ZodBoolean>;
219
+ state: z.ZodDefault<z.ZodBoolean>;
220
+ labels: z.ZodDefault<z.ZodBoolean>;
221
+ reviewers: z.ZodDefault<z.ZodBoolean>;
222
+ changeRequestPatch: z.ZodDefault<z.ZodBoolean>;
223
+ liveSummaries: z.ZodDefault<z.ZodBoolean>;
224
+ reviewModel: z.ZodOptional<z.ZodEnum<{
225
+ verdict: "verdict";
226
+ votes: "votes";
227
+ }>>;
228
+ revisions: z.ZodOptional<z.ZodBoolean>;
229
+ }, z.core.$strict>;
230
+ token: z.ZodObject<{
231
+ header: z.ZodEnum<{
232
+ authorization: "authorization";
233
+ "private-token": "private-token";
234
+ }>;
235
+ scheme: z.ZodEnum<{
236
+ basic: "basic";
237
+ bearer: "bearer";
238
+ none: "none";
239
+ token: "token";
240
+ }>;
241
+ username: z.ZodOptional<z.ZodBoolean>;
242
+ scopes: z.ZodArray<z.ZodString>;
243
+ createPath: z.ZodOptional<z.ZodString>;
244
+ }, z.core.$strict>;
245
+ transport: z.ZodObject<{
246
+ httpsUsername: z.ZodUnion<readonly [z.ZodObject<{
247
+ kind: z.ZodLiteral<"login">;
248
+ }, z.core.$strict>, z.ZodObject<{
249
+ kind: z.ZodLiteral<"fixed">;
250
+ value: z.ZodString;
251
+ }, z.core.$strict>]>;
252
+ appendDotGitToHttpsClone: z.ZodBoolean;
253
+ }, z.core.$strict>;
254
+ limits: z.ZodObject<{
255
+ commentMaxLength: z.ZodNumber;
256
+ reviewerId: z.ZodEnum<{
257
+ login: "login";
258
+ numeric: "numeric";
259
+ }>;
260
+ labelsMax: z.ZodNumber;
261
+ reviewersMax: z.ZodNumber;
262
+ }, z.core.$strict>;
263
+ trackedItems: z.ZodObject<{
264
+ batchLimit: z.ZodNumber;
265
+ }, z.core.$strict>;
266
+ presentation: z.ZodObject<{
267
+ abbreviation: z.ZodString;
268
+ promptNoun: z.ZodString;
269
+ sharedNumberSpace: z.ZodBoolean;
270
+ iconPath: z.ZodOptional<z.ZodString>;
271
+ urls: z.ZodObject<{
272
+ changeRequest: z.ZodArray<z.ZodString>;
273
+ issue: z.ZodArray<z.ZodString>;
274
+ }, z.core.$strict>;
275
+ features: z.ZodObject<{
276
+ authorCanReview: z.ZodDefault<z.ZodBoolean>;
277
+ issueCloseReason: z.ZodDefault<z.ZodBoolean>;
278
+ submitRequirements: z.ZodOptional<z.ZodBoolean>;
279
+ }, z.core.$strict>;
280
+ changeRequestTemplates: z.ZodArray<z.ZodString>;
281
+ }, z.core.$strict>;
282
+ }, z.core.$strict>>;
173
283
  }, z.core.$strip>;
174
284
  }, z.core.$strip>;
175
285
  export declare const PACKAGE_VERSION_PATTERN: RegExp;
@@ -289,6 +399,7 @@ export declare const packageManifestSchema: z.ZodObject<{
289
399
  }>>>;
290
400
  provides: z.ZodOptional<z.ZodArray<z.ZodEnum<{
291
401
  browser: "browser";
402
+ "source-control": "source-control";
292
403
  }>>>;
293
404
  surface: z.ZodOptional<z.ZodLiteral<true>>;
294
405
  }, z.core.$strip>>;
@@ -345,6 +456,115 @@ export declare const packageManifestSchema: z.ZodObject<{
345
456
  text: "text";
346
457
  }>>;
347
458
  }, z.core.$strip>>>;
459
+ sourceControl: z.ZodOptional<z.ZodObject<{
460
+ wire: z.ZodLiteral<1>;
461
+ label: z.ZodString;
462
+ terminology: z.ZodObject<{
463
+ changeRequest: z.ZodEnum<{
464
+ change: "change";
465
+ mergeRequest: "mergeRequest";
466
+ pullRequest: "pullRequest";
467
+ }>;
468
+ ci: z.ZodEnum<{
469
+ checks: "checks";
470
+ pipeline: "pipeline";
471
+ }>;
472
+ }, z.core.$strict>;
473
+ referencePrefix: z.ZodObject<{
474
+ changeRequest: z.ZodString;
475
+ issue: z.ZodString;
476
+ }, z.core.$strict>;
477
+ repositoryPath: z.ZodEnum<{
478
+ "namespace/name": "namespace/name";
479
+ "owner/name": "owner/name";
480
+ }>;
481
+ instances: z.ZodObject<{
482
+ defaultInstances: z.ZodArray<z.ZodString>;
483
+ custom: z.ZodBoolean;
484
+ allowLoopbackHttp: z.ZodBoolean;
485
+ allowPort: z.ZodBoolean;
486
+ placeholder: z.ZodString;
487
+ }, z.core.$strict>;
488
+ capabilities: z.ZodObject<{
489
+ projects: z.ZodDefault<z.ZodBoolean>;
490
+ issues: z.ZodDefault<z.ZodBoolean>;
491
+ changeRequests: z.ZodDefault<z.ZodBoolean>;
492
+ draftChangeRequests: z.ZodDefault<z.ZodBoolean>;
493
+ merge: z.ZodDefault<z.ZodUnion<readonly [z.ZodLiteral<false>, z.ZodObject<{
494
+ methods: z.ZodArray<z.ZodEnum<{
495
+ merge: "merge";
496
+ rebase: "rebase";
497
+ squash: "squash";
498
+ submit: "submit";
499
+ }>>;
500
+ }, z.core.$strict>]>>;
501
+ ci: z.ZodDefault<z.ZodBoolean>;
502
+ comments: z.ZodDefault<z.ZodBoolean>;
503
+ reviews: z.ZodDefault<z.ZodBoolean>;
504
+ state: z.ZodDefault<z.ZodBoolean>;
505
+ labels: z.ZodDefault<z.ZodBoolean>;
506
+ reviewers: z.ZodDefault<z.ZodBoolean>;
507
+ changeRequestPatch: z.ZodDefault<z.ZodBoolean>;
508
+ liveSummaries: z.ZodDefault<z.ZodBoolean>;
509
+ reviewModel: z.ZodOptional<z.ZodEnum<{
510
+ verdict: "verdict";
511
+ votes: "votes";
512
+ }>>;
513
+ revisions: z.ZodOptional<z.ZodBoolean>;
514
+ }, z.core.$strict>;
515
+ token: z.ZodObject<{
516
+ header: z.ZodEnum<{
517
+ authorization: "authorization";
518
+ "private-token": "private-token";
519
+ }>;
520
+ scheme: z.ZodEnum<{
521
+ basic: "basic";
522
+ bearer: "bearer";
523
+ none: "none";
524
+ token: "token";
525
+ }>;
526
+ username: z.ZodOptional<z.ZodBoolean>;
527
+ scopes: z.ZodArray<z.ZodString>;
528
+ createPath: z.ZodOptional<z.ZodString>;
529
+ }, z.core.$strict>;
530
+ transport: z.ZodObject<{
531
+ httpsUsername: z.ZodUnion<readonly [z.ZodObject<{
532
+ kind: z.ZodLiteral<"login">;
533
+ }, z.core.$strict>, z.ZodObject<{
534
+ kind: z.ZodLiteral<"fixed">;
535
+ value: z.ZodString;
536
+ }, z.core.$strict>]>;
537
+ appendDotGitToHttpsClone: z.ZodBoolean;
538
+ }, z.core.$strict>;
539
+ limits: z.ZodObject<{
540
+ commentMaxLength: z.ZodNumber;
541
+ reviewerId: z.ZodEnum<{
542
+ login: "login";
543
+ numeric: "numeric";
544
+ }>;
545
+ labelsMax: z.ZodNumber;
546
+ reviewersMax: z.ZodNumber;
547
+ }, z.core.$strict>;
548
+ trackedItems: z.ZodObject<{
549
+ batchLimit: z.ZodNumber;
550
+ }, z.core.$strict>;
551
+ presentation: z.ZodObject<{
552
+ abbreviation: z.ZodString;
553
+ promptNoun: z.ZodString;
554
+ sharedNumberSpace: z.ZodBoolean;
555
+ iconPath: z.ZodOptional<z.ZodString>;
556
+ urls: z.ZodObject<{
557
+ changeRequest: z.ZodArray<z.ZodString>;
558
+ issue: z.ZodArray<z.ZodString>;
559
+ }, z.core.$strict>;
560
+ features: z.ZodObject<{
561
+ authorCanReview: z.ZodDefault<z.ZodBoolean>;
562
+ issueCloseReason: z.ZodDefault<z.ZodBoolean>;
563
+ submitRequirements: z.ZodOptional<z.ZodBoolean>;
564
+ }, z.core.$strict>;
565
+ changeRequestTemplates: z.ZodArray<z.ZodString>;
566
+ }, z.core.$strict>;
567
+ }, z.core.$strict>>;
348
568
  }, z.core.$strip>;
349
569
  }, z.core.$strip>;
350
570
  }, z.core.$strip>;
package/dist/parse.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { OPENCHAMBER_SDK_MANIFEST_API_VERSIONS } from './api-version.js';
3
3
  import { OPENCHAMBER_ENGINE_PATTERN } from './host-version.js';
4
- import { DECLARED_GUEST_CAPABILITIES, GUEST_ACTIONS_MAX, GUEST_ACTION_LABEL_MAX, GUEST_COMMANDS_MAX, GUEST_COMMAND_DESCRIPTION_MAX, GUEST_COMMAND_NAME, GUEST_FILESYSTEM_PATTERNS_MAX, GUEST_FILESYSTEM_PATTERN_MAX, GUEST_ORIGINS_MAX, GUEST_SERVICE_PROVIDES, GUEST_TOOLS_MAX, GUEST_TOOL_COLUMNS_MAX, GUEST_TOOL_COLUMN_MAX, GUEST_TOOL_LANGUAGE_MAX, GUEST_TOOL_MATCH, GUEST_TOOL_MATCH_MAX, GUEST_TOOL_NAME_MAX, GUEST_TOOL_OUTPUTS, GUEST_TOOL_TEMPLATE_MAX, PANEL_ID, hasGuestPage, isGuestFilesystemPattern, isGuestPackageSvgIcon, isSafeAssetPath, resolveIntegrationAuth, GUEST_SURFACE_DOCKS, GUEST_SURFACE_DOCK_SIZE_MAX, GUEST_SURFACE_DOCK_SIZE_MIN, GUEST_STATUS_SECTION_HEIGHT_MAX, GUEST_STATUS_SECTION_HEIGHT_MIN, GUEST_STATUS_SECTION_TITLE_MAX, } from './manifest.js';
4
+ import { DECLARED_GUEST_CAPABILITIES, GUEST_ACTIONS_MAX, GUEST_ACTION_LABEL_MAX, GUEST_COMMANDS_MAX, GUEST_COMMAND_DESCRIPTION_MAX, GUEST_COMMAND_NAME, GUEST_FILESYSTEM_PATTERNS_MAX, GUEST_FILESYSTEM_PATTERN_MAX, GUEST_ORIGINS_MAX, GUEST_SERVICE_PROVIDES, GUEST_TOOLS_MAX, GUEST_TOOL_COLUMNS_MAX, GUEST_TOOL_COLUMN_MAX, GUEST_TOOL_LANGUAGE_MAX, GUEST_TOOL_MATCH, GUEST_TOOL_MATCH_MAX, GUEST_TOOL_NAME_MAX, GUEST_TOOL_OUTPUTS, GUEST_TOOL_TEMPLATE_MAX, PANEL_ID, hasGuestPage, isGuestFilesystemPattern, isGuestPackageSvgIcon, isSafeAssetPath, resolveIntegrationAuth, GUEST_SURFACE_DOCKS, GUEST_SURFACE_DOCK_SIZE_MAX, GUEST_SURFACE_DOCK_SIZE_MIN, GUEST_STATUS_SECTION_HEIGHT_MAX, GUEST_STATUS_SECTION_HEIGHT_MIN, GUEST_STATUS_SECTION_TITLE_MAX, SOURCE_CONTROL_DEFAULT_INSTANCES_MAX, SOURCE_CONTROL_GUEST_ID_MAX, SOURCE_CONTROL_URL_TEMPLATES_MAX, SOURCE_CONTROL_URL_TEMPLATE_MAX, SOURCE_CONTROL_URL_TOKENS, } from './manifest.js';
5
5
  import { GUEST_FILE_EDITORS_MAX, GUEST_FILE_EDITOR_PATTERNS_MAX, GUEST_FILE_EDITOR_TITLE_MAX, isFileEditorPattern, } from './file-editor.js';
6
6
  const isPanelIcon = (value) => (PANEL_ID.test(value) || isGuestPackageSvgIcon(value));
7
7
  const isHttpsUrl = (value) => {
@@ -156,6 +156,148 @@ const serviceSchema = z.object({
156
156
  .optional(),
157
157
  surface: z.literal(true).optional(),
158
158
  });
159
+ // SVG path data only, the same rule the UI applies to a provider's icon path:
160
+ // commands, numbers and separators, nothing that could close the attribute.
161
+ const SVG_PATH_DATA = /^[MmLlHhVvCcSsQqTtAaZz0-9eE.,\s+-]+$/;
162
+ // A literal URL template segment: no placeholder braces, no regex syntax.
163
+ const URL_TEMPLATE_LITERAL = /^[A-Za-z0-9._~-]+$/;
164
+ const URL_TEMPLATE_TOKENS = new Set(SOURCE_CONTROL_URL_TOKENS);
165
+ /**
166
+ * A web path template of a change request or issue: `/`, then segments that
167
+ * are each a literal or one of the known placeholders. `{repo}` and
168
+ * `{number}` appear exactly once, and the repository's owner exactly once as
169
+ * `{owner}` (`owner/name` paths) or `{namespace+}` (`namespace/name` paths).
170
+ */
171
+ const isUrlTemplate = (value, repositoryPath) => {
172
+ if (!value.startsWith('/') || value.length > SOURCE_CONTROL_URL_TEMPLATE_MAX)
173
+ return false;
174
+ const segments = value.slice(1).split('/');
175
+ // A normalized URL never has a `.` or `..` segment, so a template with one matches nothing.
176
+ if (segments.some((segment) => segment === '.' || segment === '..'))
177
+ return false;
178
+ if (segments.some((segment) => !URL_TEMPLATE_TOKENS.has(segment) && !URL_TEMPLATE_LITERAL.test(segment)))
179
+ return false;
180
+ const count = (token) => segments.filter((segment) => segment === token).length;
181
+ const owner = repositoryPath === 'owner/name' ? '{owner}' : '{namespace+}';
182
+ const other = repositoryPath === 'owner/name' ? '{namespace+}' : '{owner}';
183
+ return count('{repo}') === 1 && count('{number}') === 1 && count(owner) === 1 && count(other) === 0;
184
+ };
185
+ // A file inside the repository, as a change request template names it:
186
+ // relative, no `.` or `..` segment, dot-folders like `.gitea/` allowed.
187
+ const REPOSITORY_FILE_SEGMENT = /^[A-Za-z0-9._-]+$/;
188
+ const isRepositoryFilePath = (value) => value.length > 0 && value.split('/')
189
+ .every((segment) => REPOSITORY_FILE_SEGMENT.test(segment) && segment !== '.' && segment !== '..');
190
+ const prefixSchema = z.string().min(1).max(4).regex(/^\S+$/);
191
+ const literalSchema = z.string().trim().min(1).max(64);
192
+ const sourceControlCapabilitiesSchema = z.object({
193
+ projects: z.boolean().default(false),
194
+ issues: z.boolean().default(false),
195
+ changeRequests: z.boolean().default(false),
196
+ draftChangeRequests: z.boolean().default(false),
197
+ merge: z.union([
198
+ z.literal(false),
199
+ z.object({
200
+ methods: z.array(z.enum(['merge', 'squash', 'rebase', 'submit'])).min(1).max(3)
201
+ .refine((methods) => new Set(methods).size === methods.length, { message: 'merge methods must be unique' })
202
+ // A host that submits a change merges it the one way its project is set up.
203
+ .refine((methods) => !methods.includes('submit') || methods.length === 1, { message: '"submit" is the only merge method when present' }),
204
+ }).strict(),
205
+ ]).default(false),
206
+ ci: z.boolean().default(false),
207
+ comments: z.boolean().default(false),
208
+ reviews: z.boolean().default(false),
209
+ state: z.boolean().default(false),
210
+ labels: z.boolean().default(false),
211
+ reviewers: z.boolean().default(false),
212
+ changeRequestPatch: z.boolean().default(false),
213
+ liveSummaries: z.boolean().default(false),
214
+ // Left out rather than defaulted, so a manifest that does not use them parses as before.
215
+ reviewModel: z.enum(['verdict', 'votes']).optional(),
216
+ revisions: z.boolean().optional(),
217
+ }).strict();
218
+ // A Git username OpenChamber puts before the token in an HTTPS credential.
219
+ const FIXED_USERNAME = /^[A-Za-z0-9._-]{1,64}$/;
220
+ const urlTemplatesSchema = z.array(z.string().trim().min(1)).max(SOURCE_CONTROL_URL_TEMPLATES_MAX);
221
+ const sourceControlSchema = z.object({
222
+ wire: z.literal(1),
223
+ label: literalSchema,
224
+ terminology: z.object({
225
+ changeRequest: z.enum(['pullRequest', 'mergeRequest', 'change']),
226
+ ci: z.enum(['checks', 'pipeline']),
227
+ }).strict(),
228
+ referencePrefix: z.object({ changeRequest: prefixSchema, issue: prefixSchema }).strict(),
229
+ repositoryPath: z.enum(['owner/name', 'namespace/name']),
230
+ instances: z.object({
231
+ defaultInstances: z.array(z.string().trim().refine(isHttpsOrigin, { message: 'must be an https origin with no path, like https://git.example.com' }))
232
+ .max(SOURCE_CONTROL_DEFAULT_INSTANCES_MAX)
233
+ .refine((origins) => new Set(origins).size === origins.length, { message: 'default instances must be unique' }),
234
+ custom: z.boolean(),
235
+ allowLoopbackHttp: z.boolean(),
236
+ allowPort: z.boolean(),
237
+ placeholder: z.string().trim().max(256),
238
+ }).strict().refine((instances) => instances.custom || instances.defaultInstances.length > 0, { message: 'without custom instances, defaultInstances names at least one' }),
239
+ capabilities: sourceControlCapabilitiesSchema,
240
+ token: z.object({
241
+ header: z.enum(['authorization', 'private-token']),
242
+ scheme: z.enum(['bearer', 'token', 'basic', 'none']),
243
+ username: z.boolean().optional(),
244
+ scopes: z.array(z.string().trim().min(1).max(64)).max(32),
245
+ createPath: z.string().trim().max(256).refine(isSafeApiPath, { message: 'must be a path on the instance starting with "/", with no "." or ".." segment' }).optional(),
246
+ }).strict().refine((token) => (token.header === 'private-token') === (token.scheme === 'none'), { message: 'private-token takes the bare token; authorization takes a bearer, token or basic scheme' }).refine(
247
+ // Basic sends `login:token`, so Settings must ask for the login; no other scheme uses one.
248
+ (token) => (token.scheme === 'basic') === (token.username === true), { message: 'the basic scheme needs username: true, and username is only for the basic scheme' }),
249
+ transport: z.object({
250
+ httpsUsername: z.union([
251
+ z.object({ kind: z.literal('login') }).strict(),
252
+ z.object({ kind: z.literal('fixed'), value: z.string().regex(FIXED_USERNAME) }).strict(),
253
+ ]),
254
+ appendDotGitToHttpsClone: z.boolean(),
255
+ }).strict(),
256
+ limits: z.object({
257
+ commentMaxLength: z.number().int().min(1).max(1_000_000),
258
+ reviewerId: z.enum(['numeric', 'login']),
259
+ labelsMax: z.number().int().min(0).max(100),
260
+ reviewersMax: z.number().int().min(0).max(50),
261
+ }).strict(),
262
+ trackedItems: z.object({ batchLimit: z.number().int().min(1).max(50) }).strict(),
263
+ presentation: z.object({
264
+ abbreviation: z.string().trim().min(1).max(8),
265
+ promptNoun: literalSchema,
266
+ sharedNumberSpace: z.boolean(),
267
+ iconPath: z.string().max(8192).regex(SVG_PATH_DATA).optional(),
268
+ urls: z.object({ changeRequest: urlTemplatesSchema, issue: urlTemplatesSchema }).strict(),
269
+ features: z.object({
270
+ authorCanReview: z.boolean().default(false),
271
+ issueCloseReason: z.boolean().default(false),
272
+ submitRequirements: z.boolean().optional(),
273
+ }).strict(),
274
+ changeRequestTemplates: z.array(z.string().trim().max(256).refine(isRepositoryFilePath, {
275
+ message: 'must be a file path inside the repository, like .gitea/pull_request_template.md, with no "." or ".." segment',
276
+ })).max(8),
277
+ }).strict(),
278
+ }).strict().superRefine((value, ctx) => {
279
+ for (const kind of ['changeRequest', 'issue']) {
280
+ value.presentation.urls[kind].forEach((template, index) => {
281
+ if (isUrlTemplate(template, value.repositoryPath))
282
+ return;
283
+ ctx.addIssue({
284
+ code: 'custom', path: ['presentation', 'urls', kind, index],
285
+ message: `templates start with "/" and use literal segments and ${SOURCE_CONTROL_URL_TOKENS.join(', ')}: {repo} and {number} once, and ${value.repositoryPath === 'owner/name' ? '{owner}' : '{namespace+}'} once for ${value.repositoryPath} paths.`,
286
+ });
287
+ });
288
+ }
289
+ // Change request patterns are matched first, so an issue template that is
290
+ // also a change request template would read every issue link as one.
291
+ const changeRequestTemplates = new Set(value.presentation.urls.changeRequest);
292
+ value.presentation.urls.issue.forEach((template, index) => {
293
+ if (!changeRequestTemplates.has(template))
294
+ return;
295
+ ctx.addIssue({
296
+ code: 'custom', path: ['presentation', 'urls', 'issue', index],
297
+ message: 'is also a changeRequest template; a link names either a change request or an issue.',
298
+ });
299
+ });
300
+ });
159
301
  const uniqueBy = (items, key) => (new Set(items.map(key)).size === items.length);
160
302
  // `roles` only means something on a message action and `payload` only on a
161
303
  // session action; a dead field is a misconfiguration and fails closed.
@@ -229,6 +371,7 @@ const contributesSchema = z.object({
229
371
  commands: commandsSchema.optional(),
230
372
  tools: toolsSchema.optional(),
231
373
  fileEditors: fileEditorsSchema.optional(),
374
+ sourceControl: sourceControlSchema.optional(),
232
375
  });
233
376
  /**
234
377
  * Contributions that need either the panel or background execution frame.
@@ -264,6 +407,23 @@ export const openChamberManifestSchema = z.object({
264
407
  openchamber: z.string().trim().regex(OPENCHAMBER_ENGINE_PATTERN),
265
408
  }).strict().optional(),
266
409
  contributes: contributesSchema.superRefine((contributes, ctx) => {
410
+ // The Git host and the role that serves it come together, and the host
411
+ // names it `ext-<panel.id>` within the 32-character provider id.
412
+ const providesSourceControl = contributes.service?.provides?.includes('source-control') === true;
413
+ if (providesSourceControl !== (contributes.sourceControl !== undefined)) {
414
+ ctx.addIssue({
415
+ code: 'custom', path: ['service', 'provides'],
416
+ message: 'service.provides "source-control" and contributes.sourceControl come together: declare both or neither.',
417
+ });
418
+ return;
419
+ }
420
+ if (providesSourceControl && contributes.panel.id.length > SOURCE_CONTROL_GUEST_ID_MAX) {
421
+ ctx.addIssue({
422
+ code: 'custom', path: ['service', 'provides'],
423
+ message: `A source-control extension's panel.id is at most ${SOURCE_CONTROL_GUEST_ID_MAX} characters: OpenChamber names its Git host "ext-<panel.id>".`,
424
+ });
425
+ return;
426
+ }
267
427
  // Docking only means something for a page beside a shared surface.
268
428
  const docked = contributes.panel.dock !== undefined || contributes.panel.size !== undefined;
269
429
  if (docked && !(contributes.service?.surface && hasGuestPage(contributes))) {
@@ -343,6 +503,9 @@ const fail = (code, message) => ({
343
503
  code,
344
504
  message,
345
505
  });
506
+ /** `contributes.sourceControl.instances.defaultInstances[0]`, relative to the `openchamber` block. */
507
+ const fieldPath = (issuePath) => (issuePath[0] === 'openchamber' ? issuePath.slice(1) : issuePath)
508
+ .reduce((field, segment) => (Number.isInteger(segment) ? `${field}[${String(segment)}]` : `${field ? `${field}.` : ''}${String(segment)}`), '');
346
509
  const failureFromIssue = (issue) => {
347
510
  // Paths are reported relative to the `openchamber` block whether the
348
511
  // document was a bare manifest or a package.json envelope.
@@ -427,8 +590,17 @@ const failureFromIssue = (issue) => {
427
590
  if (path.startsWith('contributes.integration')) {
428
591
  return fail('invalid-integration', 'contributes.integration needs a name, description, and oauth, token, or host.');
429
592
  }
593
+ if (path.startsWith('contributes.sourceControl')) {
594
+ // The field is named either way: a refine's message alone does not say which entry failed.
595
+ const field = fieldPath(issue.path);
596
+ return fail('invalid-service', issue.code === 'custom'
597
+ ? `${field}: ${issue.message}`
598
+ : `${field} is invalid: see "contributes.sourceControl" in GUEST_SERVICES.md for every field.`);
599
+ }
430
600
  if (path.startsWith('contributes.service')) {
431
- return fail('invalid-service', 'contributes.service needs entry, runtime "host", optional permissions, optional provides ("browser"), and optional surface (true).');
601
+ return fail('invalid-service', issue.code === 'custom'
602
+ ? issue.message
603
+ : 'contributes.service needs entry, runtime "host", optional permissions, optional provides ("browser", "source-control"), and optional surface (true).');
432
604
  }
433
605
  return fail('missing-panel', 'contributes.panel is required.');
434
606
  };
package/dist/schemas.d.ts CHANGED
@@ -4,6 +4,6 @@ export { guestStorageRequestSchema, guestStorageResultSchema, guestWorkspaceSnap
4
4
  export { guestStatusControlsSchema, guestStatusControlEventSchema } from './status-control-schemas.ts';
5
5
  export { guestPopoverClosedEventSchema, guestPopoverRequestSchema } from './popover-schemas.ts';
6
6
  export { guestMessageSchema, hostMessageSchema, parseGuestMessage, parseHostMessage, } from './protocol.ts';
7
- export { browserControlActionSchema, browserProviderResultSchema } from './service-provider-schemas.ts';
7
+ export { browserControlActionSchema, browserProviderResultSchema, sourceControlProviderMethodSchema, sourceControlProviderSchemasV1, } from './service-provider-schemas.ts';
8
8
  export { surfaceClipboardAnswerSchema, surfaceControllerSchema, surfaceHostMessageSchema, surfaceInputEventSchema, surfaceResizeAnswerSchema, surfaceViewerMessageSchema, } from './service-surface-schemas.ts';
9
9
  export type { SurfaceHostMessage, SurfaceViewerMessage } from './service-surface-schemas.ts';
package/dist/schemas.js CHANGED
@@ -6,5 +6,5 @@ export { guestStorageRequestSchema, guestStorageResultSchema, guestWorkspaceSnap
6
6
  export { guestStatusControlsSchema, guestStatusControlEventSchema } from './status-control-schemas.js';
7
7
  export { guestPopoverClosedEventSchema, guestPopoverRequestSchema } from './popover-schemas.js';
8
8
  export { guestMessageSchema, hostMessageSchema, parseGuestMessage, parseHostMessage, } from './protocol.js';
9
- export { browserControlActionSchema, browserProviderResultSchema } from './service-provider-schemas.js';
9
+ export { browserControlActionSchema, browserProviderResultSchema, sourceControlProviderMethodSchema, sourceControlProviderSchemasV1, } from './service-provider-schemas.js';
10
10
  export { surfaceClipboardAnswerSchema, surfaceControllerSchema, surfaceHostMessageSchema, surfaceInputEventSchema, surfaceResizeAnswerSchema, surfaceViewerMessageSchema, } from './service-surface-schemas.js';