@valbuild/cli 0.140.2 → 0.141.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +79 -0
- package/cli/dist/valbuild-cli-cli.cjs.dev.js +157 -9
- package/cli/dist/valbuild-cli-cli.cjs.prod.js +157 -9
- package/cli/dist/valbuild-cli-cli.esm.js +159 -11
- package/package.json +5 -5
- package/src/cli.ts +1 -1
- package/src/debug/context.ts +1 -1
- package/src/debug/moduleClosure.ts +3 -1
- package/src/listUnusedFiles.ts +107 -9
- package/src/publish/__fixtures__/buildPublishProbe.ts +1 -1
- package/src/publish/runPublish.test.ts +73 -0
- package/src/publish/runPublish.ts +53 -2
- package/src/runValidation.ts +12 -0
- package/src/runValidation.videoset.test.ts +302 -0
|
@@ -4,9 +4,9 @@ import path from 'path';
|
|
|
4
4
|
import pc from 'picocolors';
|
|
5
5
|
import { glob } from 'fast-glob';
|
|
6
6
|
import { Internal, DEFAULT_VAL_REMOTE_HOST, DEFAULT_CONTENT_HOST } from '@valbuild/core';
|
|
7
|
-
import { createPrettierFormatter, createService, createValModuleFileInspector, fixHandlers, createFixPatch, getModulePathRange, findJsonEntryFilePath, createJsonEntryPathMap, createModulePathMap, findAndEvalValConfigFile, getSettings, uploadRemoteFile, createDefaultValFSHost, startValLogin, awaitValLoginConfirmation, persistPersonalAccessToken, ValLoginError, loadValModules, safeReadGit, ValOpsHttp, ValOpsFS, getPersonalAccessTokenPath, parsePersonalAccessTokenFile, formatPatchSourceError } from '@valbuild/server';
|
|
7
|
+
import { createPrettierFormatter, createService, createValModuleFileInspector, fixHandlers, createFixPatch, getModulePathRange, findJsonEntryFilePath, createJsonEntryPathMap, createModulePathMap, findAndEvalValConfigFile, getSettings, uploadRemoteFile, createDefaultValFSHost, filesOfVideo, startValLogin, awaitValLoginConfirmation, persistPersonalAccessToken, ValLoginError, loadValModules, safeReadGit, ValOpsHttp, ValOpsFS, getPersonalAccessTokenPath, parsePersonalAccessTokenFile, formatPatchSourceError, APP_MODE_REQUIRES_REMOTE_FILES } from '@valbuild/server';
|
|
8
8
|
import fs from 'fs/promises';
|
|
9
|
-
import { resolveSchemaSourceFixes, parseBuildTarget, parseDeclare, parseArtifacts, parseVerify, parsePromote, parseStatus, parsePublishToken, PublishProtocolError, parseProblems } from '@valbuild/shared/internal';
|
|
9
|
+
import { resolveSchemaSourceFixes, traverseSchemaSource, parseBuildTarget, parseDeclare, parseArtifacts, parseVerify, parsePromote, parseStatus, parsePublishToken, PublishProtocolError, parseProblems } from '@valbuild/shared/internal';
|
|
10
10
|
import fs$1 from 'fs';
|
|
11
11
|
import ts from 'typescript';
|
|
12
12
|
import JSZip from 'jszip';
|
|
@@ -324,6 +324,18 @@ async function* runValidation({
|
|
|
324
324
|
}, !!fix, sourcePath, v, remoteFiles, valModule.source, valModule.schema);
|
|
325
325
|
if (fix && fixPatch !== null && fixPatch !== void 0 && fixPatch.patch && (fixPatch === null || fixPatch === void 0 ? void 0 : fixPatch.patch.length) > 0) {
|
|
326
326
|
await service.patch(moduleFilePath, fixPatch.patch);
|
|
327
|
+
// A fix that renames something other modules name (a video
|
|
328
|
+
// set's key) rewrites them too, and only once its own patch
|
|
329
|
+
// is in: a reference to a key that was never renamed is the
|
|
330
|
+
// one thing worse than a stale one.
|
|
331
|
+
for (const other of result.otherModulePatches ?? []) {
|
|
332
|
+
await service.patch(other.moduleFilePath, other.patch);
|
|
333
|
+
yield {
|
|
334
|
+
type: "fix-applied",
|
|
335
|
+
file: other.moduleFilePath.slice(1),
|
|
336
|
+
sourcePath: other.moduleFilePath
|
|
337
|
+
};
|
|
338
|
+
}
|
|
327
339
|
fixedErrors += 1;
|
|
328
340
|
yield {
|
|
329
341
|
type: "fix-applied",
|
|
@@ -940,11 +952,10 @@ async function watchAndValidate(projectRoot, runOnce) {
|
|
|
940
952
|
async function listUnusedFiles({
|
|
941
953
|
root
|
|
942
954
|
}) {
|
|
943
|
-
var _valConfigFile$files;
|
|
944
955
|
const projectRoot = root ? path.resolve(root) : process.cwd();
|
|
945
|
-
|
|
946
|
-
//
|
|
947
|
-
const managedDir =
|
|
956
|
+
|
|
957
|
+
// Relative to the project root.
|
|
958
|
+
const managedDir = "public/val";
|
|
948
959
|
const service = await createService(projectRoot);
|
|
949
960
|
const registered = new Set(service.getModuleFilePaths());
|
|
950
961
|
const valFiles = await glob("**/*.val.{js,ts}", {
|
|
@@ -961,6 +972,54 @@ async function listUnusedFiles({
|
|
|
961
972
|
const valModule = await service.get(moduleId, "", {
|
|
962
973
|
validate: true
|
|
963
974
|
});
|
|
975
|
+
// A video is found by walking the source, not by its validation errors:
|
|
976
|
+
// unlike an image, a video with all its metadata reports none, so the
|
|
977
|
+
// errors below would call every finished video unused. And a video names
|
|
978
|
+
// more than its `path` — a poster, caption tracks, and a stream's
|
|
979
|
+
// playlists and segments — which `filesOfVideo` is the one answer to.
|
|
980
|
+
if (valModule.source !== undefined && valModule.schema) {
|
|
981
|
+
traverseSchemaSource(valModule.source, valModule.schema, moduleId, ({
|
|
982
|
+
source,
|
|
983
|
+
schema
|
|
984
|
+
}) => {
|
|
985
|
+
if (schema.type !== "video") {
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
988
|
+
for (const ref of filesOfVideo(source, {
|
|
989
|
+
projectRoot
|
|
990
|
+
})) {
|
|
991
|
+
if (!Internal.isRemoteMediaPath(ref)) {
|
|
992
|
+
filesUsedByVal.push(path.join(projectRoot, ...ref.split("/")));
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
});
|
|
996
|
+
}
|
|
997
|
+
// A media collection (`s.imageset()`, `s.fileset()`, `s.videoset()`) is
|
|
998
|
+
// keyed by its files, and an entry that is fine reports no error either —
|
|
999
|
+
// so, as for a video, they are found by walking the source. A video set's
|
|
1000
|
+
// stream holds every playlist and segment its master names, and an entry
|
|
1001
|
+
// its default poster and captions.
|
|
1002
|
+
if (valModule.source !== undefined && valModule.schema) {
|
|
1003
|
+
forEachMediaCollection(valModule.source, valModule.schema, (entries, mediaType) => {
|
|
1004
|
+
for (const [key, entry] of Object.entries(entries)) {
|
|
1005
|
+
const refs = mediaType === "videos" ?
|
|
1006
|
+
// The entry's poster and captions are its own files too.
|
|
1007
|
+
filesOfVideo(isObject(entry) ? {
|
|
1008
|
+
...entry,
|
|
1009
|
+
path: key
|
|
1010
|
+
} : {
|
|
1011
|
+
path: key
|
|
1012
|
+
}, {
|
|
1013
|
+
projectRoot
|
|
1014
|
+
}) : [key];
|
|
1015
|
+
for (const ref of refs) {
|
|
1016
|
+
if (!Internal.isRemoteMediaPath(ref)) {
|
|
1017
|
+
filesUsedByVal.push(path.join(projectRoot, ...ref.split("/")));
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
964
1023
|
// TODO: not sure using validation is the best way to do this, but it works currently.
|
|
965
1024
|
if (valModule.errors) {
|
|
966
1025
|
if (valModule.errors.validation) {
|
|
@@ -1005,6 +1064,44 @@ async function listUnusedFiles({
|
|
|
1005
1064
|
function isFileRef(value) {
|
|
1006
1065
|
return !!value && typeof value === "object" && "path" in value && typeof value.path === "string";
|
|
1007
1066
|
}
|
|
1067
|
+
function isObject(value) {
|
|
1068
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/**
|
|
1072
|
+
* Every media collection in a module's source, with its entries.
|
|
1073
|
+
*
|
|
1074
|
+
* `traverseSchemaSource` visits leaves, and a collection is a record whose
|
|
1075
|
+
* KEYS are what matters, so it never shows up there.
|
|
1076
|
+
*/
|
|
1077
|
+
function forEachMediaCollection(source, schema, visit) {
|
|
1078
|
+
if (schema.type === "record") {
|
|
1079
|
+
if (!isObject(source)) {
|
|
1080
|
+
return;
|
|
1081
|
+
}
|
|
1082
|
+
if (schema.mediaType) {
|
|
1083
|
+
visit(source, schema.mediaType);
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
for (const value of Object.values(source)) {
|
|
1087
|
+
forEachMediaCollection(value, schema.item, visit);
|
|
1088
|
+
}
|
|
1089
|
+
} else if (schema.type === "object") {
|
|
1090
|
+
if (!isObject(source)) {
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
1093
|
+
for (const [key, item] of Object.entries(schema.items)) {
|
|
1094
|
+
forEachMediaCollection(source[key], item, visit);
|
|
1095
|
+
}
|
|
1096
|
+
} else if (schema.type === "array") {
|
|
1097
|
+
if (!Array.isArray(source)) {
|
|
1098
|
+
return;
|
|
1099
|
+
}
|
|
1100
|
+
for (const value of source) {
|
|
1101
|
+
forEachMediaCollection(value, schema.item, visit);
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1008
1105
|
|
|
1009
1106
|
/**
|
|
1010
1107
|
* The versions of the Val packages this project has installed.
|
|
@@ -1185,7 +1282,6 @@ async function lsp() {
|
|
|
1185
1282
|
|
|
1186
1283
|
class DebugContextError extends Error {}
|
|
1187
1284
|
async function createDebugContext(options) {
|
|
1188
|
-
var _config$files;
|
|
1189
1285
|
const projectRoot = options.root ? path.resolve(options.root) : process.cwd();
|
|
1190
1286
|
if (!fs$1.existsSync(projectRoot)) {
|
|
1191
1287
|
throw new DebugContextError(`Project root does not exist: ${projectRoot}`);
|
|
@@ -1196,7 +1292,7 @@ async function createDebugContext(options) {
|
|
|
1196
1292
|
}
|
|
1197
1293
|
const valModules = loadValModules(projectRoot);
|
|
1198
1294
|
const contentUrl = process.env.VAL_CONTENT_URL || DEFAULT_CONTENT_HOST;
|
|
1199
|
-
const filesDirectory =
|
|
1295
|
+
const filesDirectory = "/public/val";
|
|
1200
1296
|
const project = config.project || process.env.VAL_PROJECT || null;
|
|
1201
1297
|
const git = await safeReadGit(projectRoot);
|
|
1202
1298
|
const branch = options.branch || config.gitBranch || process.env.VAL_GIT_BRANCH || git.branch || null;
|
|
@@ -1286,7 +1382,8 @@ function readAuth(projectRoot) {
|
|
|
1286
1382
|
* Starts from the modules the pending patches touch and closes over the
|
|
1287
1383
|
* cross-module references a schema can hold:
|
|
1288
1384
|
* - `keyOf` points at another module through its `path` (a SourcePath)
|
|
1289
|
-
* - `image`/`file` point at a gallery module through
|
|
1385
|
+
* - `image`/`file`/`video` point at a gallery (or video set) module through
|
|
1386
|
+
* `referencedModule`
|
|
1290
1387
|
* - route validation cross-references *every* router module, so if any
|
|
1291
1388
|
* included module has a route or router we need all of them
|
|
1292
1389
|
*/
|
|
@@ -1375,6 +1472,7 @@ function collectSchemaReferences(schema) {
|
|
|
1375
1472
|
break;
|
|
1376
1473
|
case "file":
|
|
1377
1474
|
case "image":
|
|
1475
|
+
case "video":
|
|
1378
1476
|
if (node.referencedModule) {
|
|
1379
1477
|
refs.referencedModules.push(node.referencedModule);
|
|
1380
1478
|
}
|
|
@@ -3078,7 +3176,39 @@ async function runPublish(options) {
|
|
|
3078
3176
|
if (git.status === "error") {
|
|
3079
3177
|
return git;
|
|
3080
3178
|
}
|
|
3081
|
-
const
|
|
3179
|
+
const read = await findAndEvalValConfigFile(root).then(config => ({
|
|
3180
|
+
config,
|
|
3181
|
+
error: null
|
|
3182
|
+
}), error => ({
|
|
3183
|
+
config: null,
|
|
3184
|
+
error
|
|
3185
|
+
}));
|
|
3186
|
+
const config = read.config;
|
|
3187
|
+
/*
|
|
3188
|
+
* The Val app stores all media remotely, and the deployed server refuses to
|
|
3189
|
+
* start for a project that does not say so. Refused here too, so that a
|
|
3190
|
+
* developer who removes `files: { remote: true }` finds out from this
|
|
3191
|
+
* command -- not from the site, after the build has gone out.
|
|
3192
|
+
*
|
|
3193
|
+
* A config that cannot be read is refused as well when this builds the
|
|
3194
|
+
* checkout, because the commonest way to lose the setting is to shorten
|
|
3195
|
+
* `initVal({ files: { remote: true } })` to `initVal()`, which exports no
|
|
3196
|
+
* config at all. Skipping the check then would be skipping it exactly when
|
|
3197
|
+
* it was needed.
|
|
3198
|
+
*
|
|
3199
|
+
* A directory of artifacts built elsewhere is the exception: it may have no
|
|
3200
|
+
* config beside it, or none this machine can evaluate, and the server's own
|
|
3201
|
+
* check still stands there.
|
|
3202
|
+
*/
|
|
3203
|
+
const refusal = remoteFilesRefusal(read, {
|
|
3204
|
+
building: !prebuilt
|
|
3205
|
+
});
|
|
3206
|
+
if (refusal !== null) {
|
|
3207
|
+
return {
|
|
3208
|
+
status: "error",
|
|
3209
|
+
message: refusal
|
|
3210
|
+
};
|
|
3211
|
+
}
|
|
3082
3212
|
const project = (config === null || config === void 0 ? void 0 : config.project) ?? env.VAL_PROJECT ?? null;
|
|
3083
3213
|
const credential = await resolvePublishCredential({
|
|
3084
3214
|
root,
|
|
@@ -3636,6 +3766,24 @@ function formatBytes(bytes) {
|
|
|
3636
3766
|
}
|
|
3637
3767
|
return `${value.toFixed(1)} ${units[unit]}`;
|
|
3638
3768
|
}
|
|
3769
|
+
/**
|
|
3770
|
+
* Why this project cannot be published to the Val app as its media is
|
|
3771
|
+
* configured, or null when it can. See the call site.
|
|
3772
|
+
*/
|
|
3773
|
+
function remoteFilesRefusal(read, {
|
|
3774
|
+
building
|
|
3775
|
+
}) {
|
|
3776
|
+
if (read.config !== null) {
|
|
3777
|
+
var _read$config$files;
|
|
3778
|
+
return ((_read$config$files = read.config.files) === null || _read$config$files === void 0 ? void 0 : _read$config$files.remote) === true ? null : APP_MODE_REQUIRES_REMOTE_FILES;
|
|
3779
|
+
}
|
|
3780
|
+
if (!building) return null;
|
|
3781
|
+
if (read.error !== null) {
|
|
3782
|
+
const reason = read.error instanceof Error ? read.error.message : String(read.error);
|
|
3783
|
+
return `${APP_MODE_REQUIRES_REMOTE_FILES}\n\nval.config.ts could not be read, so this could not be checked: ${reason}`;
|
|
3784
|
+
}
|
|
3785
|
+
return `${APP_MODE_REQUIRES_REMOTE_FILES}\n\nThere is no val.config.ts (or val.config.js) in this project to say it.`;
|
|
3786
|
+
}
|
|
3639
3787
|
|
|
3640
3788
|
/**
|
|
3641
3789
|
* `val publish` - a built project in, a live site out.
|
|
@@ -3941,7 +4089,7 @@ async function main() {
|
|
|
3941
4089
|
|
|
3942
4090
|
Command: list-unused-files
|
|
3943
4091
|
Description: EXPERIMENTAL.
|
|
3944
|
-
List files that are in the configured files directory (
|
|
4092
|
+
List files that are in the configured files directory (public/val) but not in use by any Val module.
|
|
3945
4093
|
This is useful for cleaning up unused files.
|
|
3946
4094
|
Options:
|
|
3947
4095
|
--root [root], -r [root] Set project root directory (default process.cwd())
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@valbuild/cli",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.141.1",
|
|
5
5
|
"description": "Val CLI tools",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -26,11 +26,11 @@
|
|
|
26
26
|
"picocolors": "^1.1.1",
|
|
27
27
|
"zod": "^4.4.3",
|
|
28
28
|
"zod-validation-error": "^5.0.0",
|
|
29
|
-
"@valbuild/core": "0.
|
|
29
|
+
"@valbuild/core": "0.141.0",
|
|
30
|
+
"@valbuild/language-server": "0.141.1",
|
|
31
|
+
"@valbuild/server": "0.141.1",
|
|
30
32
|
"@valbuild/eslint-plugin": "0.125.0",
|
|
31
|
-
"@valbuild/
|
|
32
|
-
"@valbuild/shared": "0.140.2",
|
|
33
|
-
"@valbuild/server": "0.140.2"
|
|
33
|
+
"@valbuild/shared": "0.141.0"
|
|
34
34
|
},
|
|
35
35
|
"peerDependencies": {
|
|
36
36
|
"prettier": "*",
|
package/src/cli.ts
CHANGED
|
@@ -95,7 +95,7 @@ async function main(): Promise<void> {
|
|
|
95
95
|
|
|
96
96
|
Command: list-unused-files
|
|
97
97
|
Description: EXPERIMENTAL.
|
|
98
|
-
List files that are in the configured files directory (
|
|
98
|
+
List files that are in the configured files directory (public/val) but not in use by any Val module.
|
|
99
99
|
This is useful for cleaning up unused files.
|
|
100
100
|
Options:
|
|
101
101
|
--root [root], -r [root] Set project root directory (default process.cwd())
|
package/src/debug/context.ts
CHANGED
|
@@ -64,7 +64,7 @@ export async function createDebugContext(options: {
|
|
|
64
64
|
}
|
|
65
65
|
const valModules = loadValModules(projectRoot);
|
|
66
66
|
const contentUrl = process.env.VAL_CONTENT_URL || DEFAULT_CONTENT_HOST;
|
|
67
|
-
const filesDirectory =
|
|
67
|
+
const filesDirectory = "/public/val";
|
|
68
68
|
const project = config.project || process.env.VAL_PROJECT || null;
|
|
69
69
|
const git = await safeReadGit(projectRoot);
|
|
70
70
|
const branch =
|
|
@@ -18,7 +18,8 @@ export type InclusionReason =
|
|
|
18
18
|
* Starts from the modules the pending patches touch and closes over the
|
|
19
19
|
* cross-module references a schema can hold:
|
|
20
20
|
* - `keyOf` points at another module through its `path` (a SourcePath)
|
|
21
|
-
* - `image`/`file` point at a gallery module through
|
|
21
|
+
* - `image`/`file`/`video` point at a gallery (or video set) module through
|
|
22
|
+
* `referencedModule`
|
|
22
23
|
* - route validation cross-references *every* router module, so if any
|
|
23
24
|
* included module has a route or router we need all of them
|
|
24
25
|
*/
|
|
@@ -123,6 +124,7 @@ function collectSchemaReferences(schema: SerializedSchema): SchemaReferences {
|
|
|
123
124
|
break;
|
|
124
125
|
case "file":
|
|
125
126
|
case "image":
|
|
127
|
+
case "video":
|
|
126
128
|
if (node.referencedModule) {
|
|
127
129
|
refs.referencedModules.push(node.referencedModule);
|
|
128
130
|
}
|
package/src/listUnusedFiles.ts
CHANGED
|
@@ -1,18 +1,20 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
Internal,
|
|
3
|
+
ModuleFilePath,
|
|
4
|
+
ModulePath,
|
|
5
|
+
SerializedSchema,
|
|
6
|
+
SourcePath,
|
|
7
|
+
} from "@valbuild/core";
|
|
8
|
+
import { createService, filesOfVideo } from "@valbuild/server";
|
|
9
|
+
import { traverseSchemaSource } from "@valbuild/shared/internal";
|
|
3
10
|
import { glob } from "fast-glob";
|
|
4
11
|
import path from "path";
|
|
5
|
-
import { findAndEvalValConfigFile } from "./utils/evalValConfigFile";
|
|
6
12
|
|
|
7
13
|
export async function listUnusedFiles({ root }: { root?: string }) {
|
|
8
14
|
const projectRoot = root ? path.resolve(root) : process.cwd();
|
|
9
15
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
const managedDir = (valConfigFile?.files?.directory ?? "/public/val").replace(
|
|
13
|
-
/^\//,
|
|
14
|
-
"",
|
|
15
|
-
);
|
|
16
|
+
// Relative to the project root.
|
|
17
|
+
const managedDir = "public/val";
|
|
16
18
|
|
|
17
19
|
const service = await createService(projectRoot);
|
|
18
20
|
const registered = new Set<ModuleFilePath>(service.getModuleFilePaths());
|
|
@@ -32,6 +34,56 @@ export async function listUnusedFiles({ root }: { root?: string }) {
|
|
|
32
34
|
const valModule = await service.get(moduleId, "" as ModulePath, {
|
|
33
35
|
validate: true,
|
|
34
36
|
});
|
|
37
|
+
// A video is found by walking the source, not by its validation errors:
|
|
38
|
+
// unlike an image, a video with all its metadata reports none, so the
|
|
39
|
+
// errors below would call every finished video unused. And a video names
|
|
40
|
+
// more than its `path` — a poster, caption tracks, and a stream's
|
|
41
|
+
// playlists and segments — which `filesOfVideo` is the one answer to.
|
|
42
|
+
if (valModule.source !== undefined && valModule.schema) {
|
|
43
|
+
traverseSchemaSource(
|
|
44
|
+
valModule.source,
|
|
45
|
+
valModule.schema,
|
|
46
|
+
moduleId as string as SourcePath,
|
|
47
|
+
({ source, schema }) => {
|
|
48
|
+
if (schema.type !== "video") {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
for (const ref of filesOfVideo(source, { projectRoot })) {
|
|
52
|
+
if (!Internal.isRemoteMediaPath(ref)) {
|
|
53
|
+
filesUsedByVal.push(path.join(projectRoot, ...ref.split("/")));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
// A media collection (`s.imageset()`, `s.fileset()`, `s.videoset()`) is
|
|
60
|
+
// keyed by its files, and an entry that is fine reports no error either —
|
|
61
|
+
// so, as for a video, they are found by walking the source. A video set's
|
|
62
|
+
// stream holds every playlist and segment its master names, and an entry
|
|
63
|
+
// its default poster and captions.
|
|
64
|
+
if (valModule.source !== undefined && valModule.schema) {
|
|
65
|
+
forEachMediaCollection(
|
|
66
|
+
valModule.source,
|
|
67
|
+
valModule.schema,
|
|
68
|
+
(entries, mediaType) => {
|
|
69
|
+
for (const [key, entry] of Object.entries(entries)) {
|
|
70
|
+
const refs =
|
|
71
|
+
mediaType === "videos"
|
|
72
|
+
? // The entry's poster and captions are its own files too.
|
|
73
|
+
filesOfVideo(
|
|
74
|
+
isObject(entry) ? { ...entry, path: key } : { path: key },
|
|
75
|
+
{ projectRoot },
|
|
76
|
+
)
|
|
77
|
+
: [key];
|
|
78
|
+
for (const ref of refs) {
|
|
79
|
+
if (!Internal.isRemoteMediaPath(ref)) {
|
|
80
|
+
filesUsedByVal.push(path.join(projectRoot, ...ref.split("/")));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
);
|
|
86
|
+
}
|
|
35
87
|
// TODO: not sure using validation is the best way to do this, but it works currently.
|
|
36
88
|
if (valModule.errors) {
|
|
37
89
|
if (valModule.errors.validation) {
|
|
@@ -86,3 +138,49 @@ function isFileRef(value: unknown): value is { path: string } {
|
|
|
86
138
|
typeof value.path === "string"
|
|
87
139
|
);
|
|
88
140
|
}
|
|
141
|
+
|
|
142
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
143
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Every media collection in a module's source, with its entries.
|
|
148
|
+
*
|
|
149
|
+
* `traverseSchemaSource` visits leaves, and a collection is a record whose
|
|
150
|
+
* KEYS are what matters, so it never shows up there.
|
|
151
|
+
*/
|
|
152
|
+
function forEachMediaCollection(
|
|
153
|
+
source: unknown,
|
|
154
|
+
schema: SerializedSchema,
|
|
155
|
+
visit: (
|
|
156
|
+
entries: Record<string, unknown>,
|
|
157
|
+
mediaType: "files" | "images" | "videos",
|
|
158
|
+
) => void,
|
|
159
|
+
): void {
|
|
160
|
+
if (schema.type === "record") {
|
|
161
|
+
if (!isObject(source)) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (schema.mediaType) {
|
|
165
|
+
visit(source, schema.mediaType);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
for (const value of Object.values(source)) {
|
|
169
|
+
forEachMediaCollection(value, schema.item, visit);
|
|
170
|
+
}
|
|
171
|
+
} else if (schema.type === "object") {
|
|
172
|
+
if (!isObject(source)) {
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
for (const [key, item] of Object.entries(schema.items)) {
|
|
176
|
+
forEachMediaCollection(source[key], item, visit);
|
|
177
|
+
}
|
|
178
|
+
} else if (schema.type === "array") {
|
|
179
|
+
if (!Array.isArray(source)) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
for (const value of source) {
|
|
183
|
+
forEachMediaCollection(value, schema.item, visit);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
@@ -69,7 +69,7 @@ export const Route = createFileRoute("/build/")({
|
|
|
69
69
|
`,
|
|
70
70
|
"src/styles.css": `h1 { color: rebeccapurple; }\n`,
|
|
71
71
|
// A Val project, so the publish wires it -- and wires it at a branch.
|
|
72
|
-
"val.config.ts": `export const config = { project: "acme/site" };\n`,
|
|
72
|
+
"val.config.ts": `export const config = { project: "acme/site", files: { remote: true } };\n`,
|
|
73
73
|
"val.modules.ts": `export default { modules: [] };\n`,
|
|
74
74
|
"public/robots.txt": "User-agent: *\n",
|
|
75
75
|
};
|
|
@@ -4,6 +4,7 @@ import os from "os";
|
|
|
4
4
|
import path from "path";
|
|
5
5
|
import zlib from "zlib";
|
|
6
6
|
import { startFakeContentService } from "./fakeContent";
|
|
7
|
+
import { APP_MODE_REQUIRES_REMOTE_FILES } from "@valbuild/server";
|
|
7
8
|
import { runPublish } from "./runPublish";
|
|
8
9
|
|
|
9
10
|
/**
|
|
@@ -59,6 +60,78 @@ const apiCalls = (calls: Array<{ method: string; path: string }>) =>
|
|
|
59
60
|
.map(({ method, path: p }) => `${method} ${p}`);
|
|
60
61
|
|
|
61
62
|
describe("val publish", () => {
|
|
63
|
+
test("refuses a project whose media is not remote, before building", async () => {
|
|
64
|
+
// The Val app has no repository for an upload to go into, so its deployed
|
|
65
|
+
// server refuses such a project -- and finding that out after the build
|
|
66
|
+
// has gone out is the worst place to find it.
|
|
67
|
+
const fake = await startFakeContentService({ token: TOKEN });
|
|
68
|
+
const root = makeArtifacts();
|
|
69
|
+
fs.writeFileSync(
|
|
70
|
+
path.join(root, "val.config.ts"),
|
|
71
|
+
`export const config = { project: "acme/site" };\n`,
|
|
72
|
+
);
|
|
73
|
+
try {
|
|
74
|
+
const result = await run(root, fake.url);
|
|
75
|
+
expect(result).toEqual({
|
|
76
|
+
status: "error",
|
|
77
|
+
message: APP_MODE_REQUIRES_REMOTE_FILES,
|
|
78
|
+
});
|
|
79
|
+
expect(apiCalls(fake.calls)).toEqual([]);
|
|
80
|
+
} finally {
|
|
81
|
+
await fake.close();
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test.each([
|
|
86
|
+
[
|
|
87
|
+
// `initVal()` exports no config at all: the commonest way to lose the
|
|
88
|
+
// setting, and the one a lenient read would wave through.
|
|
89
|
+
"a config that cannot be read",
|
|
90
|
+
{ "val.config.ts": "export const config = undefined;\n" },
|
|
91
|
+
"could not be read",
|
|
92
|
+
],
|
|
93
|
+
["no config at all", {}, "There is no val.config.ts"],
|
|
94
|
+
])(
|
|
95
|
+
"refuses to build a checkout with %s",
|
|
96
|
+
async (_, files: Record<string, string>, reason) => {
|
|
97
|
+
const fake = await startFakeContentService({ token: TOKEN });
|
|
98
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "val-publish-cfg-"));
|
|
99
|
+
fs.writeFileSync(path.join(root, "package.json"), "{}\n");
|
|
100
|
+
for (const [name, contents] of Object.entries(files)) {
|
|
101
|
+
fs.writeFileSync(path.join(root, name), contents);
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
const result = await run(root, fake.url);
|
|
105
|
+
expect(result.status).toBe("error");
|
|
106
|
+
expect(result).toMatchObject({
|
|
107
|
+
message: expect.stringContaining(APP_MODE_REQUIRES_REMOTE_FILES),
|
|
108
|
+
});
|
|
109
|
+
expect(result).toMatchObject({
|
|
110
|
+
message: expect.stringContaining(reason),
|
|
111
|
+
});
|
|
112
|
+
// Refused before anything was asked of content, let alone built.
|
|
113
|
+
expect(fake.calls).toEqual([]);
|
|
114
|
+
} finally {
|
|
115
|
+
await fake.close();
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
test("...and publishes it once it says files: { remote: true }", async () => {
|
|
121
|
+
const fake = await startFakeContentService({ token: TOKEN });
|
|
122
|
+
const root = makeArtifacts();
|
|
123
|
+
fs.writeFileSync(
|
|
124
|
+
path.join(root, "val.config.ts"),
|
|
125
|
+
`export const config = { project: "acme/site", files: { remote: true } };\n`,
|
|
126
|
+
);
|
|
127
|
+
try {
|
|
128
|
+
const result = await run(root, fake.url);
|
|
129
|
+
expect(result).toMatchObject({ status: "live" });
|
|
130
|
+
} finally {
|
|
131
|
+
await fake.close();
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
62
135
|
test("declares, uploads, confirms, verifies and promotes", async () => {
|
|
63
136
|
const fake = await startFakeContentService({ token: TOKEN });
|
|
64
137
|
const root = makeArtifacts();
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { safeReadGit } from "@valbuild/server";
|
|
1
|
+
import { APP_MODE_REQUIRES_REMOTE_FILES, safeReadGit } from "@valbuild/server";
|
|
2
|
+
import type { ValConfig } from "@valbuild/core";
|
|
2
3
|
import fs from "fs";
|
|
3
4
|
import path from "path";
|
|
4
5
|
import { findAndEvalValConfigFile } from "../utils/evalValConfigFile";
|
|
@@ -155,7 +156,31 @@ export async function runPublish(
|
|
|
155
156
|
return git;
|
|
156
157
|
}
|
|
157
158
|
|
|
158
|
-
const
|
|
159
|
+
const read: ConfigRead = await findAndEvalValConfigFile(root).then(
|
|
160
|
+
(config) => ({ config, error: null }),
|
|
161
|
+
(error: unknown) => ({ config: null, error }),
|
|
162
|
+
);
|
|
163
|
+
const config = read.config;
|
|
164
|
+
/*
|
|
165
|
+
* The Val app stores all media remotely, and the deployed server refuses to
|
|
166
|
+
* start for a project that does not say so. Refused here too, so that a
|
|
167
|
+
* developer who removes `files: { remote: true }` finds out from this
|
|
168
|
+
* command -- not from the site, after the build has gone out.
|
|
169
|
+
*
|
|
170
|
+
* A config that cannot be read is refused as well when this builds the
|
|
171
|
+
* checkout, because the commonest way to lose the setting is to shorten
|
|
172
|
+
* `initVal({ files: { remote: true } })` to `initVal()`, which exports no
|
|
173
|
+
* config at all. Skipping the check then would be skipping it exactly when
|
|
174
|
+
* it was needed.
|
|
175
|
+
*
|
|
176
|
+
* A directory of artifacts built elsewhere is the exception: it may have no
|
|
177
|
+
* config beside it, or none this machine can evaluate, and the server's own
|
|
178
|
+
* check still stands there.
|
|
179
|
+
*/
|
|
180
|
+
const refusal = remoteFilesRefusal(read, { building: !prebuilt });
|
|
181
|
+
if (refusal !== null) {
|
|
182
|
+
return { status: "error", message: refusal };
|
|
183
|
+
}
|
|
159
184
|
const project = config?.project ?? env.VAL_PROJECT ?? null;
|
|
160
185
|
const credential = await resolvePublishCredential({
|
|
161
186
|
root,
|
|
@@ -741,3 +766,29 @@ export function formatBytes(bytes: number): string {
|
|
|
741
766
|
}
|
|
742
767
|
return `${value.toFixed(1)} ${units[unit]}`;
|
|
743
768
|
}
|
|
769
|
+
|
|
770
|
+
type ConfigRead =
|
|
771
|
+
| { config: ValConfig | null; error: null }
|
|
772
|
+
| { config: null; error: unknown };
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* Why this project cannot be published to the Val app as its media is
|
|
776
|
+
* configured, or null when it can. See the call site.
|
|
777
|
+
*/
|
|
778
|
+
function remoteFilesRefusal(
|
|
779
|
+
read: ConfigRead,
|
|
780
|
+
{ building }: { building: boolean },
|
|
781
|
+
): string | null {
|
|
782
|
+
if (read.config !== null) {
|
|
783
|
+
return read.config.files?.remote === true
|
|
784
|
+
? null
|
|
785
|
+
: APP_MODE_REQUIRES_REMOTE_FILES;
|
|
786
|
+
}
|
|
787
|
+
if (!building) return null;
|
|
788
|
+
if (read.error !== null) {
|
|
789
|
+
const reason =
|
|
790
|
+
read.error instanceof Error ? read.error.message : String(read.error);
|
|
791
|
+
return `${APP_MODE_REQUIRES_REMOTE_FILES}\n\nval.config.ts could not be read, so this could not be checked: ${reason}`;
|
|
792
|
+
}
|
|
793
|
+
return `${APP_MODE_REQUIRES_REMOTE_FILES}\n\nThere is no val.config.ts (or val.config.js) in this project to say it.`;
|
|
794
|
+
}
|
package/src/runValidation.ts
CHANGED
|
@@ -294,6 +294,18 @@ export async function* runValidation({
|
|
|
294
294
|
|
|
295
295
|
if (fix && fixPatch?.patch && fixPatch?.patch.length > 0) {
|
|
296
296
|
await service.patch(moduleFilePath, fixPatch.patch);
|
|
297
|
+
// A fix that renames something other modules name (a video
|
|
298
|
+
// set's key) rewrites them too, and only once its own patch
|
|
299
|
+
// is in: a reference to a key that was never renamed is the
|
|
300
|
+
// one thing worse than a stale one.
|
|
301
|
+
for (const other of result.otherModulePatches ?? []) {
|
|
302
|
+
await service.patch(other.moduleFilePath, other.patch);
|
|
303
|
+
yield {
|
|
304
|
+
type: "fix-applied",
|
|
305
|
+
file: other.moduleFilePath.slice(1),
|
|
306
|
+
sourcePath: other.moduleFilePath,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
297
309
|
fixedErrors += 1;
|
|
298
310
|
yield { type: "fix-applied", file, sourcePath };
|
|
299
311
|
} else if (
|