@valbuild/cli 0.140.2 → 0.141.0

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.
@@ -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
- const valConfigFile = await findAndEvalValConfigFile(projectRoot);
946
- // Strip the leading "/" so it is relative to the project root (e.g. "public/val").
947
- const managedDir = ((valConfigFile === null || valConfigFile === void 0 || (_valConfigFile$files = valConfigFile.files) === null || _valConfigFile$files === void 0 ? void 0 : _valConfigFile$files.directory) ?? "/public/val").replace(/^\//, "");
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 = ((_config$files = config.files) === null || _config$files === void 0 ? void 0 : _config$files.directory) ?? "/public/val";
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 `referencedModule`
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 config = await findAndEvalValConfigFile(root).catch(() => null);
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 (files.directory, default public/val) but not in use by any Val module.
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.140.2",
4
+ "version": "0.141.0",
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.139.1",
30
29
  "@valbuild/eslint-plugin": "0.125.0",
31
- "@valbuild/language-server": "0.140.2",
32
- "@valbuild/shared": "0.140.2",
33
- "@valbuild/server": "0.140.2"
30
+ "@valbuild/language-server": "0.141.0",
31
+ "@valbuild/core": "0.141.0",
32
+ "@valbuild/shared": "0.141.0",
33
+ "@valbuild/server": "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 (files.directory, default public/val) but not in use by any Val module.
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())
@@ -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 = config.files?.directory ?? "/public/val";
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 `referencedModule`
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
  }
@@ -1,18 +1,20 @@
1
- import { ModuleFilePath, ModulePath, SourcePath } from "@valbuild/core";
2
- import { createService } from "@valbuild/server";
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
- const valConfigFile = await findAndEvalValConfigFile(projectRoot);
11
- // Strip the leading "/" so it is relative to the project root (e.g. "public/val").
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 config = await findAndEvalValConfigFile(root).catch(() => null);
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
+ }
@@ -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 (