@valbuild/server 0.146.1 → 0.146.4

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.
@@ -8,7 +8,7 @@ import path__default from 'path';
8
8
  import fs from 'fs';
9
9
  import vm from 'node:vm';
10
10
  import { Module } from 'node:module';
11
- import { isSchemaSourceFixError, resolveSchemaSourceFixForError, Patch, getErrorMessageFromUnknownJson, schemaErrorsOf, schemaTypesOfPath, sideFiles, sideSource, PlanRefusalError, PatchId as PatchId$1, JSONValue, SerializedSchema, newestCommitSha, VAL_ENABLE_COOKIE_NAME, VAL_STATE_COOKIE, VAL_SESSION_COOKIE, parseJob, VAL_STUDIO_HEADER, Api, traverseSchemaSource, richTextImageSchema, forEachRichTextImage } from '@valbuild/shared/internal';
11
+ import { isSchemaSourceFixError, resolveSchemaSourceFixForError, Patch, getErrorMessageFromUnknownJson, schemaErrorsOf, PatchSets, joinSharedChanges, schemaTypesOfPath, sideFiles, sideSource, JSONValue, PlanRefusalError, PatchId as PatchId$1, SerializedSchema, newestCommitSha, VAL_ENABLE_COOKIE_NAME, VAL_STATE_COOKIE, VAL_SESSION_COOKIE, parseJob, VAL_STUDIO_HEADER, Api, traverseSchemaSource, richTextImageSchema, forEachRichTextImage } from '@valbuild/shared/internal';
12
12
  import { createUIRequestHandler } from '@valbuild/ui/server';
13
13
  import crypto$1, { createHash } from 'crypto';
14
14
  import z$1, { z } from 'zod';
@@ -9674,6 +9674,45 @@ function mergeFromSite(input) {
9674
9674
  };
9675
9675
  }
9676
9676
 
9677
+ /** A change pending on the site, in chain order. */
9678
+
9679
+ /**
9680
+ * The site's pending changes that conflict with going from `site` to
9681
+ * `merged`, as whole patch sets: each inner list moves together, into a
9682
+ * proposal of its own. Everything else stays pending.
9683
+ *
9684
+ * Patch sets are the Studio's own (`PatchSets`), from the schema the changes
9685
+ * were written against; sets that share a change are one, because a change
9686
+ * cannot be split. A set moves when, taken as the site's side of a three-way
9687
+ * merge with the merged commit as the other, it conflicts (`mergeValues`, so
9688
+ * the same value on both sides moves too); or when it no longer applies to
9689
+ * the merged commit, or leaves it invalid under the merged schema.
9690
+ */
9691
+ function changesToMove(input) {
9692
+ const {
9693
+ site,
9694
+ merged,
9695
+ pending
9696
+ } = input;
9697
+ const sets = new PatchSets();
9698
+ for (const change of pending) {
9699
+ var _site$modules$change$;
9700
+ sets.insert(change.moduleFilePath, (_site$modules$change$ = site.modules[change.moduleFilePath]) === null || _site$modules$change$ === void 0 ? void 0 : _site$modules$change$.schema, change.patch, change.patchId, "", null);
9701
+ }
9702
+ const groups = joinSharedChanges(sets.serialize().map(set => set.patches.map(p => p.patchId)));
9703
+ const moves = [];
9704
+ for (const group of groups) {
9705
+ const changes = pending.filter(change => group.has(change.patchId));
9706
+ if (conflictsWithMerge(changes, site, merged)) {
9707
+ moves.push(changes.map(change => change.patchId));
9708
+ }
9709
+ }
9710
+ // In chain order, so the moved proposals are opened in the order the
9711
+ // changes were written.
9712
+ const order = ids => pending.findIndex(change => change.patchId === ids[0]);
9713
+ return moves.sort((a, b) => order(a) - order(b));
9714
+ }
9715
+
9677
9716
  // --- the pieces --------------------------------------------------------------
9678
9717
 
9679
9718
  /**
@@ -9706,6 +9745,39 @@ function resolveContent(moduleFilePath, merged, resolutions) {
9706
9745
  unresolved
9707
9746
  };
9708
9747
  }
9748
+ function conflictsWithMerge(changes, site, merged) {
9749
+ const modules = [...new Set(changes.map(change => change.moduleFilePath))];
9750
+ for (const moduleFilePath of modules) {
9751
+ var _site$modules$moduleF;
9752
+ const ops = changes.filter(change => change.moduleFilePath === moduleFilePath).flatMap(change => change.patch).filter(op => op.op !== "file");
9753
+ const before = (_site$modules$moduleF = site.modules[moduleFilePath]) === null || _site$modules$moduleF === void 0 ? void 0 : _site$modules$moduleF.source;
9754
+ const after = merged.modules[moduleFilePath];
9755
+ if (before === undefined || after === undefined) return true;
9756
+ const written = apply(before, ops);
9757
+ if (written === undefined) return true;
9758
+ const mergedSource = after.source;
9759
+ const atomic = atomicFor(after.schema);
9760
+ if (mergeValues(before, written, mergedSource, atomic).conflicts.length) {
9761
+ return true;
9762
+ }
9763
+ // Over the merged commit: it applies, and is as valid as it was.
9764
+ const over = apply(mergedSource, ops);
9765
+ if (over === undefined) return true;
9766
+ const known = new Set(errorPaths(moduleFilePath, after, mergedSource));
9767
+ if (errorPaths(moduleFilePath, after, over).some(path => !known.has(path))) {
9768
+ return true;
9769
+ }
9770
+ }
9771
+ return false;
9772
+ }
9773
+ function apply(source, ops) {
9774
+ if (source === undefined) return undefined;
9775
+ const applied = applyPatch(deepClone(source), new JSONOps(), ops);
9776
+ return result.isOk(applied) ? applied.value : undefined;
9777
+ }
9778
+ function errorPaths(moduleFilePath, module, source) {
9779
+ return validateModule(moduleFilePath, module, source).map(error => error.path.join("/"));
9780
+ }
9709
9781
 
9710
9782
  /**
9711
9783
  * The schema check of a merge: content in `merged` -- the project
@@ -10069,6 +10141,105 @@ function sameJson(a, b) {
10069
10141
  return JSON.stringify(a) === JSON.stringify(b);
10070
10142
  }
10071
10143
 
10144
+ /**
10145
+ * Which of the site's pending changes a merge of a proposal moves into
10146
+ * proposals of their own (valbuild/home `docs/hosted-publishing.md`, step 8.2,
10147
+ * decision 4), from what content hands over (`GET /proposals/:name/move-inputs`)
10148
+ * and this server's schemas.
10149
+ *
10150
+ * The schemas are the ones this server runs: the proposal's own build at its
10151
+ * address, which is the code the merge publishes. They stand in for the site's
10152
+ * too, to group the site's changes into patch sets -- the same unless the
10153
+ * proposal changed that module's schema, in which case a change written
10154
+ * against the old one is checked against the new one, which is what the
10155
+ * merge leaves it under anyway.
10156
+ *
10157
+ * A page this server has no schema for is moved whole: what cannot be checked
10158
+ * is not left on the site to break.
10159
+ */
10160
+
10161
+ const PatchIdOf = z.string().refine(s => !!s);
10162
+ const ModuleFilePathOf = z.string().refine(s => s.startsWith("/"));
10163
+
10164
+ /**
10165
+ * Content's `move-inputs` answer, as it is parsed: the one definition, so the
10166
+ * parser and what `movesAtMerge` reads cannot drift apart.
10167
+ */
10168
+ const MoveInputs = z.object({
10169
+ /** The proposal's last save; absent from a content service before it. */
10170
+ save: z.string().nullable().optional(),
10171
+ siteHead: z.string().nullable(),
10172
+ pending: z.array(z.object({
10173
+ patchId: PatchIdOf,
10174
+ moduleFilePath: ModuleFilePathOf,
10175
+ patch: Patch,
10176
+ author: z.string().nullable(),
10177
+ authorName: z.string().nullable()
10178
+ })),
10179
+ modules: z.record(z.string(), z.object({
10180
+ site: JSONValue.nullable(),
10181
+ merged: JSONValue.nullable()
10182
+ }))
10183
+ });
10184
+
10185
+ /** What the Publish press carries (`MergeBody.moves` in content), and who moves. */
10186
+
10187
+ function movesAtMerge(inputs, schemas) {
10188
+ const site = {
10189
+ files: {},
10190
+ modules: {}
10191
+ };
10192
+ const merged = {
10193
+ files: {},
10194
+ modules: {}
10195
+ };
10196
+ // The pages of the pending changes, which are the pages content answered.
10197
+ for (const moduleFilePath of new Set(inputs.pending.map(change => change.moduleFilePath))) {
10198
+ const page = inputs.modules[moduleFilePath];
10199
+ if (page === undefined) continue;
10200
+ const {
10201
+ site: before,
10202
+ merged: after
10203
+ } = page;
10204
+ const schema = schemas[moduleFilePath];
10205
+ if (schema === undefined) continue;
10206
+ const serialized = schema["executeSerialize"]();
10207
+ const validate = source => validateWith(schema, moduleFilePath, source);
10208
+ if (before !== null) {
10209
+ site.modules[moduleFilePath] = {
10210
+ source: before,
10211
+ schema: serialized,
10212
+ entries: null,
10213
+ validate
10214
+ };
10215
+ }
10216
+ if (after !== null) {
10217
+ merged.modules[moduleFilePath] = {
10218
+ source: after,
10219
+ schema: serialized,
10220
+ entries: null,
10221
+ validate
10222
+ };
10223
+ }
10224
+ }
10225
+ const groups = changesToMove({
10226
+ site,
10227
+ merged,
10228
+ pending: inputs.pending.map(change => ({
10229
+ patchId: change.patchId,
10230
+ moduleFilePath: change.moduleFilePath,
10231
+ patch: change.patch
10232
+ }))
10233
+ });
10234
+ const nameOf = new Map(inputs.pending.map(change => [change.patchId, change.authorName ?? "Someone"]));
10235
+ return {
10236
+ siteHead: inputs.siteHead,
10237
+ considered: inputs.pending.map(change => change.patchId),
10238
+ groups,
10239
+ authors: groups.map(group => [...new Set(group.map(id => nameOf.get(id) ?? "Someone"))])
10240
+ };
10241
+ }
10242
+
10072
10243
  const textEncoder$2 = new TextEncoder();
10073
10244
 
10074
10245
  /** The body of the content API's 403 for a write the project's plan refuses. */
@@ -10114,6 +10285,8 @@ const GetApplicablePatches = z.object({
10114
10285
  * Optional for the same reason.
10115
10286
  */
10116
10287
  headVersion: z.number().optional(),
10288
+ /** The branch's last commit. See `OrderedPatches.headCommitSha`. */
10289
+ headCommitSha: z.string().nullable().optional(),
10117
10290
  /** Of `patches`, the ones a running publish holds. See `OrderedPatches.publishingPatches`. */
10118
10291
  publishingPatches: z.array(PatchId$1).optional(),
10119
10292
  /** Of `patches`, the ones a failed publish holds back. See `OrderedPatches.heldBackPatches`. */
@@ -10706,6 +10879,19 @@ class ValOpsHttp extends ValOps {
10706
10879
  };
10707
10880
  }
10708
10881
 
10882
+ /**
10883
+ * adept, through content: lockfiles, upgrades and type bundles, for the
10884
+ * Studio's unlinked `/val/adept` page. A job is content's to scope to
10885
+ * this project; a name and a version are matched, not passed, so nothing
10886
+ * with a `..` reaches another of content's routes.
10887
+ *
10888
+ * Allowed at a proposal's address too: adept computes and publishes
10889
+ * nothing, so there is nothing a proposal's Studio could move on the site.
10890
+ */
10891
+ static adeptPathAllowed(path) {
10892
+ return /^\/adept\/(lockfile|upgrade|jobs\/\d{1,18}(\/result)?|compiler\/[0-9A-Za-z][0-9A-Za-z.+-]{0,63})$/.test(path) || /^\/adept\/types\/public\/(@[a-z0-9~-][a-z0-9._~-]{0,213}\/)?[a-z0-9~-][a-z0-9._~-]{0,213}@[0-9a-z][0-9a-z.+-]{0,63}$/i.test(path);
10893
+ }
10894
+
10709
10895
  /**
10710
10896
  * The content paths this may reach, and nothing else.
10711
10897
  *
@@ -10748,6 +10934,7 @@ class ValOpsHttp extends ValOps {
10748
10934
  if (/^\/publish-requests(\/[A-Za-z0-9_-]{1,100})?$/.test(path)) {
10749
10935
  return true;
10750
10936
  }
10937
+ if (ValOpsHttp.adeptPathAllowed(path)) return true;
10751
10938
  if (/^\/publish-jobs\/(next|[A-Za-z0-9_-]{1,100}\/(steps|renew|cancel|discard))$/.test(path)) {
10752
10939
  return true;
10753
10940
  }
@@ -10829,12 +11016,13 @@ class ValOpsHttp extends ValOps {
10829
11016
  return true;
10830
11017
  }
10831
11018
  if (/^\/publish\/[A-Za-z0-9_-]+\/artifacts$/.test(path)) return true;
11019
+ if (ValOpsHttp.adeptPathAllowed(path)) return true;
10832
11020
  // Where its press is -- read only: a POST there is the site's Try again.
10833
11021
  if (method === "GET" && path !== "/publish-requests/try-again" && /^\/publish-requests\/[A-Za-z0-9_-]{1,100}$/.test(path)) return true;
10834
11022
  return /^\/publish-jobs\/[A-Za-z0-9_-]{1,100}\/(merge-prepare|steps|renew|cancel)$/.test(path);
10835
11023
  }
10836
11024
  async proposalsApi(path, init, profileId) {
10837
- var _exec2, _exec3, _this$proposal;
11025
+ var _exec2, _exec3, _exec4, _exec5, _this$proposal;
10838
11026
  const json = "application/json";
10839
11027
  const updating = (_exec2 = /^\/([0-9a-f]{20})\/update-content$/.exec(path)) === null || _exec2 === void 0 ? void 0 : _exec2[1];
10840
11028
  if (updating !== undefined && init.method === "POST") {
@@ -10844,6 +11032,20 @@ class ValOpsHttp extends ValOps {
10844
11032
  if (withCode !== undefined && init.method === "POST") {
10845
11033
  return this.updateCodeFromSite(withCode, init.body, profileId);
10846
11034
  }
11035
+ /*
11036
+ * Publish, and the checks before it, at the proposal's own address: Val
11037
+ * says which of the site's pending changes the merge moves, from this
11038
+ * build's schemas (step 8.2). Anywhere else they go to content as they
11039
+ * are, and the site's pending changes block as they always did.
11040
+ */
11041
+ const checking = (_exec4 = /^\/([0-9a-f]{20})\/merge-checks$/.exec(path)) === null || _exec4 === void 0 ? void 0 : _exec4[1];
11042
+ if (checking !== undefined && init.method === "GET" && (await this.answersMovesFor(checking, profileId))) {
11043
+ return this.mergeChecksWithMoves(checking, profileId);
11044
+ }
11045
+ const pressing = (_exec5 = /^\/([0-9a-f]{20})\/merge$/.exec(path)) === null || _exec5 === void 0 ? void 0 : _exec5[1];
11046
+ if (pressing !== undefined && init.method === "POST" && (await this.answersMovesFor(pressing, profileId))) {
11047
+ return this.pressMergeWithMoves(pressing, init.body, profileId);
11048
+ }
10847
11049
  if (!ValOpsHttp.proposalsApiAllowed(init.method, path)) {
10848
11050
  return {
10849
11051
  status: 403,
@@ -11031,6 +11233,143 @@ class ValOpsHttp extends ValOps {
11031
11233
  return updatedAnswer(text);
11032
11234
  }
11033
11235
 
11236
+ /**
11237
+ * Whether this server says what a merge of `name` moves: at the proposal's
11238
+ * own address, whose schemas are the ones it publishes; and on the site, for
11239
+ * Publish selected's proposal, which changes no code -- so the site's
11240
+ * schemas are its own -- and has no address to be published from.
11241
+ */
11242
+ async answersMovesFor(name, profileId) {
11243
+ if (this.proposal !== null) return this.proposal.name === name;
11244
+ const read = await this.proposalContent(name, profileId)("");
11245
+ if (!read.ok) return false;
11246
+ const kind = ProposalKindAnswer.safeParse(await read.json().catch(() => null));
11247
+ return kind.success && kind.data.proposal.kind === "publish";
11248
+ }
11249
+
11250
+ /** Content's proposals API for one proposal, as the person asking. */
11251
+ proposalContent(name, profileId) {
11252
+ return (path, init = {}) => fetch(`${this.contentUrl}/v1/${this.project}/proposals/${name}${path}`, {
11253
+ ...init,
11254
+ headers: {
11255
+ ...this.authHeaders,
11256
+ ...(profileId !== null ? {
11257
+ "x-val-profile-id": profileId
11258
+ } : {}),
11259
+ ...(init.body === undefined ? {} : {
11260
+ "Content-Type": "application/json"
11261
+ })
11262
+ }
11263
+ });
11264
+ }
11265
+
11266
+ /**
11267
+ * Which of the site's pending changes a merge of this proposal moves
11268
+ * (`movesAtMerge`), or `null` when content could not say what is pending:
11269
+ * then nothing is claimed, and the site's pending changes block the merge.
11270
+ */
11271
+ async mergeMovesOf(name, profileId) {
11272
+ const read = await this.proposalContent(name, profileId)("/move-inputs");
11273
+ if (!read.ok) return null;
11274
+ const inputs = MoveInputs.safeParse(await read.json().catch(() => null));
11275
+ if (!inputs.success) return null;
11276
+ return {
11277
+ ...movesAtMerge(inputs.data, await this.getSchemas()),
11278
+ save: inputs.data.save
11279
+ };
11280
+ }
11281
+
11282
+ /**
11283
+ * The merge checks, with `pending-on-site` answered by what the merge
11284
+ * moves rather than by whether anything is pending: the conflicting
11285
+ * changes move to a proposal of their own when it is published, and the
11286
+ * rest stay, so neither blocks it.
11287
+ */
11288
+ async mergeChecksWithMoves(name, profileId) {
11289
+ const json = "application/json";
11290
+ const read = await this.proposalContent(name, profileId)("/merge-checks");
11291
+ const text = await read.text();
11292
+ const answer = {
11293
+ status: read.status,
11294
+ contentType: json,
11295
+ body: text
11296
+ };
11297
+ if (!read.ok) return answer;
11298
+ const parsed = MergeChecksAnswer.safeParse(parseJsonOrNull(text));
11299
+ if (!parsed.success) return answer;
11300
+ const pending = parsed.data.checks.find(check => check.id === "pending-on-site");
11301
+ if (pending === undefined || pending.ok) return answer;
11302
+ const moves = await this.mergeMovesOf(name, profileId);
11303
+ if (moves === null) return answer;
11304
+ const moving = moves.groups.flat().length;
11305
+ const names = [...new Set(moves.authors.flat())];
11306
+ const checks = parsed.data.checks.map(check => check.id !== "pending-on-site" ? check : {
11307
+ ...check,
11308
+ ok: true,
11309
+ blockers: [],
11310
+ message: moving === 0 ? "Unpublished changes on these pages do not conflict with this proposal, and stay unpublished" : `${moving === 1 ? "1 unpublished change" : `${moving} unpublished changes`} by ${namesOf(names)} ${moving === 1 ? "conflicts" : "conflict"} with this proposal, and ${moving === 1 ? "moves" : "move"} to ${moves.groups.length === 1 ? "a new proposal" : "new proposals"} when it is published`,
11311
+ moves: moves.groups.map((group, at) => ({
11312
+ patchIds: group,
11313
+ authors: moves.authors[at] ?? []
11314
+ }))
11315
+ });
11316
+ const othersOk = parsed.data.checks.every(check => check.ok || check.id === "pending-on-site");
11317
+ return {
11318
+ status: 200,
11319
+ contentType: json,
11320
+ body: JSON.stringify({
11321
+ ...parsed.data,
11322
+ checks,
11323
+ canMerge: parsed.data.canMerge || othersOk
11324
+ })
11325
+ };
11326
+ }
11327
+
11328
+ /** Publish in a proposal, carrying the moves (`MergeBody.moves` in content). */
11329
+ async pressMergeWithMoves(name, body, profileId) {
11330
+ const json = "application/json";
11331
+ const content = this.proposalContent(name, profileId);
11332
+ const forward = async sent => {
11333
+ const res = await content("/merge", {
11334
+ method: "POST",
11335
+ ...(sent === undefined ? {} : {
11336
+ body: sent
11337
+ })
11338
+ });
11339
+ return {
11340
+ status: res.status,
11341
+ contentType: res.headers.get("Content-Type") ?? json,
11342
+ body: await res.text()
11343
+ };
11344
+ };
11345
+ let pressed;
11346
+ try {
11347
+ pressed = body === undefined || body === "" ? {} : JSON.parse(body);
11348
+ } catch {
11349
+ return forward(body);
11350
+ }
11351
+ if (typeof pressed !== "object" || pressed === null) return forward(body);
11352
+ const moves = await this.mergeMovesOf(name, profileId);
11353
+ if (moves === null) return forward(body);
11354
+ return forward(JSON.stringify({
11355
+ /*
11356
+ * The save the moves were worked out against is the one this press
11357
+ * publishes: named, so a change written after the press -- which the
11358
+ * Studio lets an editor make at once -- continues in a new proposal
11359
+ * rather than refusing it as unsaved.
11360
+ */
11361
+ ...(typeof moves.save === "string" ? {
11362
+ save: moves.save
11363
+ } : {}),
11364
+ ...pressed,
11365
+ moves: {
11366
+ siteHead: moves.siteHead,
11367
+ considered: moves.considered,
11368
+ groups: moves.groups
11369
+ }
11370
+ }));
11371
+ }
11372
+
11034
11373
  /**
11035
11374
  * Update from the site, for a proposal with code (valbuild/home
11036
11375
  * `docs/hosted-publishing.md`, step 8.2, slice 1b), at
@@ -11808,6 +12147,9 @@ class ValOpsHttp extends ValOps {
11808
12147
  ...(data.headVersion !== undefined ? {
11809
12148
  headVersion: data.headVersion
11810
12149
  } : {}),
12150
+ ...(data.headCommitSha !== undefined ? {
12151
+ headCommitSha: data.headCommitSha
12152
+ } : {}),
11811
12153
  ...(data.publishingPatches !== undefined ? {
11812
12154
  publishingPatches: data.publishingPatches
11813
12155
  } : {}),
@@ -12252,15 +12594,22 @@ class ValOpsHttp extends ValOps {
12252
12594
  };
12253
12595
  });
12254
12596
  }
12255
- async commit(prepared, message, committer, filesDirectory, newBranch) {
12597
+ async commit(prepared, message, committer, filesDirectory, newBranch,
12598
+ /**
12599
+ * A save into this proposal rather than this server's own branch: the
12600
+ * site's server saving Publish selected's proposal (`/publish-selected`),
12601
+ * which has no address of its own to save from.
12602
+ */
12603
+ intoProposal) {
12256
12604
  try {
12257
- var _res$headers$get3;
12605
+ var _this$proposal5, _res$headers$get3;
12258
12606
  /*
12259
12607
  * A save in a proposal is a commit on the proposal's branch, at its own
12260
12608
  * route: the site's `/commit` refuses a proposal's patches, which is
12261
12609
  * the line that keeps them off the site until a merge.
12262
12610
  */
12263
- const commitUrl = this.proposal !== null ? `${this.contentUrl}/v1/${this.project}/proposals/${this.proposal.name}/save` : `${this.contentUrl}/v1/${this.project}/commit`;
12611
+ const proposalName = intoProposal ?? ((_this$proposal5 = this.proposal) === null || _this$proposal5 === void 0 ? void 0 : _this$proposal5.name);
12612
+ const commitUrl = proposalName !== undefined ? `${this.contentUrl}/v1/${this.project}/proposals/${proposalName}/save` : `${this.contentUrl}/v1/${this.project}/commit`;
12264
12613
  const res = await fetch(commitUrl, {
12265
12614
  method: "POST",
12266
12615
  headers: {
@@ -12668,6 +13017,27 @@ const UpdateCodeBody = z.object({
12668
13017
  }).optional()
12669
13018
  });
12670
13019
 
13020
+ /** Content's `GET /proposals/:name/move-inputs`. */
13021
+ /** The one thing `answersMovesFor` reads from content's `GET /proposals/:name`. */
13022
+ const ProposalKindAnswer = z.object({
13023
+ proposal: z.object({
13024
+ kind: z.string()
13025
+ })
13026
+ });
13027
+ /** Content's `GET /proposals/:name/merge-checks`, as far as it is changed here. */
13028
+ const MergeChecksAnswer = z.object({
13029
+ checks: z.array(z.object({
13030
+ id: z.string(),
13031
+ ok: z.boolean()
13032
+ }).passthrough()),
13033
+ canMerge: z.boolean()
13034
+ }).passthrough();
13035
+
13036
+ /** "Kari", "Kari and Bo", "Kari, Bo and 2 more". */
13037
+ function namesOf(names) {
13038
+ return names.length <= 2 ? names.join(" and ") : `${names.slice(0, 2).join(", ")} and ${names.length - 2} more`;
13039
+ }
13040
+
12671
13041
  /** Content's `GET /proposals/:name/merge-trees`. */
12672
13042
  const MergeTrees = z.object({
12673
13043
  base: z.string().nullable(),
@@ -12711,6 +13081,15 @@ const MergeInputs = z.object({
12711
13081
  }))
12712
13082
  });
12713
13083
 
13084
+ /** `JSON.parse`, or `null` for a body that is not JSON: an answer passed on as it came. */
13085
+ function parseJsonOrNull(text) {
13086
+ try {
13087
+ return JSON.parse(text);
13088
+ } catch {
13089
+ return null;
13090
+ }
13091
+ }
13092
+
12714
13093
  /**
12715
13094
  * One stored patch. Everything the ordered chain needs and nothing else.
12716
13095
  */
@@ -16659,6 +17038,142 @@ const ValServer = (valModules, options, callbacks) => {
16659
17038
  });
16660
17039
  }
16661
17040
  },
17041
+ "/publish-selected": {
17042
+ POST: async req => {
17043
+ const auth = getAuth(req.cookies);
17044
+ if (auth.error) {
17045
+ return {
17046
+ status: 401,
17047
+ json: {
17048
+ message: auth.error
17049
+ }
17050
+ };
17051
+ }
17052
+ const profileId = profileOf(auth);
17053
+ if (profileId === null) {
17054
+ return {
17055
+ status: 401,
17056
+ json: {
17057
+ message: "Sign in to publish"
17058
+ }
17059
+ };
17060
+ }
17061
+ if (serverOps instanceof ValOpsHttp) {
17062
+ await serverOps.learnProjectExpectation();
17063
+ }
17064
+ if (!(serverOps instanceof ValOpsHttp) || serverOps.sourceMode() !== "managed" || !serverOps.publishesAsJobs() || serverOps.currentProposal() !== null) {
17065
+ return {
17066
+ status: 409,
17067
+ json: {
17068
+ message: "Publish selected is for a site whose publishes are proposals: this deployment cannot."
17069
+ }
17070
+ };
17071
+ }
17072
+ const refusal = serverOps.publishRefusal();
17073
+ if (refusal) {
17074
+ return {
17075
+ status: 409,
17076
+ json: {
17077
+ message: refusal.message
17078
+ }
17079
+ };
17080
+ }
17081
+ const {
17082
+ patchIds,
17083
+ displayName,
17084
+ requestId
17085
+ } = req.body;
17086
+ /*
17087
+ * The selection's content over the site's head, exactly as a publish
17088
+ * job of it is prepared: this build's source, the commits since it,
17089
+ * and the selection on top. Only what the selection touched is saved,
17090
+ * so the proposal's save is the selection and nothing else.
17091
+ */
17092
+ const built = await buildSourcesOf(serverOps, patchIds, commit, {
17093
+ binaries: false
17094
+ });
17095
+ if ("status" in built) return built;
17096
+ if (built.headCommitSha === undefined) {
17097
+ return {
17098
+ status: 409,
17099
+ json: {
17100
+ message: "The content service does not say where the site's head is, which Publish selected needs: it is older than this Studio."
17101
+ }
17102
+ };
17103
+ }
17104
+ const selected = new Set(patchIds);
17105
+ const prepared = {
17106
+ ...built.withJob,
17107
+ patchedSourceFiles: built.split.archive.patchedSourceFiles,
17108
+ patchedBinaryFilesDescriptors: built.split.archive.patchedBinaryFilesDescriptors,
17109
+ moduleVersions: built.split.archive.modules,
17110
+ appliedPatches: Object.fromEntries(Object.entries(built.withJob.appliedPatches).map(([path, ids]) => [path, ids.filter(id => selected.has(id))]).filter(([, ids]) => ids.length > 0))
17111
+ };
17112
+ /*
17113
+ * Moved and based in one transaction, on the head the selection was
17114
+ * prepared over: content refuses the create when the site is no
17115
+ * longer there, and nothing has moved.
17116
+ */
17117
+ const created = await serverOps.proposalsApi("", {
17118
+ method: "POST",
17119
+ body: JSON.stringify({
17120
+ displayName,
17121
+ kind: "publish",
17122
+ patchIds,
17123
+ requestId,
17124
+ base: built.headCommitSha
17125
+ })
17126
+ }, profileId);
17127
+ const createdBody = PublishProposalCreated.safeParse(safeJson(created.body));
17128
+ const createdError = ContentError.safeParse(safeJson(created.body));
17129
+ if (created.status === 409) {
17130
+ /*
17131
+ * Content refuses a base that is not the site's head -- a publish
17132
+ * landed in between, and pressing again prepares over the new one.
17133
+ * Any other refusal is content's to put in words, and again would
17134
+ * be refused the same way.
17135
+ */
17136
+ const message = createdError.success ? createdError.data.message : "";
17137
+ return /base is not the site's head/.test(message) ? {
17138
+ status: 409,
17139
+ json: {
17140
+ message: "The site was published while this was being prepared. Nothing was published: publish them again.",
17141
+ headMoved: true
17142
+ }
17143
+ } : {
17144
+ status: 409,
17145
+ json: {
17146
+ message: message || "The changes could not be moved into a proposal to publish them."
17147
+ }
17148
+ };
17149
+ }
17150
+ if (created.status !== 200 || !createdBody.success) {
17151
+ return {
17152
+ status: 500,
17153
+ json: {
17154
+ message: `The changes could not be moved into a proposal to publish them (${created.status}): ${created.body.slice(0, 300)}`
17155
+ }
17156
+ };
17157
+ }
17158
+ const proposal = createdBody.data.proposal.name;
17159
+ const saved = await serverOps.commit(prepared, `Publish selected: ${displayName}`, profileId, FILES_DIRECTORY, undefined, proposal);
17160
+ if (saved.error) {
17161
+ return {
17162
+ status: 502,
17163
+ json: {
17164
+ message: `The changes are in a proposal, but could not be saved there: ${saved.error.message}`,
17165
+ proposal
17166
+ }
17167
+ };
17168
+ }
17169
+ return {
17170
+ status: 200,
17171
+ json: {
17172
+ proposal
17173
+ }
17174
+ };
17175
+ }
17176
+ },
16662
17177
  "/admin/status": {
16663
17178
  GET: async req => {
16664
17179
  const credential = await getValBuildCredential(req.cookies);
@@ -18554,7 +19069,14 @@ function getIsRemoteRequired(schemas) {
18554
19069
  * `split.archive` is the managed job's archive; a connected job's archive is
18555
19070
  * the push's, made from its own changes alone, by the caller.
18556
19071
  */
18557
- async function buildSourcesOf(serverOps, patchIds, commit) {
19072
+ async function buildSourcesOf(serverOps, patchIds, commit,
19073
+ /**
19074
+ * Read the job's binary files back for a build. Off for a caller that only
19075
+ * commits what was prepared, which names the files rather than carrying them.
19076
+ */
19077
+ options = {
19078
+ binaries: true
19079
+ }) {
18558
19080
  const chain = await serverOps.fetchPatches({
18559
19081
  patchIds: undefined,
18560
19082
  excludePatchOps: false
@@ -18613,9 +19135,14 @@ async function buildSourcesOf(serverOps, patchIds, commit) {
18613
19135
  jobPatchIds: patchIds
18614
19136
  });
18615
19137
  // Read now, while the job's changes still hold their files.
18616
- const binaries = await readCommittedBinaryFiles(serverOps, split.archive.patchedBinaryFilesDescriptors);
19138
+ const binaries = options.binaries ? await readCommittedBinaryFiles(serverOps, split.archive.patchedBinaryFilesDescriptors) : {
19139
+ files: {},
19140
+ unread: []
19141
+ };
18617
19142
  return {
18618
19143
  split,
19144
+ withJob,
19145
+ headCommitSha: chain.headCommitSha,
18619
19146
  sourceFiles: split.buildSourceFiles,
18620
19147
  binaryFiles: binaries.files,
18621
19148
  binaryFilesUnread: binaries.unread
@@ -18665,6 +19192,24 @@ function jobPrepareAnswer(sent, build) {
18665
19192
  }
18666
19193
  }
18667
19194
 
19195
+ /** What `/publish-selected` reads from content's proposal create. */
19196
+ const PublishProposalCreated = z.object({
19197
+ proposal: z.object({
19198
+ name: z.string()
19199
+ })
19200
+ });
19201
+ /** Content's refusal, in its words. */
19202
+ const ContentError = z.object({
19203
+ message: z.string()
19204
+ });
19205
+ function safeJson(text) {
19206
+ try {
19207
+ return JSON.parse(text);
19208
+ } catch {
19209
+ return null;
19210
+ }
19211
+ }
19212
+
18668
19213
  async function createValServer(valModules, route, opts, config, callbacks, formatter,
18669
19214
  /**
18670
19215
  * Called after a save has applied its patches. EXPERIMENTAL — see
@@ -23540,7 +24085,8 @@ async function handleExternalUpload() {
23540
24085
  };
23541
24086
  }
23542
24087
 
23543
- // Fix handler registry. `keyof:check-keys`, `router:check-route`,
24088
+ // Fix handler registry. `keyof:check-keys`, the gallery markers
24089
+ // (`image:check-gallery` and the rest), `router:check-route`,
23544
24090
  // `locale:check-locale` and `record:fill-keys` are resolved upfront by the
23545
24091
  // shared resolveSchemaSourceFixes — they never reach this registry, so they're
23546
24092
  // excluded from the key set.