@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.
- package/CHANGELOG.md +54 -0
- package/dist/declarations/src/ValOps.d.ts +11 -0
- package/dist/declarations/src/ValOpsHttp.d.ts +41 -1
- package/dist/declarations/src/fixHandlers.d.ts +1 -1
- package/dist/valbuild-server.cjs.dev.js +553 -7
- package/dist/valbuild-server.cjs.prod.js +553 -7
- package/dist/valbuild-server.esm.js +554 -8
- package/package.json +4 -4
|
@@ -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,
|
|
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
|
|
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`,
|
|
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.
|