@valbuild/server 0.146.1 → 0.146.2
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 +34 -0
- package/dist/declarations/src/ValOps.d.ts +11 -0
- package/dist/declarations/src/ValOpsHttp.d.ts +31 -1
- package/dist/declarations/src/fixHandlers.d.ts +1 -1
- package/dist/valbuild-server.cjs.dev.js +538 -7
- package/dist/valbuild-server.cjs.prod.js +538 -7
- package/dist/valbuild-server.esm.js +539 -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`. */
|
|
@@ -10834,7 +11007,7 @@ class ValOpsHttp extends ValOps {
|
|
|
10834
11007
|
return /^\/publish-jobs\/[A-Za-z0-9_-]{1,100}\/(merge-prepare|steps|renew|cancel)$/.test(path);
|
|
10835
11008
|
}
|
|
10836
11009
|
async proposalsApi(path, init, profileId) {
|
|
10837
|
-
var _exec2, _exec3, _this$proposal;
|
|
11010
|
+
var _exec2, _exec3, _exec4, _exec5, _this$proposal;
|
|
10838
11011
|
const json = "application/json";
|
|
10839
11012
|
const updating = (_exec2 = /^\/([0-9a-f]{20})\/update-content$/.exec(path)) === null || _exec2 === void 0 ? void 0 : _exec2[1];
|
|
10840
11013
|
if (updating !== undefined && init.method === "POST") {
|
|
@@ -10844,6 +11017,20 @@ class ValOpsHttp extends ValOps {
|
|
|
10844
11017
|
if (withCode !== undefined && init.method === "POST") {
|
|
10845
11018
|
return this.updateCodeFromSite(withCode, init.body, profileId);
|
|
10846
11019
|
}
|
|
11020
|
+
/*
|
|
11021
|
+
* Publish, and the checks before it, at the proposal's own address: Val
|
|
11022
|
+
* says which of the site's pending changes the merge moves, from this
|
|
11023
|
+
* build's schemas (step 8.2). Anywhere else they go to content as they
|
|
11024
|
+
* are, and the site's pending changes block as they always did.
|
|
11025
|
+
*/
|
|
11026
|
+
const checking = (_exec4 = /^\/([0-9a-f]{20})\/merge-checks$/.exec(path)) === null || _exec4 === void 0 ? void 0 : _exec4[1];
|
|
11027
|
+
if (checking !== undefined && init.method === "GET" && (await this.answersMovesFor(checking, profileId))) {
|
|
11028
|
+
return this.mergeChecksWithMoves(checking, profileId);
|
|
11029
|
+
}
|
|
11030
|
+
const pressing = (_exec5 = /^\/([0-9a-f]{20})\/merge$/.exec(path)) === null || _exec5 === void 0 ? void 0 : _exec5[1];
|
|
11031
|
+
if (pressing !== undefined && init.method === "POST" && (await this.answersMovesFor(pressing, profileId))) {
|
|
11032
|
+
return this.pressMergeWithMoves(pressing, init.body, profileId);
|
|
11033
|
+
}
|
|
10847
11034
|
if (!ValOpsHttp.proposalsApiAllowed(init.method, path)) {
|
|
10848
11035
|
return {
|
|
10849
11036
|
status: 403,
|
|
@@ -11031,6 +11218,143 @@ class ValOpsHttp extends ValOps {
|
|
|
11031
11218
|
return updatedAnswer(text);
|
|
11032
11219
|
}
|
|
11033
11220
|
|
|
11221
|
+
/**
|
|
11222
|
+
* Whether this server says what a merge of `name` moves: at the proposal's
|
|
11223
|
+
* own address, whose schemas are the ones it publishes; and on the site, for
|
|
11224
|
+
* Publish selected's proposal, which changes no code -- so the site's
|
|
11225
|
+
* schemas are its own -- and has no address to be published from.
|
|
11226
|
+
*/
|
|
11227
|
+
async answersMovesFor(name, profileId) {
|
|
11228
|
+
if (this.proposal !== null) return this.proposal.name === name;
|
|
11229
|
+
const read = await this.proposalContent(name, profileId)("");
|
|
11230
|
+
if (!read.ok) return false;
|
|
11231
|
+
const kind = ProposalKindAnswer.safeParse(await read.json().catch(() => null));
|
|
11232
|
+
return kind.success && kind.data.proposal.kind === "publish";
|
|
11233
|
+
}
|
|
11234
|
+
|
|
11235
|
+
/** Content's proposals API for one proposal, as the person asking. */
|
|
11236
|
+
proposalContent(name, profileId) {
|
|
11237
|
+
return (path, init = {}) => fetch(`${this.contentUrl}/v1/${this.project}/proposals/${name}${path}`, {
|
|
11238
|
+
...init,
|
|
11239
|
+
headers: {
|
|
11240
|
+
...this.authHeaders,
|
|
11241
|
+
...(profileId !== null ? {
|
|
11242
|
+
"x-val-profile-id": profileId
|
|
11243
|
+
} : {}),
|
|
11244
|
+
...(init.body === undefined ? {} : {
|
|
11245
|
+
"Content-Type": "application/json"
|
|
11246
|
+
})
|
|
11247
|
+
}
|
|
11248
|
+
});
|
|
11249
|
+
}
|
|
11250
|
+
|
|
11251
|
+
/**
|
|
11252
|
+
* Which of the site's pending changes a merge of this proposal moves
|
|
11253
|
+
* (`movesAtMerge`), or `null` when content could not say what is pending:
|
|
11254
|
+
* then nothing is claimed, and the site's pending changes block the merge.
|
|
11255
|
+
*/
|
|
11256
|
+
async mergeMovesOf(name, profileId) {
|
|
11257
|
+
const read = await this.proposalContent(name, profileId)("/move-inputs");
|
|
11258
|
+
if (!read.ok) return null;
|
|
11259
|
+
const inputs = MoveInputs.safeParse(await read.json().catch(() => null));
|
|
11260
|
+
if (!inputs.success) return null;
|
|
11261
|
+
return {
|
|
11262
|
+
...movesAtMerge(inputs.data, await this.getSchemas()),
|
|
11263
|
+
save: inputs.data.save
|
|
11264
|
+
};
|
|
11265
|
+
}
|
|
11266
|
+
|
|
11267
|
+
/**
|
|
11268
|
+
* The merge checks, with `pending-on-site` answered by what the merge
|
|
11269
|
+
* moves rather than by whether anything is pending: the conflicting
|
|
11270
|
+
* changes move to a proposal of their own when it is published, and the
|
|
11271
|
+
* rest stay, so neither blocks it.
|
|
11272
|
+
*/
|
|
11273
|
+
async mergeChecksWithMoves(name, profileId) {
|
|
11274
|
+
const json = "application/json";
|
|
11275
|
+
const read = await this.proposalContent(name, profileId)("/merge-checks");
|
|
11276
|
+
const text = await read.text();
|
|
11277
|
+
const answer = {
|
|
11278
|
+
status: read.status,
|
|
11279
|
+
contentType: json,
|
|
11280
|
+
body: text
|
|
11281
|
+
};
|
|
11282
|
+
if (!read.ok) return answer;
|
|
11283
|
+
const parsed = MergeChecksAnswer.safeParse(parseJsonOrNull(text));
|
|
11284
|
+
if (!parsed.success) return answer;
|
|
11285
|
+
const pending = parsed.data.checks.find(check => check.id === "pending-on-site");
|
|
11286
|
+
if (pending === undefined || pending.ok) return answer;
|
|
11287
|
+
const moves = await this.mergeMovesOf(name, profileId);
|
|
11288
|
+
if (moves === null) return answer;
|
|
11289
|
+
const moving = moves.groups.flat().length;
|
|
11290
|
+
const names = [...new Set(moves.authors.flat())];
|
|
11291
|
+
const checks = parsed.data.checks.map(check => check.id !== "pending-on-site" ? check : {
|
|
11292
|
+
...check,
|
|
11293
|
+
ok: true,
|
|
11294
|
+
blockers: [],
|
|
11295
|
+
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`,
|
|
11296
|
+
moves: moves.groups.map((group, at) => ({
|
|
11297
|
+
patchIds: group,
|
|
11298
|
+
authors: moves.authors[at] ?? []
|
|
11299
|
+
}))
|
|
11300
|
+
});
|
|
11301
|
+
const othersOk = parsed.data.checks.every(check => check.ok || check.id === "pending-on-site");
|
|
11302
|
+
return {
|
|
11303
|
+
status: 200,
|
|
11304
|
+
contentType: json,
|
|
11305
|
+
body: JSON.stringify({
|
|
11306
|
+
...parsed.data,
|
|
11307
|
+
checks,
|
|
11308
|
+
canMerge: parsed.data.canMerge || othersOk
|
|
11309
|
+
})
|
|
11310
|
+
};
|
|
11311
|
+
}
|
|
11312
|
+
|
|
11313
|
+
/** Publish in a proposal, carrying the moves (`MergeBody.moves` in content). */
|
|
11314
|
+
async pressMergeWithMoves(name, body, profileId) {
|
|
11315
|
+
const json = "application/json";
|
|
11316
|
+
const content = this.proposalContent(name, profileId);
|
|
11317
|
+
const forward = async sent => {
|
|
11318
|
+
const res = await content("/merge", {
|
|
11319
|
+
method: "POST",
|
|
11320
|
+
...(sent === undefined ? {} : {
|
|
11321
|
+
body: sent
|
|
11322
|
+
})
|
|
11323
|
+
});
|
|
11324
|
+
return {
|
|
11325
|
+
status: res.status,
|
|
11326
|
+
contentType: res.headers.get("Content-Type") ?? json,
|
|
11327
|
+
body: await res.text()
|
|
11328
|
+
};
|
|
11329
|
+
};
|
|
11330
|
+
let pressed;
|
|
11331
|
+
try {
|
|
11332
|
+
pressed = body === undefined || body === "" ? {} : JSON.parse(body);
|
|
11333
|
+
} catch {
|
|
11334
|
+
return forward(body);
|
|
11335
|
+
}
|
|
11336
|
+
if (typeof pressed !== "object" || pressed === null) return forward(body);
|
|
11337
|
+
const moves = await this.mergeMovesOf(name, profileId);
|
|
11338
|
+
if (moves === null) return forward(body);
|
|
11339
|
+
return forward(JSON.stringify({
|
|
11340
|
+
/*
|
|
11341
|
+
* The save the moves were worked out against is the one this press
|
|
11342
|
+
* publishes: named, so a change written after the press -- which the
|
|
11343
|
+
* Studio lets an editor make at once -- continues in a new proposal
|
|
11344
|
+
* rather than refusing it as unsaved.
|
|
11345
|
+
*/
|
|
11346
|
+
...(typeof moves.save === "string" ? {
|
|
11347
|
+
save: moves.save
|
|
11348
|
+
} : {}),
|
|
11349
|
+
...pressed,
|
|
11350
|
+
moves: {
|
|
11351
|
+
siteHead: moves.siteHead,
|
|
11352
|
+
considered: moves.considered,
|
|
11353
|
+
groups: moves.groups
|
|
11354
|
+
}
|
|
11355
|
+
}));
|
|
11356
|
+
}
|
|
11357
|
+
|
|
11034
11358
|
/**
|
|
11035
11359
|
* Update from the site, for a proposal with code (valbuild/home
|
|
11036
11360
|
* `docs/hosted-publishing.md`, step 8.2, slice 1b), at
|
|
@@ -11808,6 +12132,9 @@ class ValOpsHttp extends ValOps {
|
|
|
11808
12132
|
...(data.headVersion !== undefined ? {
|
|
11809
12133
|
headVersion: data.headVersion
|
|
11810
12134
|
} : {}),
|
|
12135
|
+
...(data.headCommitSha !== undefined ? {
|
|
12136
|
+
headCommitSha: data.headCommitSha
|
|
12137
|
+
} : {}),
|
|
11811
12138
|
...(data.publishingPatches !== undefined ? {
|
|
11812
12139
|
publishingPatches: data.publishingPatches
|
|
11813
12140
|
} : {}),
|
|
@@ -12252,15 +12579,22 @@ class ValOpsHttp extends ValOps {
|
|
|
12252
12579
|
};
|
|
12253
12580
|
});
|
|
12254
12581
|
}
|
|
12255
|
-
async commit(prepared, message, committer, filesDirectory, newBranch
|
|
12582
|
+
async commit(prepared, message, committer, filesDirectory, newBranch,
|
|
12583
|
+
/**
|
|
12584
|
+
* A save into this proposal rather than this server's own branch: the
|
|
12585
|
+
* site's server saving Publish selected's proposal (`/publish-selected`),
|
|
12586
|
+
* which has no address of its own to save from.
|
|
12587
|
+
*/
|
|
12588
|
+
intoProposal) {
|
|
12256
12589
|
try {
|
|
12257
|
-
var _res$headers$get3;
|
|
12590
|
+
var _this$proposal5, _res$headers$get3;
|
|
12258
12591
|
/*
|
|
12259
12592
|
* A save in a proposal is a commit on the proposal's branch, at its own
|
|
12260
12593
|
* route: the site's `/commit` refuses a proposal's patches, which is
|
|
12261
12594
|
* the line that keeps them off the site until a merge.
|
|
12262
12595
|
*/
|
|
12263
|
-
const
|
|
12596
|
+
const proposalName = intoProposal ?? ((_this$proposal5 = this.proposal) === null || _this$proposal5 === void 0 ? void 0 : _this$proposal5.name);
|
|
12597
|
+
const commitUrl = proposalName !== undefined ? `${this.contentUrl}/v1/${this.project}/proposals/${proposalName}/save` : `${this.contentUrl}/v1/${this.project}/commit`;
|
|
12264
12598
|
const res = await fetch(commitUrl, {
|
|
12265
12599
|
method: "POST",
|
|
12266
12600
|
headers: {
|
|
@@ -12668,6 +13002,27 @@ const UpdateCodeBody = z.object({
|
|
|
12668
13002
|
}).optional()
|
|
12669
13003
|
});
|
|
12670
13004
|
|
|
13005
|
+
/** Content's `GET /proposals/:name/move-inputs`. */
|
|
13006
|
+
/** The one thing `answersMovesFor` reads from content's `GET /proposals/:name`. */
|
|
13007
|
+
const ProposalKindAnswer = z.object({
|
|
13008
|
+
proposal: z.object({
|
|
13009
|
+
kind: z.string()
|
|
13010
|
+
})
|
|
13011
|
+
});
|
|
13012
|
+
/** Content's `GET /proposals/:name/merge-checks`, as far as it is changed here. */
|
|
13013
|
+
const MergeChecksAnswer = z.object({
|
|
13014
|
+
checks: z.array(z.object({
|
|
13015
|
+
id: z.string(),
|
|
13016
|
+
ok: z.boolean()
|
|
13017
|
+
}).passthrough()),
|
|
13018
|
+
canMerge: z.boolean()
|
|
13019
|
+
}).passthrough();
|
|
13020
|
+
|
|
13021
|
+
/** "Kari", "Kari and Bo", "Kari, Bo and 2 more". */
|
|
13022
|
+
function namesOf(names) {
|
|
13023
|
+
return names.length <= 2 ? names.join(" and ") : `${names.slice(0, 2).join(", ")} and ${names.length - 2} more`;
|
|
13024
|
+
}
|
|
13025
|
+
|
|
12671
13026
|
/** Content's `GET /proposals/:name/merge-trees`. */
|
|
12672
13027
|
const MergeTrees = z.object({
|
|
12673
13028
|
base: z.string().nullable(),
|
|
@@ -12711,6 +13066,15 @@ const MergeInputs = z.object({
|
|
|
12711
13066
|
}))
|
|
12712
13067
|
});
|
|
12713
13068
|
|
|
13069
|
+
/** `JSON.parse`, or `null` for a body that is not JSON: an answer passed on as it came. */
|
|
13070
|
+
function parseJsonOrNull(text) {
|
|
13071
|
+
try {
|
|
13072
|
+
return JSON.parse(text);
|
|
13073
|
+
} catch {
|
|
13074
|
+
return null;
|
|
13075
|
+
}
|
|
13076
|
+
}
|
|
13077
|
+
|
|
12714
13078
|
/**
|
|
12715
13079
|
* One stored patch. Everything the ordered chain needs and nothing else.
|
|
12716
13080
|
*/
|
|
@@ -16659,6 +17023,142 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
16659
17023
|
});
|
|
16660
17024
|
}
|
|
16661
17025
|
},
|
|
17026
|
+
"/publish-selected": {
|
|
17027
|
+
POST: async req => {
|
|
17028
|
+
const auth = getAuth(req.cookies);
|
|
17029
|
+
if (auth.error) {
|
|
17030
|
+
return {
|
|
17031
|
+
status: 401,
|
|
17032
|
+
json: {
|
|
17033
|
+
message: auth.error
|
|
17034
|
+
}
|
|
17035
|
+
};
|
|
17036
|
+
}
|
|
17037
|
+
const profileId = profileOf(auth);
|
|
17038
|
+
if (profileId === null) {
|
|
17039
|
+
return {
|
|
17040
|
+
status: 401,
|
|
17041
|
+
json: {
|
|
17042
|
+
message: "Sign in to publish"
|
|
17043
|
+
}
|
|
17044
|
+
};
|
|
17045
|
+
}
|
|
17046
|
+
if (serverOps instanceof ValOpsHttp) {
|
|
17047
|
+
await serverOps.learnProjectExpectation();
|
|
17048
|
+
}
|
|
17049
|
+
if (!(serverOps instanceof ValOpsHttp) || serverOps.sourceMode() !== "managed" || !serverOps.publishesAsJobs() || serverOps.currentProposal() !== null) {
|
|
17050
|
+
return {
|
|
17051
|
+
status: 409,
|
|
17052
|
+
json: {
|
|
17053
|
+
message: "Publish selected is for a site whose publishes are proposals: this deployment cannot."
|
|
17054
|
+
}
|
|
17055
|
+
};
|
|
17056
|
+
}
|
|
17057
|
+
const refusal = serverOps.publishRefusal();
|
|
17058
|
+
if (refusal) {
|
|
17059
|
+
return {
|
|
17060
|
+
status: 409,
|
|
17061
|
+
json: {
|
|
17062
|
+
message: refusal.message
|
|
17063
|
+
}
|
|
17064
|
+
};
|
|
17065
|
+
}
|
|
17066
|
+
const {
|
|
17067
|
+
patchIds,
|
|
17068
|
+
displayName,
|
|
17069
|
+
requestId
|
|
17070
|
+
} = req.body;
|
|
17071
|
+
/*
|
|
17072
|
+
* The selection's content over the site's head, exactly as a publish
|
|
17073
|
+
* job of it is prepared: this build's source, the commits since it,
|
|
17074
|
+
* and the selection on top. Only what the selection touched is saved,
|
|
17075
|
+
* so the proposal's save is the selection and nothing else.
|
|
17076
|
+
*/
|
|
17077
|
+
const built = await buildSourcesOf(serverOps, patchIds, commit, {
|
|
17078
|
+
binaries: false
|
|
17079
|
+
});
|
|
17080
|
+
if ("status" in built) return built;
|
|
17081
|
+
if (built.headCommitSha === undefined) {
|
|
17082
|
+
return {
|
|
17083
|
+
status: 409,
|
|
17084
|
+
json: {
|
|
17085
|
+
message: "The content service does not say where the site's head is, which Publish selected needs: it is older than this Studio."
|
|
17086
|
+
}
|
|
17087
|
+
};
|
|
17088
|
+
}
|
|
17089
|
+
const selected = new Set(patchIds);
|
|
17090
|
+
const prepared = {
|
|
17091
|
+
...built.withJob,
|
|
17092
|
+
patchedSourceFiles: built.split.archive.patchedSourceFiles,
|
|
17093
|
+
patchedBinaryFilesDescriptors: built.split.archive.patchedBinaryFilesDescriptors,
|
|
17094
|
+
moduleVersions: built.split.archive.modules,
|
|
17095
|
+
appliedPatches: Object.fromEntries(Object.entries(built.withJob.appliedPatches).map(([path, ids]) => [path, ids.filter(id => selected.has(id))]).filter(([, ids]) => ids.length > 0))
|
|
17096
|
+
};
|
|
17097
|
+
/*
|
|
17098
|
+
* Moved and based in one transaction, on the head the selection was
|
|
17099
|
+
* prepared over: content refuses the create when the site is no
|
|
17100
|
+
* longer there, and nothing has moved.
|
|
17101
|
+
*/
|
|
17102
|
+
const created = await serverOps.proposalsApi("", {
|
|
17103
|
+
method: "POST",
|
|
17104
|
+
body: JSON.stringify({
|
|
17105
|
+
displayName,
|
|
17106
|
+
kind: "publish",
|
|
17107
|
+
patchIds,
|
|
17108
|
+
requestId,
|
|
17109
|
+
base: built.headCommitSha
|
|
17110
|
+
})
|
|
17111
|
+
}, profileId);
|
|
17112
|
+
const createdBody = PublishProposalCreated.safeParse(safeJson(created.body));
|
|
17113
|
+
const createdError = ContentError.safeParse(safeJson(created.body));
|
|
17114
|
+
if (created.status === 409) {
|
|
17115
|
+
/*
|
|
17116
|
+
* Content refuses a base that is not the site's head -- a publish
|
|
17117
|
+
* landed in between, and pressing again prepares over the new one.
|
|
17118
|
+
* Any other refusal is content's to put in words, and again would
|
|
17119
|
+
* be refused the same way.
|
|
17120
|
+
*/
|
|
17121
|
+
const message = createdError.success ? createdError.data.message : "";
|
|
17122
|
+
return /base is not the site's head/.test(message) ? {
|
|
17123
|
+
status: 409,
|
|
17124
|
+
json: {
|
|
17125
|
+
message: "The site was published while this was being prepared. Nothing was published: publish them again.",
|
|
17126
|
+
headMoved: true
|
|
17127
|
+
}
|
|
17128
|
+
} : {
|
|
17129
|
+
status: 409,
|
|
17130
|
+
json: {
|
|
17131
|
+
message: message || "The changes could not be moved into a proposal to publish them."
|
|
17132
|
+
}
|
|
17133
|
+
};
|
|
17134
|
+
}
|
|
17135
|
+
if (created.status !== 200 || !createdBody.success) {
|
|
17136
|
+
return {
|
|
17137
|
+
status: 500,
|
|
17138
|
+
json: {
|
|
17139
|
+
message: `The changes could not be moved into a proposal to publish them (${created.status}): ${created.body.slice(0, 300)}`
|
|
17140
|
+
}
|
|
17141
|
+
};
|
|
17142
|
+
}
|
|
17143
|
+
const proposal = createdBody.data.proposal.name;
|
|
17144
|
+
const saved = await serverOps.commit(prepared, `Publish selected: ${displayName}`, profileId, FILES_DIRECTORY, undefined, proposal);
|
|
17145
|
+
if (saved.error) {
|
|
17146
|
+
return {
|
|
17147
|
+
status: 502,
|
|
17148
|
+
json: {
|
|
17149
|
+
message: `The changes are in a proposal, but could not be saved there: ${saved.error.message}`,
|
|
17150
|
+
proposal
|
|
17151
|
+
}
|
|
17152
|
+
};
|
|
17153
|
+
}
|
|
17154
|
+
return {
|
|
17155
|
+
status: 200,
|
|
17156
|
+
json: {
|
|
17157
|
+
proposal
|
|
17158
|
+
}
|
|
17159
|
+
};
|
|
17160
|
+
}
|
|
17161
|
+
},
|
|
16662
17162
|
"/admin/status": {
|
|
16663
17163
|
GET: async req => {
|
|
16664
17164
|
const credential = await getValBuildCredential(req.cookies);
|
|
@@ -18554,7 +19054,14 @@ function getIsRemoteRequired(schemas) {
|
|
|
18554
19054
|
* `split.archive` is the managed job's archive; a connected job's archive is
|
|
18555
19055
|
* the push's, made from its own changes alone, by the caller.
|
|
18556
19056
|
*/
|
|
18557
|
-
async function buildSourcesOf(serverOps, patchIds, commit
|
|
19057
|
+
async function buildSourcesOf(serverOps, patchIds, commit,
|
|
19058
|
+
/**
|
|
19059
|
+
* Read the job's binary files back for a build. Off for a caller that only
|
|
19060
|
+
* commits what was prepared, which names the files rather than carrying them.
|
|
19061
|
+
*/
|
|
19062
|
+
options = {
|
|
19063
|
+
binaries: true
|
|
19064
|
+
}) {
|
|
18558
19065
|
const chain = await serverOps.fetchPatches({
|
|
18559
19066
|
patchIds: undefined,
|
|
18560
19067
|
excludePatchOps: false
|
|
@@ -18613,9 +19120,14 @@ async function buildSourcesOf(serverOps, patchIds, commit) {
|
|
|
18613
19120
|
jobPatchIds: patchIds
|
|
18614
19121
|
});
|
|
18615
19122
|
// Read now, while the job's changes still hold their files.
|
|
18616
|
-
const binaries = await readCommittedBinaryFiles(serverOps, split.archive.patchedBinaryFilesDescriptors)
|
|
19123
|
+
const binaries = options.binaries ? await readCommittedBinaryFiles(serverOps, split.archive.patchedBinaryFilesDescriptors) : {
|
|
19124
|
+
files: {},
|
|
19125
|
+
unread: []
|
|
19126
|
+
};
|
|
18617
19127
|
return {
|
|
18618
19128
|
split,
|
|
19129
|
+
withJob,
|
|
19130
|
+
headCommitSha: chain.headCommitSha,
|
|
18619
19131
|
sourceFiles: split.buildSourceFiles,
|
|
18620
19132
|
binaryFiles: binaries.files,
|
|
18621
19133
|
binaryFilesUnread: binaries.unread
|
|
@@ -18665,6 +19177,24 @@ function jobPrepareAnswer(sent, build) {
|
|
|
18665
19177
|
}
|
|
18666
19178
|
}
|
|
18667
19179
|
|
|
19180
|
+
/** What `/publish-selected` reads from content's proposal create. */
|
|
19181
|
+
const PublishProposalCreated = z.object({
|
|
19182
|
+
proposal: z.object({
|
|
19183
|
+
name: z.string()
|
|
19184
|
+
})
|
|
19185
|
+
});
|
|
19186
|
+
/** Content's refusal, in its words. */
|
|
19187
|
+
const ContentError = z.object({
|
|
19188
|
+
message: z.string()
|
|
19189
|
+
});
|
|
19190
|
+
function safeJson(text) {
|
|
19191
|
+
try {
|
|
19192
|
+
return JSON.parse(text);
|
|
19193
|
+
} catch {
|
|
19194
|
+
return null;
|
|
19195
|
+
}
|
|
19196
|
+
}
|
|
19197
|
+
|
|
18668
19198
|
async function createValServer(valModules, route, opts, config, callbacks, formatter,
|
|
18669
19199
|
/**
|
|
18670
19200
|
* Called after a save has applied its patches. EXPERIMENTAL — see
|
|
@@ -23540,7 +24070,8 @@ async function handleExternalUpload() {
|
|
|
23540
24070
|
};
|
|
23541
24071
|
}
|
|
23542
24072
|
|
|
23543
|
-
// Fix handler registry. `keyof:check-keys`,
|
|
24073
|
+
// Fix handler registry. `keyof:check-keys`, the gallery markers
|
|
24074
|
+
// (`image:check-gallery` and the rest), `router:check-route`,
|
|
23544
24075
|
// `locale:check-locale` and `record:fill-keys` are resolved upfront by the
|
|
23545
24076
|
// shared resolveSchemaSourceFixes — they never reach this registry, so they're
|
|
23546
24077
|
// excluded from the key set.
|
package/package.json
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"./package.json": "./package.json"
|
|
17
17
|
},
|
|
18
18
|
"types": "dist/valbuild-server.cjs.d.ts",
|
|
19
|
-
"version": "0.146.
|
|
19
|
+
"version": "0.146.2",
|
|
20
20
|
"devDependencies": {
|
|
21
21
|
"@prettier/sync": "^0.6.1",
|
|
22
22
|
"@types/jest": "^30.0.0",
|
|
@@ -30,9 +30,9 @@
|
|
|
30
30
|
"typescript": "^6.0.3",
|
|
31
31
|
"zod": "^4.4.3",
|
|
32
32
|
"zod-validation-error": "^5.0.0",
|
|
33
|
-
"@valbuild/
|
|
34
|
-
"@valbuild/
|
|
35
|
-
"@valbuild/
|
|
33
|
+
"@valbuild/shared": "0.146.2",
|
|
34
|
+
"@valbuild/ui": "0.146.2",
|
|
35
|
+
"@valbuild/core": "0.146.2"
|
|
36
36
|
},
|
|
37
37
|
"engines": {
|
|
38
38
|
"node": "^20.19.0 || >=22"
|