@valbuild/server 0.123.0 → 0.123.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 CHANGED
@@ -1,5 +1,36 @@
1
1
  # @valbuild/server
2
2
 
3
+ ## 0.123.2
4
+
5
+ ### Patch Changes
6
+
7
+ - [#619](https://github.com/valbuild/val/pull/619) [`8e58c34`](https://github.com/valbuild/val/commit/8e58c3495d1bf0f221a57082cb0a3045929722a1) Thanks [@freekh](https://github.com/freekh)! - Stop `val validate` reporting published remote gallery images as missing — and `--fix` deleting them
8
+
9
+ A remote gallery (`s.images({ remote: true })`) keys an uploaded entry by its
10
+ remote URL. Two separate checks read that key, normalised it back to the local
11
+ path it encodes, and then required a file to be sitting there:
12
+
13
+ - `val validate` reported _"Gallery … has tracked files that do not exist on
14
+ disk"_ for every published remote image;
15
+ - `val validate --fix` **removed the entry from the gallery**, silently deleting
16
+ the reference to a file that was safely on the content host.
17
+
18
+ Both were wrong for the same reason. Publishing uploads remote files to the
19
+ content host and copies only local ones into the working tree, so an image added
20
+ through the Studio — or over MCP — has no file in the repo by design. Putting one
21
+ there is exactly what remote storage exists to avoid.
22
+
23
+ Remote entries are now exempt from both the missing-file check and the
24
+ metadata-from-disk verification. Whether a remote entry is sound is
25
+ `image:check-remote`'s question, and it already asks it. Nothing changes for
26
+ local entries, or for a remote entry that does have a local file — `--fix`
27
+ promotes a local file to a remote ref and leaves the file where it was, and that
28
+ file is still counted as tracked rather than reported as untracked.
29
+
30
+ - Updated dependencies [[`04b6d4c`](https://github.com/valbuild/val/commit/04b6d4cbbecc131bbaf3c20633af9dad8c857310), [`3e93508`](https://github.com/valbuild/val/commit/3e93508b05d08b0c24947a97c6141a9ad8a3931e)]:
31
+ - @valbuild/shared@0.123.2
32
+ - @valbuild/ui@0.123.2
33
+
3
34
  ## 0.123.0
4
35
 
5
36
  ### Minor Changes
@@ -162,6 +162,35 @@ export declare function handleRemoteGalleryFileUpload(ctx: FixHandlerContext): P
162
162
  export declare function handleRemoteFileDownload(ctx: FixHandlerContext): Promise<FixHandlerResult>;
163
163
  export declare function handleRemoteFileCheck(): Promise<FixHandlerResult>;
164
164
  export declare function handleUniqueFolderCheck(ctx: FixHandlerContext): Promise<FixHandlerResult>;
165
+ /**
166
+ * What is out of step between a gallery's entries and its directory.
167
+ *
168
+ * Two questions, and they treat a remote entry differently — which is the whole
169
+ * reason this is separate from the handler around it:
170
+ *
171
+ * - **Missing**: an entry with no bytes at its local path. Asked of LOCAL
172
+ * entries only. A remote entry's bytes live on the content host, and nothing
173
+ * puts a copy in the working tree: `saveOrUploadFiles` uploads the remote
174
+ * descriptors and copies only the local ones into the tree, so a remote entry
175
+ * added through the Studio (or over MCP) has no local file by design, and
176
+ * demanding one would mean committing remote bytes to git — which is what
177
+ * remote storage exists to avoid. Whether those bytes really are on the host
178
+ * is a different check, `image:check-remote`, which already runs for exactly
179
+ * these entries.
180
+ * - **Untracked**: a file in the directory that no entry claims. Asked of every
181
+ * entry, remote included, and that is why they are normalised to their local
182
+ * path: `val validate --fix` promotes a local file to a remote ref and leaves
183
+ * the file where it was, so a remote entry can perfectly well have one.
184
+ */
185
+ export declare function checkGalleryFiles(input: {
186
+ entryKeys: string[];
187
+ directory: string;
188
+ projectRoot: string;
189
+ fs: Pick<IValFSHost, "fileExists" | "readDirectory">;
190
+ }): {
191
+ missingTrackedFiles: string[];
192
+ untrackedFiles: string[];
193
+ };
165
194
  export declare function handleCheckAllFiles(ctx: FixHandlerContext): Promise<FixHandlerResult>;
166
195
  export declare function handleJsonValuesExtractEntry(ctx: FixHandlerContext): Promise<FixHandlerResult>;
167
196
  /**
@@ -13506,6 +13506,72 @@ async function extractFileMetadata(filename, _input // TODO: use buffer to deter
13506
13506
  };
13507
13507
  }
13508
13508
 
13509
+ /**
13510
+ * A gallery entry's key, read as what it actually is.
13511
+ *
13512
+ * A gallery is a record keyed by its entries' file paths. A REMOTE gallery keys
13513
+ * an uploaded entry by the remote URL instead — which encodes that same path —
13514
+ * so every entry has a local path, and the interesting question is whether the
13515
+ * bytes are expected to be at it.
13516
+ *
13517
+ * **They are not, for a remote entry added the ordinary way.** `saveOrUploadFiles`
13518
+ * uploads the remote descriptors to the content host and copies only the local
13519
+ * ones into the working tree, so an image added through the Studio (or over MCP)
13520
+ * and published has no file in the repo, by design — putting one there is what
13521
+ * remote storage exists to avoid. A remote entry CAN have one, because
13522
+ * `val validate --fix` promotes a local file to a remote ref and leaves the file
13523
+ * where it was; so "remote" means "do not require it", never "there is not one".
13524
+ *
13525
+ * One copy, in its own file, because this used to be two: a `remoteKeyToLocalPath`
13526
+ * in `fixHandlers` and a `galleryKeyToLocalPath` in `createFixPatch`, each
13527
+ * normalising the key correctly and each then treating the result as a file that
13528
+ * must exist. That agreement is what made a published remote image report as
13529
+ * missing from both sides at once.
13530
+ */
13531
+
13532
+ /**
13533
+ * A key that names somewhere else, whether or not it is a ref we can read.
13534
+ *
13535
+ * Case-insensitive, because a scheme is (RFC 3986) — and because
13536
+ * `splitRemoteRef`'s pattern is not, so `HTTPS://…` is precisely one of the
13537
+ * spellings that arrives here unparsed and must not be mistaken for a path.
13538
+ */
13539
+ function isRemoteUrl(key) {
13540
+ return /^https?:\/\//i.test(key);
13541
+ }
13542
+ function galleryEntryOf(key) {
13543
+ const remoteRefRes = core.Internal.remote.splitRemoteRef(key);
13544
+ if (remoteRefRes.status === "success") {
13545
+ return {
13546
+ localPath: `/${remoteRefRes.filePath}`,
13547
+ remote: true
13548
+ };
13549
+ }
13550
+ if (isRemoteUrl(key)) {
13551
+ // A URL that is not a ref this version can read: truncated by a hand edit,
13552
+ // a path outside `public/`, a `..` segment — or written by a core version
13553
+ // whose format `splitRemoteRef` rejects.
13554
+ //
13555
+ // Still not a path in the working tree, so the on-disk checks must not run
13556
+ // against it. They would find nothing at `<projectRoot>/https://…`, report
13557
+ // the entry as missing, and `--fix` would DELETE it — the exact failure
13558
+ // this module exists to prevent, arriving at the one moment the key is the
13559
+ // only remaining record of where the bytes went.
13560
+ //
13561
+ // Nothing is being hidden by this: a malformed key is already reported, by
13562
+ // the record schema's own "Invalid remote URL format". That is an error for
13563
+ // a person to look at, not a file for `--fix` to go missing.
13564
+ return {
13565
+ localPath: key,
13566
+ remote: true
13567
+ };
13568
+ }
13569
+ return {
13570
+ localPath: key,
13571
+ remote: false
13572
+ };
13573
+ }
13574
+
13509
13575
  /**
13510
13576
  * The media path a validation error is about, if it is about media at all.
13511
13577
  *
@@ -13701,14 +13767,6 @@ async function downloadFileFromRemote(ref, filePath) {
13701
13767
  // record-level fix expands into per-entry errors that should point at the
13702
13768
  // individual entry (e.g. `?p="/public/val/logo.png"`) rather than the record.
13703
13769
 
13704
- // Gallery entries are keyed by their file path. Remote galleries key uploaded
13705
- // entries by a remote URL while keeping the file on disk at its local path, so
13706
- // normalize a remote-URL key back to that local path for on-disk reads.
13707
- function galleryKeyToLocalPath(key) {
13708
- const res = core.Internal.remote.splitRemoteRef(key);
13709
- return res.status === "success" ? `/${res.filePath}` : key;
13710
- }
13711
-
13712
13770
  // Patch path of the record that holds a media entry. Media keys are file paths
13713
13771
  // that can contain dots, which sourceToPatchPath cannot round-trip, so strip
13714
13772
  // the (JSON-encoded) key segment off the entry source path and convert the
@@ -14017,7 +14075,18 @@ async function createFixPatch(config, apply, sourcePath, validationError, remote
14017
14075
  }
14018
14076
  const gallerySource = moduleSource;
14019
14077
  for (const [entryKey, storedEntry] of Object.entries(gallerySource)) {
14020
- const filename = path__namespace["default"].join(config.projectRoot, galleryKeyToLocalPath(entryKey));
14078
+ const entry = galleryEntryOf(entryKey);
14079
+ if (entry.remote) {
14080
+ // A remote entry's bytes are on the content host, so there may be no
14081
+ // local file to re-derive its metadata from — and where there is one,
14082
+ // rewriting the metadata from it would invalidate the ref, which has
14083
+ // that metadata baked into its validation hash. Whether a remote
14084
+ // entry is sound is `image:check-remote`'s question, and it already
14085
+ // asks it. Reading the file here reported every published remote
14086
+ // image as unreadable, and with `--fix` REMOVED it from the gallery.
14087
+ continue;
14088
+ }
14089
+ const filename = path__namespace["default"].join(config.projectRoot, entry.localPath);
14021
14090
  let buffer;
14022
14091
  try {
14023
14092
  buffer = fs__default["default"].readFileSync(filename);
@@ -14683,15 +14752,48 @@ async function handleUniqueFolderCheck(ctx) {
14683
14752
  };
14684
14753
  }
14685
14754
 
14686
- // Maps a gallery key to its on-disk local path. Remote galleries key uploaded
14687
- // entries by a remote URL while keeping the file on disk; everything else is
14688
- // already a local path and is returned unchanged.
14689
- function remoteKeyToLocalPath(key) {
14690
- const remoteRefRes = core.Internal.remote.splitRemoteRef(key);
14691
- if (remoteRefRes.status === "success") {
14692
- return `/${remoteRefRes.filePath}`;
14755
+ /**
14756
+ * What is out of step between a gallery's entries and its directory.
14757
+ *
14758
+ * Two questions, and they treat a remote entry differently — which is the whole
14759
+ * reason this is separate from the handler around it:
14760
+ *
14761
+ * - **Missing**: an entry with no bytes at its local path. Asked of LOCAL
14762
+ * entries only. A remote entry's bytes live on the content host, and nothing
14763
+ * puts a copy in the working tree: `saveOrUploadFiles` uploads the remote
14764
+ * descriptors and copies only the local ones into the tree, so a remote entry
14765
+ * added through the Studio (or over MCP) has no local file by design, and
14766
+ * demanding one would mean committing remote bytes to git — which is what
14767
+ * remote storage exists to avoid. Whether those bytes really are on the host
14768
+ * is a different check, `image:check-remote`, which already runs for exactly
14769
+ * these entries.
14770
+ * - **Untracked**: a file in the directory that no entry claims. Asked of every
14771
+ * entry, remote included, and that is why they are normalised to their local
14772
+ * path: `val validate --fix` promotes a local file to a remote ref and leaves
14773
+ * the file where it was, so a remote entry can perfectly well have one.
14774
+ */
14775
+ function checkGalleryFiles(input) {
14776
+ const {
14777
+ directory,
14778
+ projectRoot,
14779
+ fs
14780
+ } = input;
14781
+ const entries = input.entryKeys.map(galleryEntryOf);
14782
+ const trackedFiles = new Set(entries.map(entry => entry.localPath));
14783
+ const missingTrackedFiles = entries.filter(entry => !entry.remote && !fs.fileExists(path__namespace["default"].join(projectRoot, entry.localPath))).map(entry => entry.localPath);
14784
+ const filesInDir = [];
14785
+ try {
14786
+ const found = fs.readDirectory(path__namespace["default"].join(projectRoot, directory), undefined, undefined, ["**/*"]);
14787
+ for (const entry of found) {
14788
+ filesInDir.push("/" + path__namespace["default"].relative(projectRoot, entry).split(path__namespace["default"].sep).join("/"));
14789
+ }
14790
+ } catch {
14791
+ // directory doesn't exist — no untracked files possible
14693
14792
  }
14694
- return key;
14793
+ return {
14794
+ missingTrackedFiles,
14795
+ untrackedFiles: filesInDir.filter(f => !trackedFiles.has(f))
14796
+ };
14695
14797
  }
14696
14798
  async function handleCheckAllFiles(ctx) {
14697
14799
  const value = ctx.validationError.value;
@@ -14711,14 +14813,14 @@ async function handleCheckAllFiles(ctx) {
14711
14813
  errorMessage: `Could not get source for ${ctx.sourcePath}`
14712
14814
  };
14713
14815
  }
14714
- // Gallery entries are keyed by their file path. Remote galleries key uploaded
14715
- // entries by a remote URL, but the file is kept on disk at its local path, so
14716
- // normalize remote-URL keys back to that local path for the on-disk checks.
14717
- const trackedFiles = new Set(Object.keys(source).map(remoteKeyToLocalPath));
14718
-
14719
- // Check that all tracked files exist on disk
14720
- const missingTrackedFiles = Array.from(trackedFiles).filter(f => {
14721
- return !ctx.fs.fileExists(path__namespace["default"].join(ctx.projectRoot, f));
14816
+ const {
14817
+ missingTrackedFiles,
14818
+ untrackedFiles
14819
+ } = checkGalleryFiles({
14820
+ entryKeys: Object.keys(source),
14821
+ directory,
14822
+ projectRoot: ctx.projectRoot,
14823
+ fs: ctx.fs
14722
14824
  });
14723
14825
  if (missingTrackedFiles.length > 0) {
14724
14826
  if (!ctx.fix) {
@@ -14733,18 +14835,6 @@ async function handleCheckAllFiles(ctx) {
14733
14835
  shouldApplyPatch: true
14734
14836
  };
14735
14837
  }
14736
- const dirPath = path__namespace["default"].join(ctx.projectRoot, directory);
14737
- const filesInDir = [];
14738
- try {
14739
- const entries = ctx.fs.readDirectory(dirPath, undefined, undefined, ["**/*"]);
14740
- for (const entry of entries) {
14741
- const relPath = "/" + path__namespace["default"].relative(ctx.projectRoot, entry).split(path__namespace["default"].sep).join("/");
14742
- filesInDir.push(relPath);
14743
- }
14744
- } catch {
14745
- // directory doesn't exist — no untracked files possible
14746
- }
14747
- const untrackedFiles = filesInDir.filter(f => !trackedFiles.has(f));
14748
14838
  if (untrackedFiles.length > 0) {
14749
14839
  return {
14750
14840
  success: false,
@@ -13506,6 +13506,72 @@ async function extractFileMetadata(filename, _input // TODO: use buffer to deter
13506
13506
  };
13507
13507
  }
13508
13508
 
13509
+ /**
13510
+ * A gallery entry's key, read as what it actually is.
13511
+ *
13512
+ * A gallery is a record keyed by its entries' file paths. A REMOTE gallery keys
13513
+ * an uploaded entry by the remote URL instead — which encodes that same path —
13514
+ * so every entry has a local path, and the interesting question is whether the
13515
+ * bytes are expected to be at it.
13516
+ *
13517
+ * **They are not, for a remote entry added the ordinary way.** `saveOrUploadFiles`
13518
+ * uploads the remote descriptors to the content host and copies only the local
13519
+ * ones into the working tree, so an image added through the Studio (or over MCP)
13520
+ * and published has no file in the repo, by design — putting one there is what
13521
+ * remote storage exists to avoid. A remote entry CAN have one, because
13522
+ * `val validate --fix` promotes a local file to a remote ref and leaves the file
13523
+ * where it was; so "remote" means "do not require it", never "there is not one".
13524
+ *
13525
+ * One copy, in its own file, because this used to be two: a `remoteKeyToLocalPath`
13526
+ * in `fixHandlers` and a `galleryKeyToLocalPath` in `createFixPatch`, each
13527
+ * normalising the key correctly and each then treating the result as a file that
13528
+ * must exist. That agreement is what made a published remote image report as
13529
+ * missing from both sides at once.
13530
+ */
13531
+
13532
+ /**
13533
+ * A key that names somewhere else, whether or not it is a ref we can read.
13534
+ *
13535
+ * Case-insensitive, because a scheme is (RFC 3986) — and because
13536
+ * `splitRemoteRef`'s pattern is not, so `HTTPS://…` is precisely one of the
13537
+ * spellings that arrives here unparsed and must not be mistaken for a path.
13538
+ */
13539
+ function isRemoteUrl(key) {
13540
+ return /^https?:\/\//i.test(key);
13541
+ }
13542
+ function galleryEntryOf(key) {
13543
+ const remoteRefRes = core.Internal.remote.splitRemoteRef(key);
13544
+ if (remoteRefRes.status === "success") {
13545
+ return {
13546
+ localPath: `/${remoteRefRes.filePath}`,
13547
+ remote: true
13548
+ };
13549
+ }
13550
+ if (isRemoteUrl(key)) {
13551
+ // A URL that is not a ref this version can read: truncated by a hand edit,
13552
+ // a path outside `public/`, a `..` segment — or written by a core version
13553
+ // whose format `splitRemoteRef` rejects.
13554
+ //
13555
+ // Still not a path in the working tree, so the on-disk checks must not run
13556
+ // against it. They would find nothing at `<projectRoot>/https://…`, report
13557
+ // the entry as missing, and `--fix` would DELETE it — the exact failure
13558
+ // this module exists to prevent, arriving at the one moment the key is the
13559
+ // only remaining record of where the bytes went.
13560
+ //
13561
+ // Nothing is being hidden by this: a malformed key is already reported, by
13562
+ // the record schema's own "Invalid remote URL format". That is an error for
13563
+ // a person to look at, not a file for `--fix` to go missing.
13564
+ return {
13565
+ localPath: key,
13566
+ remote: true
13567
+ };
13568
+ }
13569
+ return {
13570
+ localPath: key,
13571
+ remote: false
13572
+ };
13573
+ }
13574
+
13509
13575
  /**
13510
13576
  * The media path a validation error is about, if it is about media at all.
13511
13577
  *
@@ -13701,14 +13767,6 @@ async function downloadFileFromRemote(ref, filePath) {
13701
13767
  // record-level fix expands into per-entry errors that should point at the
13702
13768
  // individual entry (e.g. `?p="/public/val/logo.png"`) rather than the record.
13703
13769
 
13704
- // Gallery entries are keyed by their file path. Remote galleries key uploaded
13705
- // entries by a remote URL while keeping the file on disk at its local path, so
13706
- // normalize a remote-URL key back to that local path for on-disk reads.
13707
- function galleryKeyToLocalPath(key) {
13708
- const res = core.Internal.remote.splitRemoteRef(key);
13709
- return res.status === "success" ? `/${res.filePath}` : key;
13710
- }
13711
-
13712
13770
  // Patch path of the record that holds a media entry. Media keys are file paths
13713
13771
  // that can contain dots, which sourceToPatchPath cannot round-trip, so strip
13714
13772
  // the (JSON-encoded) key segment off the entry source path and convert the
@@ -14017,7 +14075,18 @@ async function createFixPatch(config, apply, sourcePath, validationError, remote
14017
14075
  }
14018
14076
  const gallerySource = moduleSource;
14019
14077
  for (const [entryKey, storedEntry] of Object.entries(gallerySource)) {
14020
- const filename = path__namespace["default"].join(config.projectRoot, galleryKeyToLocalPath(entryKey));
14078
+ const entry = galleryEntryOf(entryKey);
14079
+ if (entry.remote) {
14080
+ // A remote entry's bytes are on the content host, so there may be no
14081
+ // local file to re-derive its metadata from — and where there is one,
14082
+ // rewriting the metadata from it would invalidate the ref, which has
14083
+ // that metadata baked into its validation hash. Whether a remote
14084
+ // entry is sound is `image:check-remote`'s question, and it already
14085
+ // asks it. Reading the file here reported every published remote
14086
+ // image as unreadable, and with `--fix` REMOVED it from the gallery.
14087
+ continue;
14088
+ }
14089
+ const filename = path__namespace["default"].join(config.projectRoot, entry.localPath);
14021
14090
  let buffer;
14022
14091
  try {
14023
14092
  buffer = fs__default["default"].readFileSync(filename);
@@ -14683,15 +14752,48 @@ async function handleUniqueFolderCheck(ctx) {
14683
14752
  };
14684
14753
  }
14685
14754
 
14686
- // Maps a gallery key to its on-disk local path. Remote galleries key uploaded
14687
- // entries by a remote URL while keeping the file on disk; everything else is
14688
- // already a local path and is returned unchanged.
14689
- function remoteKeyToLocalPath(key) {
14690
- const remoteRefRes = core.Internal.remote.splitRemoteRef(key);
14691
- if (remoteRefRes.status === "success") {
14692
- return `/${remoteRefRes.filePath}`;
14755
+ /**
14756
+ * What is out of step between a gallery's entries and its directory.
14757
+ *
14758
+ * Two questions, and they treat a remote entry differently — which is the whole
14759
+ * reason this is separate from the handler around it:
14760
+ *
14761
+ * - **Missing**: an entry with no bytes at its local path. Asked of LOCAL
14762
+ * entries only. A remote entry's bytes live on the content host, and nothing
14763
+ * puts a copy in the working tree: `saveOrUploadFiles` uploads the remote
14764
+ * descriptors and copies only the local ones into the tree, so a remote entry
14765
+ * added through the Studio (or over MCP) has no local file by design, and
14766
+ * demanding one would mean committing remote bytes to git — which is what
14767
+ * remote storage exists to avoid. Whether those bytes really are on the host
14768
+ * is a different check, `image:check-remote`, which already runs for exactly
14769
+ * these entries.
14770
+ * - **Untracked**: a file in the directory that no entry claims. Asked of every
14771
+ * entry, remote included, and that is why they are normalised to their local
14772
+ * path: `val validate --fix` promotes a local file to a remote ref and leaves
14773
+ * the file where it was, so a remote entry can perfectly well have one.
14774
+ */
14775
+ function checkGalleryFiles(input) {
14776
+ const {
14777
+ directory,
14778
+ projectRoot,
14779
+ fs
14780
+ } = input;
14781
+ const entries = input.entryKeys.map(galleryEntryOf);
14782
+ const trackedFiles = new Set(entries.map(entry => entry.localPath));
14783
+ const missingTrackedFiles = entries.filter(entry => !entry.remote && !fs.fileExists(path__namespace["default"].join(projectRoot, entry.localPath))).map(entry => entry.localPath);
14784
+ const filesInDir = [];
14785
+ try {
14786
+ const found = fs.readDirectory(path__namespace["default"].join(projectRoot, directory), undefined, undefined, ["**/*"]);
14787
+ for (const entry of found) {
14788
+ filesInDir.push("/" + path__namespace["default"].relative(projectRoot, entry).split(path__namespace["default"].sep).join("/"));
14789
+ }
14790
+ } catch {
14791
+ // directory doesn't exist — no untracked files possible
14693
14792
  }
14694
- return key;
14793
+ return {
14794
+ missingTrackedFiles,
14795
+ untrackedFiles: filesInDir.filter(f => !trackedFiles.has(f))
14796
+ };
14695
14797
  }
14696
14798
  async function handleCheckAllFiles(ctx) {
14697
14799
  const value = ctx.validationError.value;
@@ -14711,14 +14813,14 @@ async function handleCheckAllFiles(ctx) {
14711
14813
  errorMessage: `Could not get source for ${ctx.sourcePath}`
14712
14814
  };
14713
14815
  }
14714
- // Gallery entries are keyed by their file path. Remote galleries key uploaded
14715
- // entries by a remote URL, but the file is kept on disk at its local path, so
14716
- // normalize remote-URL keys back to that local path for the on-disk checks.
14717
- const trackedFiles = new Set(Object.keys(source).map(remoteKeyToLocalPath));
14718
-
14719
- // Check that all tracked files exist on disk
14720
- const missingTrackedFiles = Array.from(trackedFiles).filter(f => {
14721
- return !ctx.fs.fileExists(path__namespace["default"].join(ctx.projectRoot, f));
14816
+ const {
14817
+ missingTrackedFiles,
14818
+ untrackedFiles
14819
+ } = checkGalleryFiles({
14820
+ entryKeys: Object.keys(source),
14821
+ directory,
14822
+ projectRoot: ctx.projectRoot,
14823
+ fs: ctx.fs
14722
14824
  });
14723
14825
  if (missingTrackedFiles.length > 0) {
14724
14826
  if (!ctx.fix) {
@@ -14733,18 +14835,6 @@ async function handleCheckAllFiles(ctx) {
14733
14835
  shouldApplyPatch: true
14734
14836
  };
14735
14837
  }
14736
- const dirPath = path__namespace["default"].join(ctx.projectRoot, directory);
14737
- const filesInDir = [];
14738
- try {
14739
- const entries = ctx.fs.readDirectory(dirPath, undefined, undefined, ["**/*"]);
14740
- for (const entry of entries) {
14741
- const relPath = "/" + path__namespace["default"].relative(ctx.projectRoot, entry).split(path__namespace["default"].sep).join("/");
14742
- filesInDir.push(relPath);
14743
- }
14744
- } catch {
14745
- // directory doesn't exist — no untracked files possible
14746
- }
14747
- const untrackedFiles = filesInDir.filter(f => !trackedFiles.has(f));
14748
14838
  if (untrackedFiles.length > 0) {
14749
14839
  return {
14750
14840
  success: false,
@@ -13472,6 +13472,72 @@ async function extractFileMetadata(filename, _input // TODO: use buffer to deter
13472
13472
  };
13473
13473
  }
13474
13474
 
13475
+ /**
13476
+ * A gallery entry's key, read as what it actually is.
13477
+ *
13478
+ * A gallery is a record keyed by its entries' file paths. A REMOTE gallery keys
13479
+ * an uploaded entry by the remote URL instead — which encodes that same path —
13480
+ * so every entry has a local path, and the interesting question is whether the
13481
+ * bytes are expected to be at it.
13482
+ *
13483
+ * **They are not, for a remote entry added the ordinary way.** `saveOrUploadFiles`
13484
+ * uploads the remote descriptors to the content host and copies only the local
13485
+ * ones into the working tree, so an image added through the Studio (or over MCP)
13486
+ * and published has no file in the repo, by design — putting one there is what
13487
+ * remote storage exists to avoid. A remote entry CAN have one, because
13488
+ * `val validate --fix` promotes a local file to a remote ref and leaves the file
13489
+ * where it was; so "remote" means "do not require it", never "there is not one".
13490
+ *
13491
+ * One copy, in its own file, because this used to be two: a `remoteKeyToLocalPath`
13492
+ * in `fixHandlers` and a `galleryKeyToLocalPath` in `createFixPatch`, each
13493
+ * normalising the key correctly and each then treating the result as a file that
13494
+ * must exist. That agreement is what made a published remote image report as
13495
+ * missing from both sides at once.
13496
+ */
13497
+
13498
+ /**
13499
+ * A key that names somewhere else, whether or not it is a ref we can read.
13500
+ *
13501
+ * Case-insensitive, because a scheme is (RFC 3986) — and because
13502
+ * `splitRemoteRef`'s pattern is not, so `HTTPS://…` is precisely one of the
13503
+ * spellings that arrives here unparsed and must not be mistaken for a path.
13504
+ */
13505
+ function isRemoteUrl(key) {
13506
+ return /^https?:\/\//i.test(key);
13507
+ }
13508
+ function galleryEntryOf(key) {
13509
+ const remoteRefRes = Internal.remote.splitRemoteRef(key);
13510
+ if (remoteRefRes.status === "success") {
13511
+ return {
13512
+ localPath: `/${remoteRefRes.filePath}`,
13513
+ remote: true
13514
+ };
13515
+ }
13516
+ if (isRemoteUrl(key)) {
13517
+ // A URL that is not a ref this version can read: truncated by a hand edit,
13518
+ // a path outside `public/`, a `..` segment — or written by a core version
13519
+ // whose format `splitRemoteRef` rejects.
13520
+ //
13521
+ // Still not a path in the working tree, so the on-disk checks must not run
13522
+ // against it. They would find nothing at `<projectRoot>/https://…`, report
13523
+ // the entry as missing, and `--fix` would DELETE it — the exact failure
13524
+ // this module exists to prevent, arriving at the one moment the key is the
13525
+ // only remaining record of where the bytes went.
13526
+ //
13527
+ // Nothing is being hidden by this: a malformed key is already reported, by
13528
+ // the record schema's own "Invalid remote URL format". That is an error for
13529
+ // a person to look at, not a file for `--fix` to go missing.
13530
+ return {
13531
+ localPath: key,
13532
+ remote: true
13533
+ };
13534
+ }
13535
+ return {
13536
+ localPath: key,
13537
+ remote: false
13538
+ };
13539
+ }
13540
+
13475
13541
  /**
13476
13542
  * The media path a validation error is about, if it is about media at all.
13477
13543
  *
@@ -13667,14 +13733,6 @@ async function downloadFileFromRemote(ref, filePath) {
13667
13733
  // record-level fix expands into per-entry errors that should point at the
13668
13734
  // individual entry (e.g. `?p="/public/val/logo.png"`) rather than the record.
13669
13735
 
13670
- // Gallery entries are keyed by their file path. Remote galleries key uploaded
13671
- // entries by a remote URL while keeping the file on disk at its local path, so
13672
- // normalize a remote-URL key back to that local path for on-disk reads.
13673
- function galleryKeyToLocalPath(key) {
13674
- const res = Internal.remote.splitRemoteRef(key);
13675
- return res.status === "success" ? `/${res.filePath}` : key;
13676
- }
13677
-
13678
13736
  // Patch path of the record that holds a media entry. Media keys are file paths
13679
13737
  // that can contain dots, which sourceToPatchPath cannot round-trip, so strip
13680
13738
  // the (JSON-encoded) key segment off the entry source path and convert the
@@ -13983,7 +14041,18 @@ async function createFixPatch(config, apply, sourcePath, validationError, remote
13983
14041
  }
13984
14042
  const gallerySource = moduleSource;
13985
14043
  for (const [entryKey, storedEntry] of Object.entries(gallerySource)) {
13986
- const filename = path__default.join(config.projectRoot, galleryKeyToLocalPath(entryKey));
14044
+ const entry = galleryEntryOf(entryKey);
14045
+ if (entry.remote) {
14046
+ // A remote entry's bytes are on the content host, so there may be no
14047
+ // local file to re-derive its metadata from — and where there is one,
14048
+ // rewriting the metadata from it would invalidate the ref, which has
14049
+ // that metadata baked into its validation hash. Whether a remote
14050
+ // entry is sound is `image:check-remote`'s question, and it already
14051
+ // asks it. Reading the file here reported every published remote
14052
+ // image as unreadable, and with `--fix` REMOVED it from the gallery.
14053
+ continue;
14054
+ }
14055
+ const filename = path__default.join(config.projectRoot, entry.localPath);
13987
14056
  let buffer;
13988
14057
  try {
13989
14058
  buffer = fs.readFileSync(filename);
@@ -14649,15 +14718,48 @@ async function handleUniqueFolderCheck(ctx) {
14649
14718
  };
14650
14719
  }
14651
14720
 
14652
- // Maps a gallery key to its on-disk local path. Remote galleries key uploaded
14653
- // entries by a remote URL while keeping the file on disk; everything else is
14654
- // already a local path and is returned unchanged.
14655
- function remoteKeyToLocalPath(key) {
14656
- const remoteRefRes = Internal.remote.splitRemoteRef(key);
14657
- if (remoteRefRes.status === "success") {
14658
- return `/${remoteRefRes.filePath}`;
14721
+ /**
14722
+ * What is out of step between a gallery's entries and its directory.
14723
+ *
14724
+ * Two questions, and they treat a remote entry differently — which is the whole
14725
+ * reason this is separate from the handler around it:
14726
+ *
14727
+ * - **Missing**: an entry with no bytes at its local path. Asked of LOCAL
14728
+ * entries only. A remote entry's bytes live on the content host, and nothing
14729
+ * puts a copy in the working tree: `saveOrUploadFiles` uploads the remote
14730
+ * descriptors and copies only the local ones into the tree, so a remote entry
14731
+ * added through the Studio (or over MCP) has no local file by design, and
14732
+ * demanding one would mean committing remote bytes to git — which is what
14733
+ * remote storage exists to avoid. Whether those bytes really are on the host
14734
+ * is a different check, `image:check-remote`, which already runs for exactly
14735
+ * these entries.
14736
+ * - **Untracked**: a file in the directory that no entry claims. Asked of every
14737
+ * entry, remote included, and that is why they are normalised to their local
14738
+ * path: `val validate --fix` promotes a local file to a remote ref and leaves
14739
+ * the file where it was, so a remote entry can perfectly well have one.
14740
+ */
14741
+ function checkGalleryFiles(input) {
14742
+ const {
14743
+ directory,
14744
+ projectRoot,
14745
+ fs
14746
+ } = input;
14747
+ const entries = input.entryKeys.map(galleryEntryOf);
14748
+ const trackedFiles = new Set(entries.map(entry => entry.localPath));
14749
+ const missingTrackedFiles = entries.filter(entry => !entry.remote && !fs.fileExists(path__default.join(projectRoot, entry.localPath))).map(entry => entry.localPath);
14750
+ const filesInDir = [];
14751
+ try {
14752
+ const found = fs.readDirectory(path__default.join(projectRoot, directory), undefined, undefined, ["**/*"]);
14753
+ for (const entry of found) {
14754
+ filesInDir.push("/" + path__default.relative(projectRoot, entry).split(path__default.sep).join("/"));
14755
+ }
14756
+ } catch {
14757
+ // directory doesn't exist — no untracked files possible
14659
14758
  }
14660
- return key;
14759
+ return {
14760
+ missingTrackedFiles,
14761
+ untrackedFiles: filesInDir.filter(f => !trackedFiles.has(f))
14762
+ };
14661
14763
  }
14662
14764
  async function handleCheckAllFiles(ctx) {
14663
14765
  const value = ctx.validationError.value;
@@ -14677,14 +14779,14 @@ async function handleCheckAllFiles(ctx) {
14677
14779
  errorMessage: `Could not get source for ${ctx.sourcePath}`
14678
14780
  };
14679
14781
  }
14680
- // Gallery entries are keyed by their file path. Remote galleries key uploaded
14681
- // entries by a remote URL, but the file is kept on disk at its local path, so
14682
- // normalize remote-URL keys back to that local path for the on-disk checks.
14683
- const trackedFiles = new Set(Object.keys(source).map(remoteKeyToLocalPath));
14684
-
14685
- // Check that all tracked files exist on disk
14686
- const missingTrackedFiles = Array.from(trackedFiles).filter(f => {
14687
- return !ctx.fs.fileExists(path__default.join(ctx.projectRoot, f));
14782
+ const {
14783
+ missingTrackedFiles,
14784
+ untrackedFiles
14785
+ } = checkGalleryFiles({
14786
+ entryKeys: Object.keys(source),
14787
+ directory,
14788
+ projectRoot: ctx.projectRoot,
14789
+ fs: ctx.fs
14688
14790
  });
14689
14791
  if (missingTrackedFiles.length > 0) {
14690
14792
  if (!ctx.fix) {
@@ -14699,18 +14801,6 @@ async function handleCheckAllFiles(ctx) {
14699
14801
  shouldApplyPatch: true
14700
14802
  };
14701
14803
  }
14702
- const dirPath = path__default.join(ctx.projectRoot, directory);
14703
- const filesInDir = [];
14704
- try {
14705
- const entries = ctx.fs.readDirectory(dirPath, undefined, undefined, ["**/*"]);
14706
- for (const entry of entries) {
14707
- const relPath = "/" + path__default.relative(ctx.projectRoot, entry).split(path__default.sep).join("/");
14708
- filesInDir.push(relPath);
14709
- }
14710
- } catch {
14711
- // directory doesn't exist — no untracked files possible
14712
- }
14713
- const untrackedFiles = filesInDir.filter(f => !trackedFiles.has(f));
14714
14804
  if (untrackedFiles.length > 0) {
14715
14805
  return {
14716
14806
  success: false,
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.123.0",
19
+ "version": "0.123.2",
20
20
  "devDependencies": {
21
21
  "@prettier/sync": "^0.6.1",
22
22
  "@types/jest": "^30.0.0"
@@ -29,9 +29,9 @@
29
29
  "typescript": "^6.0.3",
30
30
  "zod": "^4.4.3",
31
31
  "zod-validation-error": "^5.0.0",
32
+ "@valbuild/shared": "0.123.2",
32
33
  "@valbuild/core": "0.121.0",
33
- "@valbuild/shared": "0.123.0",
34
- "@valbuild/ui": "0.123.0"
34
+ "@valbuild/ui": "0.123.2"
35
35
  },
36
36
  "engines": {
37
37
  "node": "^20.19.0 || >=22"