@valbuild/server 0.132.1 → 0.134.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.
@@ -4313,7 +4313,25 @@ class ValOps {
4313
4313
  };
4314
4314
  };
4315
4315
  const patchedJsonEntries = {};
4316
- const allResults = await Promise.all(Object.entries(patchesByModule).map(([path, patches]) => applySourceFilePatches(path, patches)));
4316
+ /*
4317
+ * Patching the FILE TEXT, for a store that has somewhere to put it.
4318
+ *
4319
+ * Where there is nowhere -- an http project with no repository -- the
4320
+ * patches are still all applied, because they are: `getSources(analysis)`
4321
+ * below applies them to Source, and that is what the commit records and
4322
+ * what every reader of this project sees. What is skipped is rendering
4323
+ * that data back out as `.val.ts` and `*.val.json`, which would be a
4324
+ * mirror with no repository to mirror into -- and which cannot even be
4325
+ * attempted, because producing it starts by READING the current text from
4326
+ * the repository that is not there.
4327
+ */
4328
+ const allResults = await Promise.all(Object.entries(patchesByModule).map(([path, patches]) => this.mirrorsSourceFiles ? applySourceFilePatches(path, patches) : Promise.resolve({
4329
+ path: path,
4330
+ appliedPatches: patches.map(p => p.patchId),
4331
+ result: null,
4332
+ extraFiles: {},
4333
+ jsonEntries: {}
4334
+ })));
4317
4335
  let hasErrors = false;
4318
4336
  const sourceFilePatchErrors = {};
4319
4337
  const appliedPatches = {};
@@ -4488,6 +4506,46 @@ class ValOps {
4488
4506
  });
4489
4507
  }
4490
4508
 
4509
+ /**
4510
+ * Why a publish cannot happen here, or `null` when one can.
4511
+ *
4512
+ * Named rather than thrown, and asked BEFORE the click: the Studio shows the
4513
+ * reason and disables the action, instead of letting someone write a commit
4514
+ * message and then meeting a failure from four layers down.
4515
+ *
4516
+ * `no-base` is the only code so far and means what it says: there is nowhere
4517
+ * for this publish's commit to be based. Note which way round that is --
4518
+ * a project whose content service is the store of record always HAS a base
4519
+ * (the service's own chain, which mints its own shas), so the refusal is not
4520
+ * about missing git. It is about a deployment that cannot do what its
4521
+ * project requires.
4522
+ */
4523
+ publishRefusal() {
4524
+ return null;
4525
+ }
4526
+
4527
+ /**
4528
+ * Whether a commit here produces `.val.ts` TEXT as well as data.
4529
+ *
4530
+ * True everywhere there is somewhere to put it: a working tree in `fs` mode,
4531
+ * a host holding its own source in memory mode, a git repository in `http`
4532
+ * mode. False for an `http` project whose content service is the store of
4533
+ * record and which has no repository attached -- see `git` on
4534
+ * {@link ValApiOptions}.
4535
+ *
4536
+ * WHAT IS NOT AFFECTED, and it is the part worth being sure of:
4537
+ * `moduleVersions` -- what each changed module IS after the commit, with its
4538
+ * schema -- comes from `getSources(analysis)`, which applies the ops to
4539
+ * Source in the stores. It does not go near the file text. So a commit with
4540
+ * no mirror still records everything history and a later `connect-github`
4541
+ * fold need; what it does not record is a rendering of that data as code.
4542
+ *
4543
+ * WHAT IS: the ops are no longer applied to the file text as well, so a
4544
+ * patch that would not fit the `.val.ts` is not reported here. That check
4545
+ * only ever existed for the text being produced, and there is none.
4546
+ */
4547
+ mirrorsSourceFiles = true;
4548
+
4491
4549
  /**
4492
4550
  * Take the `.val.ts` text a commit produced as the new committed source.
4493
4551
  *
@@ -4652,6 +4710,16 @@ function formatPatchSourceError(error) {
4652
4710
  return "Unknown patch source error: " + JSON.stringify(_exhaustiveCheck);
4653
4711
  }
4654
4712
  }
4713
+
4714
+ /**
4715
+ * Why a publish is refused, in a form a person can be shown.
4716
+ *
4717
+ * `code` is for the Studio to branch on and `message` is what it says. Both,
4718
+ * rather than a code and a lookup table on the client: the server knows what
4719
+ * is actually missing -- which branch, which commit -- and a client-side table
4720
+ * could only ever say the generic version.
4721
+ */
4722
+
4655
4723
  /**
4656
4724
  * The patches a json entry render should apply, out of the whole chain.
4657
4725
  *
@@ -7491,8 +7559,14 @@ const GetApplicablePatches = z.object({
7491
7559
  })),
7492
7560
  commits: z.array(z.object({
7493
7561
  commitSha: z.string(),
7494
- clientCommitSha: z.string(),
7495
- parentCommitSha: z.string(),
7562
+ /*
7563
+ * Nullable since the content service mints its own commit shas: a
7564
+ * root commit has no parent, and a publisher that did not say where
7565
+ * it was has no client sha. Parsed rather than trusted, so a service
7566
+ * that sends null gets null here instead of failing the whole poll.
7567
+ */
7568
+ clientCommitSha: z.string().nullable(),
7569
+ parentCommitSha: z.string().nullable(),
7496
7570
  commitMessage: z.string().nullable(),
7497
7571
  branch: z.string(),
7498
7572
  creator: z.string(),
@@ -7508,7 +7582,21 @@ const GetApplicablePatches = z.object({
7508
7582
  // `ValDeployment`: this is the only source for a deployment that Val
7509
7583
  // did not publish, and zod would otherwise strip it here.
7510
7584
  commitMessage: z.string().nullable().optional()
7511
- })).optional()
7585
+ })).optional(),
7586
+ /**
7587
+ * What the project EXPECTS of whoever publishes it.
7588
+ *
7589
+ * Optional because a content service that predates it sends nothing, and
7590
+ * absent means "not reported" -- never "managed". The difference decides
7591
+ * whether a publish is refused, so guessing either way would be worse than
7592
+ * not checking: guessing `managed` would let a deployment publish a
7593
+ * connected project it cannot mirror, and guessing `connected` would refuse
7594
+ * every publish against an older service.
7595
+ */
7596
+ project: z.object({
7597
+ sourceMode: z.union([z.literal("managed"), z.literal("connected")]),
7598
+ branch: z.string()
7599
+ }).optional()
7512
7600
  });
7513
7601
  const FilesResponse = z.object({
7514
7602
  files: z.array(z.union([z.object({
@@ -7581,8 +7669,10 @@ const CommitResponse = z.object({
7581
7669
  // commit changed nothing", which is indistinguishable from a real answer.
7582
7670
  const HistoricalCommitResponse = z.object({
7583
7671
  commitSha: z.string(),
7584
- parentCommitSha: z.string(),
7585
- clientCommitSha: z.string(),
7672
+ /** `null` for a root commit; see the note on the applicable-patches schema. */
7673
+ parentCommitSha: z.string().nullable(),
7674
+ /** `null` when the publisher did not say where it was. */
7675
+ clientCommitSha: z.string().nullable(),
7586
7676
  branch: z.string(),
7587
7677
  createdBranch: z.string().nullable(),
7588
7678
  creator: z.string().nullable(),
@@ -7600,8 +7690,8 @@ const CommitPatchesResponse = z.object({
7600
7690
  commitSha: z.string(),
7601
7691
  commit: z.object({
7602
7692
  commitSha: z.string(),
7603
- parentCommitSha: z.string(),
7604
- clientCommitSha: z.string(),
7693
+ parentCommitSha: z.string().nullable(),
7694
+ clientCommitSha: z.string().nullable(),
7605
7695
  branch: z.string(),
7606
7696
  createdBranch: z.string().nullable(),
7607
7697
  creator: z.string().nullable(),
@@ -7701,9 +7791,45 @@ class ValOpsHttp extends ValOps {
7701
7791
  patchesAreLocal = false;
7702
7792
  /** See {@link ValOps.requiresAuth}. */
7703
7793
  requiresAuth = true;
7704
- constructor(contentUrl, project, commitSha,
7705
- // TODO: CommitSha
7706
- branch,
7794
+ /**
7795
+ * A commit mirrors into `.val.ts` only when there is a repository.
7796
+ *
7797
+ * See {@link ValOps.mirrorsSourceFiles}. Set in the constructor rather than
7798
+ * as an initialiser because it depends on `git`, and a class field
7799
+ * initialiser runs before the constructor body has assigned it.
7800
+ */
7801
+ mirrorsSourceFiles;
7802
+ /**
7803
+ * What the content service last said this project expects of its publisher.
7804
+ *
7805
+ * `null` until something has asked it, which in practice is the first poll.
7806
+ * It is remembered rather than asked for on demand because the answer is
7807
+ * only wanted on the publish path, and that path already fetches the
7808
+ * patches it is publishing -- so a dedicated request would be a second round
7809
+ * trip for two fields that just arrived.
7810
+ *
7811
+ * It can be one poll out of date, and that is the right amount: the thing it
7812
+ * changes is whether this deployment can mirror commits into a repository,
7813
+ * which changes when a project CONNECTS one -- and a project that has just
7814
+ * connected is one whose builds are about to be replaced anyway.
7815
+ */
7816
+ projectExpectation = null;
7817
+ constructor(contentUrl, project,
7818
+ /**
7819
+ * The repository this project's commits are mirrored into, or `null`.
7820
+ *
7821
+ * `null` is a project whose content service is the store of record: it
7822
+ * mints its own commit shas, and it knows this project's branch from the
7823
+ * project itself. Every request below that would have carried a branch and
7824
+ * a commit omits them instead, and the service answers from the project's
7825
+ * own chain -- which is where those answers always came from.
7826
+ *
7827
+ * It is NOT a degraded mode. The one thing that genuinely needs a
7828
+ * repository is producing the `.val.ts` text a commit mirrors, and a
7829
+ * project with no repository has nothing to mirror into. See `git` on
7830
+ * {@link ValApiOptions}.
7831
+ */
7832
+ git,
7707
7833
  /**
7708
7834
  * An api key (how the app itself authenticates) or a personal access token
7709
7835
  * (how the CLI authenticates after `val login`). Same two shapes as
@@ -7713,14 +7839,49 @@ class ValOpsHttp extends ValOps {
7713
7839
  super(valModules, options);
7714
7840
  this.contentUrl = contentUrl;
7715
7841
  this.project = project;
7716
- this.commitSha = commitSha;
7717
- this.branch = branch;
7842
+ this.git = git;
7718
7843
  this.authHeaders = "pat" in auth ? {
7719
7844
  "x-val-pat": auth.pat
7720
7845
  } : {
7721
7846
  Authorization: `Bearer ${auth.apiKey}`
7722
7847
  };
7723
7848
  this.root = (options === null || options === void 0 ? void 0 : options.root) ?? "";
7849
+ this.mirrorsSourceFiles = git !== null;
7850
+ }
7851
+ /**
7852
+ * A deployment that cannot mirror a project which expects to be mirrored.
7853
+ *
7854
+ * This is the one shape of "no base" that exists, and it is not the one it
7855
+ * sounds like. A project with no repository is fine: the content service is
7856
+ * the store of record for it and mints its own commit shas, so there is
7857
+ * always somewhere for the commit to go. What is refused is the mismatch --
7858
+ * a project whose commits are mirrored into a repository, being published by
7859
+ * a build that was made before it had one and so has no commit to produce
7860
+ * that mirror against.
7861
+ *
7862
+ * It is a REAL state rather than a defensive check: it is exactly what a
7863
+ * deployment looks like between a project connecting a repository and its
7864
+ * next build going out. Left unchecked, such a publish writes the data and
7865
+ * silently fails to mirror it, and the repository quietly falls behind the
7866
+ * content nobody is told about.
7867
+ *
7868
+ * `null` when the service did not say (see `project` on the response
7869
+ * schema): an older content service is not evidence of anything, and
7870
+ * refusing every publish against one would be a worse failure than not
7871
+ * checking.
7872
+ */
7873
+ publishRefusal() {
7874
+ var _this$projectExpectat;
7875
+ if (this.git !== null) {
7876
+ return null;
7877
+ }
7878
+ if (((_this$projectExpectat = this.projectExpectation) === null || _this$projectExpectat === void 0 ? void 0 : _this$projectExpectat.sourceMode) !== "connected") {
7879
+ return null;
7880
+ }
7881
+ return {
7882
+ code: "no-base",
7883
+ message: `This project mirrors its content into a git repository (branch ` + `'${this.projectExpectation.branch}'), but this deployment was not ` + "built from one, so it does not know which commit to write that " + "mirror against. Publishing would save the content and silently " + "leave the repository behind. Deploy this project again from its " + "repository, and publishing will work from that build on."
7884
+ };
7724
7885
  }
7725
7886
  async onInit() {
7726
7887
  // TODO: unused for now. Implement or remove
@@ -7911,16 +8072,31 @@ class ValOpsHttp extends ValOps {
7911
8072
  * decided against.
7912
8073
  */
7913
8074
  headCommitSha: newestCommitSha(allPatchData.commits) ?? undefined,
7914
- commitSha: this.commitSha
8075
+ /*
8076
+ * Spread: a project with no repository has no such commit, and saying
8077
+ * so by leaving the key out is different from sending an empty string
8078
+ * the Studio would try to show.
8079
+ */
8080
+ ...(this.git ? {
8081
+ commitSha: this.git.commit
8082
+ } : {})
7915
8083
  };
7916
8084
  }
7917
8085
  async getWebSocketNonce(profileId) {
7918
8086
  return fetch(`${this.contentUrl}/v1/${this.project}/websocket/nonces`, {
7919
8087
  method: "POST",
7920
8088
  body: JSON.stringify({
7921
- branch: this.branch,
7922
8089
  profileId,
7923
- commitSha: this.commitSha
8090
+ /*
8091
+ * Omitted when there is no repository, like every other request here.
8092
+ * The content service knows this project's branch -- it is a column on
8093
+ * the project -- and a nonce is scoped to the project and the person,
8094
+ * not to a position in a chain.
8095
+ */
8096
+ ...(this.git ? {
8097
+ branch: this.git.branch,
8098
+ commitSha: this.git.commit
8099
+ } : {})
7924
8100
  }),
7925
8101
  headers: {
7926
8102
  ...this.authHeaders,
@@ -8068,8 +8244,20 @@ class ValOpsHttp extends ValOps {
8068
8244
  }
8069
8245
  async fetchPatchesInternal(filters) {
8070
8246
  const params = [];
8071
- params.push(["branch", this.branch]);
8072
- params.push(["commit", this.commitSha]);
8247
+ /*
8248
+ * A position in the chain, WHEN THIS BUILD HAS ONE.
8249
+ *
8250
+ * `commit` tells the content service where this deployment sits, so it can
8251
+ * answer with the commits at or after that point. A build with no
8252
+ * repository has no such commit baked into it, and asking the service to
8253
+ * resolve the position itself is strictly better than inventing one: it
8254
+ * holds the chain, and for a project it is the only publisher of, the
8255
+ * position IS the head.
8256
+ */
8257
+ if (this.git) {
8258
+ params.push(["branch", this.git.branch]);
8259
+ params.push(["commit", this.git.commit]);
8260
+ }
8073
8261
  if (filters.patchIds) {
8074
8262
  for (const patchId of filters.patchIds) {
8075
8263
  params.push(["patch_id", patchId]);
@@ -8090,6 +8278,17 @@ class ValOpsHttp extends ValOps {
8090
8278
  if (parsed.success) {
8091
8279
  const errors = [];
8092
8280
  const data = parsed.data;
8281
+ /*
8282
+ * What the project expects of its publisher, remembered.
8283
+ *
8284
+ * Recorded here rather than returned because every caller of this
8285
+ * already has what it needs and only the publish path asks the
8286
+ * question -- and that path calls this first. See
8287
+ * {@link publishRefusal}.
8288
+ */
8289
+ if (data.project) {
8290
+ this.projectExpectation = data.project;
8291
+ }
8093
8292
  for (const patchesRes of data.patches) {
8094
8293
  var _patchesRes$applied;
8095
8294
  patches.push({
@@ -8310,7 +8509,7 @@ class ValOpsHttp extends ValOps {
8310
8509
  * `saveSourceFilePatch`): groups are per branch, so a request without one
8311
8510
  * is not merely under-specified, it is rejected.
8312
8511
  */
8313
- const params = new URLSearchParams([["branch", this.branch]]);
8512
+ const params = new URLSearchParams(this.git ? [["branch", this.git.branch]] : []);
8314
8513
  const res = await fetch(`${this.contentUrl}/v1/${this.project}/patch-groups?${params}`, {
8315
8514
  headers: this.authHeaders
8316
8515
  });
@@ -8462,8 +8661,10 @@ class ValOpsHttp extends ValOps {
8462
8661
  patchId,
8463
8662
  parentPatchId: parentRef.type === "patch" ? parentRef.patchId : null,
8464
8663
  baseSha,
8465
- commit: this.commitSha,
8466
- branch: this.branch,
8664
+ ...(this.git ? {
8665
+ commit: this.git.commit,
8666
+ branch: this.git.branch
8667
+ } : {}),
8467
8668
  coreVersion: Internal.VERSION.core,
8468
8669
  /*
8469
8670
  * Group membership in the SAME request as the patch.
@@ -8629,11 +8830,28 @@ class ValOpsHttp extends ValOps {
8629
8830
  });
8630
8831
  }
8631
8832
  async getSourceFile(path) {
8833
+ /*
8834
+ * There is no file to read without a repository to read it from.
8835
+ *
8836
+ * Reachable only through the CLI's debug snapshot now: the publish path
8837
+ * does not call this for such a project at all -- see
8838
+ * {@link ValOps.mirrorsSourceFiles} -- because there is nothing for it to
8839
+ * produce. It is an error rather than an empty string because an empty
8840
+ * `.val.ts` would be patched successfully and committed as a module that
8841
+ * had lost all its content.
8842
+ */
8843
+ if (!this.git) {
8844
+ return {
8845
+ error: {
8846
+ message: `Cannot read the source of ${path}: this project has no ` + "repository, so there is no `.val.ts` to read. Its content lives " + "in Val's content service, which is the store of record for it."
8847
+ }
8848
+ };
8849
+ }
8632
8850
  const filesRes = await this.getHttpFiles([{
8633
8851
  filePath: path,
8634
8852
  location: "repo",
8635
8853
  root: this.root,
8636
- commitSha: this.commitSha
8854
+ commitSha: this.git.commit
8637
8855
  }]);
8638
8856
  if (filesRes.error) {
8639
8857
  return filesRes;
@@ -8653,11 +8871,22 @@ class ValOpsHttp extends ValOps {
8653
8871
  async getBinaryFile(filePath) {
8654
8872
  // We could also just get this from public/ on the running server. Current approach feels more clean, but will be slower / puts more server load... We might want to change this
8655
8873
  const requestFiles = [];
8874
+ if (!this.git) {
8875
+ /*
8876
+ * A published binary lives in the repository, and there is none.
8877
+ *
8878
+ * `null` is this method's existing "not found", which is what a caller
8879
+ * already handles: the Studio falls back to the patch's own copy, which
8880
+ * is where a managed project's files stay. See the note on local files
8881
+ * in the content service's commit handler.
8882
+ */
8883
+ return null;
8884
+ }
8656
8885
  requestFiles.push({
8657
8886
  filePath: filePath,
8658
8887
  location: "repo",
8659
8888
  root: this.root,
8660
- commitSha: this.commitSha
8889
+ commitSha: this.git.commit
8661
8890
  });
8662
8891
  const filesRes = await this.getHttpFiles(requestFiles);
8663
8892
  if (filesRes.error) {
@@ -8836,7 +9065,6 @@ class ValOpsHttp extends ValOps {
8836
9065
  patchGroupId) {
8837
9066
  try {
8838
9067
  var _res$headers$get3;
8839
- const existingBranch = this.branch;
8840
9068
  const res = await fetch(`${this.contentUrl}/v1/${this.project}/commit`, {
8841
9069
  method: "POST",
8842
9070
  headers: {
@@ -8880,13 +9108,29 @@ class ValOpsHttp extends ValOps {
8880
9108
  * moved on.
8881
9109
  */
8882
9110
  modules: prepared.moduleVersions,
8883
- commit: this.commitSha,
9111
+ /*
9112
+ * WHERE THIS BUILD THOUGHT IT WAS -- and only for a project with a
9113
+ * repository, which is the only thing that still reads it.
9114
+ *
9115
+ * The content service mints its own commit shas, so this is no
9116
+ * longer the parent, and it never was the concurrency check: that is
9117
+ * `expectedHeadCommitSha` against `newestCommitSha(patches.commits)`,
9118
+ * which `/save` runs before it gets here. What is left is the git
9119
+ * fast-forward check, which needs to know the commit this build read
9120
+ * the branch at to tell whether the branch has moved under it.
9121
+ *
9122
+ * The branch goes with it. The project's branch is a column on the
9123
+ * project, so a build asserting one could only ever disagree with
9124
+ * the project it is publishing to.
9125
+ */
9126
+ ...(this.git ? {
9127
+ commit: this.git.commit
9128
+ } : {}),
8884
9129
  root: this.root,
8885
9130
  filesDirectory,
8886
9131
  baseSha: await this.getBaseSha(),
8887
9132
  committer,
8888
9133
  message,
8889
- existingBranch,
8890
9134
  newBranch,
8891
9135
  ...(patchGroupId !== undefined ? {
8892
9136
  patchGroupId
@@ -10648,13 +10892,42 @@ async function initHandlerOptions(route, opts, config) {
10648
10892
  if (!maybeApiKey || !maybeValSecret) {
10649
10893
  throw new Error("VAL_API_KEY and VAL_SECRET env vars must both be set in proxy mode" + because);
10650
10894
  }
10895
+ /*
10896
+ * A COMMIT IS NOT WHAT PUTS AN APP IN HTTP MODE. Credentials are.
10897
+ *
10898
+ * Both of these used to be required here, and the requirement was a
10899
+ * repository disguised as a configuration check: a deployment with no
10900
+ * commit to name -- one whose content service owns its content, which is
10901
+ * now the normal case -- threw at boot, or, worse, never reached this
10902
+ * branch at all and fell through to `fs` mode, looking for a working tree
10903
+ * that was not there.
10904
+ *
10905
+ * Absent is a project with no repository to mirror commits into. The
10906
+ * content service mints its own commit shas and is the store of record for
10907
+ * content, so there is nothing missing: see `git` on {@link ValApiOptions}
10908
+ * for what a commit is still FOR where there is one.
10909
+ *
10910
+ * Taken together or not at all. A commit without a branch names a point
10911
+ * with no line of work to publish to, and a branch without a commit names
10912
+ * a line with no position in it; either alone would be a half-configured
10913
+ * repository that fails later, at a publish, rather than here.
10914
+ *
10915
+ * ONE NAME, wherever it comes from. `opts` is the bindings'
10916
+ * `{ versions, ...config }`, so `opts.gitCommit` IS `val.config.ts`'s
10917
+ * `gitCommit` -- there is nothing to map and no second place to look.
10918
+ *
10919
+ * It used to be a nested `git: { commit, branch }` here and two flat keys
10920
+ * in `ValConfig`, and the two never met: nothing mapped one onto the
10921
+ * other, so a project that set the documented config keys -- which is
10922
+ * what every Vercel deployment does, from `VERCEL_GIT_COMMIT_SHA` --
10923
+ * resolved to no repository at all. Nothing failed and nothing was
10924
+ * logged; the commit was simply never sent, and every patch it saved
10925
+ * recorded none.
10926
+ */
10651
10927
  const maybeGitCommit = opts.gitCommit || process.env.VAL_GIT_COMMIT;
10652
- if (!maybeGitCommit) {
10653
- throw new Error("VAL_GIT_COMMIT env var must be set in proxy mode" + because);
10654
- }
10655
10928
  const maybeGitBranch = opts.gitBranch || process.env.VAL_GIT_BRANCH;
10656
- if (!maybeGitBranch) {
10657
- throw new Error("VAL_GIT_BRANCH env var must be set in proxy mode" + because);
10929
+ if (!!maybeGitCommit !== !!maybeGitBranch) {
10930
+ throw new Error(`Val is configured with a git ${maybeGitCommit ? "commit" : "branch"} ` + `but no ${maybeGitCommit ? "branch" : "commit"}. Set both ` + "(`gitCommit` and `gitBranch` in val.config.ts, or VAL_GIT_COMMIT " + "and VAL_GIT_BRANCH) for a project whose content is mirrored into " + "a repository, or neither for one whose content service is the " + "store of record." + because);
10658
10931
  }
10659
10932
  if (!maybeValProject) {
10660
10933
  throw new Error("Proxy mode does not work unless the 'project' option in val.config is defined or the VAL_PROJECT env var is set." + because);
@@ -10672,8 +10945,19 @@ async function initHandlerOptions(route, opts, config) {
10672
10945
  route,
10673
10946
  apiKey: maybeApiKey,
10674
10947
  valSecret: maybeValSecret,
10675
- commit: maybeGitCommit,
10676
- branch: maybeGitBranch,
10948
+ /*
10949
+ * Spread, so a project with no repository has no `git` key at all rather
10950
+ * than one holding undefined. `ValOpsHttp` asks `git === null` to decide
10951
+ * whether to send a branch and a commit with every request, and a key
10952
+ * that is present-but-undefined is one more thing for that check to get
10953
+ * wrong.
10954
+ */
10955
+ ...(maybeGitCommit && maybeGitBranch ? {
10956
+ git: {
10957
+ commit: maybeGitCommit,
10958
+ branch: maybeGitBranch
10959
+ }
10960
+ } : {}),
10677
10961
  root: opts.root,
10678
10962
  project: maybeValProject,
10679
10963
  valEnableRedirectUrl,
@@ -10736,7 +11020,7 @@ function createValOps(valModules, options) {
10736
11020
  });
10737
11021
  }
10738
11022
  if (options.mode === "http") {
10739
- return new ValOpsHttp(options.valContentUrl, options.project, options.commit, options.branch, {
11023
+ return new ValOpsHttp(options.valContentUrl, options.project, options.git ?? null, {
10740
11024
  apiKey: options.apiKey
10741
11025
  }, valModules, {
10742
11026
  formatter: options.formatter,
@@ -10942,6 +11226,7 @@ async function resolveRemoteFileAuth(options) {
10942
11226
  /** What a publish reports back, in either shape. */
10943
11227
 
10944
11228
  const ValServer = (valModules, options, callbacks) => {
11229
+ var _options$git;
10945
11230
  const AIContentBlock = z.union([z.object({
10946
11231
  type: z.literal("text"),
10947
11232
  text: z.string()
@@ -10973,7 +11258,7 @@ const ValServer = (valModules, options, callbacks) => {
10973
11258
  url.searchParams.set("state", token);
10974
11259
  return url.toString();
10975
11260
  };
10976
- const commit = options.mode === "http" ? options.commit : undefined;
11261
+ const commit = options.mode === "http" ? (_options$git = options.git) === null || _options$git === void 0 ? void 0 : _options$git.commit : undefined;
10977
11262
  const getAppErrorUrl = error => {
10978
11263
  if (!options.project) {
10979
11264
  throw new Error("Project is not set");
@@ -11570,12 +11855,24 @@ const ValServer = (valModules, options, callbacks) => {
11570
11855
  * start against a server that was working.
11571
11856
  */
11572
11857
  const mode = serverOps.patchesAreLocal ? "fs" : "http";
11858
+ /*
11859
+ * Why publishing is unavailable, so the Studio can say so.
11860
+ *
11861
+ * Spread rather than set to null, so a server that can publish sends
11862
+ * no such key at all: the Studio's "can I publish" is then the
11863
+ * presence of the field, and there is no null to mistake for a reason
11864
+ * it failed to compute.
11865
+ */
11866
+ const publishRefusal = serverOps.publishRefusal();
11573
11867
  return {
11574
11868
  status: 200,
11575
11869
  json: {
11576
11870
  ...currentStat,
11577
11871
  profileId: profileId ?? null,
11578
11872
  mode,
11873
+ ...(publishRefusal ? {
11874
+ publishRefusal
11875
+ } : {}),
11579
11876
  config: options.config
11580
11877
  }
11581
11878
  };
@@ -12764,6 +13061,29 @@ const ValServer = (valModules, options, callbacks) => {
12764
13061
  patchIds,
12765
13062
  excludePatchOps: false
12766
13063
  });
13064
+ /*
13065
+ * Can this deployment publish this project AT ALL?
13066
+ *
13067
+ * After the fetch above, deliberately: that call is what tells the
13068
+ * data layer what the project expects, and asking before it would be
13069
+ * asking a question nothing has answered yet. It is still before
13070
+ * anything is written, which is the part that matters.
13071
+ *
13072
+ * The Studio already knows -- `/stat` carries the same refusal, so the
13073
+ * action is disabled with the reason shown. This is the second half of
13074
+ * that: a stat can be stale by a poll, and nothing may reach `prepare`
13075
+ * on a project it cannot mirror.
13076
+ */
13077
+ const refusal = serverOps.publishRefusal();
13078
+ if (refusal) {
13079
+ return {
13080
+ status: 409,
13081
+ json: {
13082
+ message: refusal.message,
13083
+ publishRefusal: refusal
13084
+ }
13085
+ };
13086
+ }
12767
13087
  /*
12768
13088
  * Exactly the patches this request consumes, and the ONLY ones it may
12769
13089
  * delete afterwards.
@@ -15993,6 +16313,44 @@ async function createFixPatch(config, apply, sourcePath, validationError, remote
15993
16313
  fixes: undefined
15994
16314
  });
15995
16315
  }
16316
+ } else if (fix === "view:check-module") {
16317
+ // The schema is the authority: it names the module, and there is exactly
16318
+ // one valid pointer. So this is written rather than reported — nobody has
16319
+ // a decision to make here.
16320
+ const [, modulePath] = Internal.splitModuleFilePathAndModulePath(sourcePath);
16321
+ if (moduleSource === undefined || moduleSchema === undefined) {
16322
+ remainingErrors.push({
16323
+ ...validationError,
16324
+ message: "Unexpected error while checking a view (no module source or schema)",
16325
+ fixes: undefined
16326
+ });
16327
+ continue;
16328
+ }
16329
+ const {
16330
+ schema: schemaAtPath
16331
+ } = Internal.resolvePath(modulePath, moduleSource, moduleSchema);
16332
+ if (schemaAtPath.type !== "view") {
16333
+ remainingErrors.push({
16334
+ ...validationError,
16335
+ message: `Could not fix view: schema at ${sourcePath} is '${schemaAtPath.type}', not a view`,
16336
+ fixes: undefined
16337
+ });
16338
+ continue;
16339
+ }
16340
+ if (apply) {
16341
+ patch.push({
16342
+ op: "replace",
16343
+ path: Internal.createPatchPath(modulePath),
16344
+ value: {
16345
+ view: schemaAtPath.moduleFilePath
16346
+ }
16347
+ });
16348
+ } else {
16349
+ remainingErrors.push({
16350
+ ...validationError,
16351
+ message: `This view points at the wrong module. Expected '${schemaAtPath.moduleFilePath}'.`
16352
+ });
16353
+ }
15996
16354
  }
15997
16355
  }
15998
16356
  if (!validationError.fixes || validationError.fixes.length === 0) {
@@ -16736,6 +17094,25 @@ async function handleJsonValuesExtractEntry(ctx) {
16736
17094
  * `fixableErrorMessage` rather than a plain error, because the error IS fixable
16737
17095
  * — just not by this command.
16738
17096
  */
17097
+ /**
17098
+ * A view pointer that names a module its schema does not.
17099
+ *
17100
+ * Nothing to look up and nothing to ask: the schema names the module, so the
17101
+ * one correct value is already known. `createFixPatch` writes it — this handler
17102
+ * exists to send it there, and to say what `--fix` would do when it is off.
17103
+ */
17104
+ async function handleViewCheckModule(ctx) {
17105
+ if (!ctx.fix) {
17106
+ return {
17107
+ success: true,
17108
+ fixableErrorMessage: `${ctx.validationError.message}. ` + "Run 'val validate --fix' to point it at the module its schema names."
17109
+ };
17110
+ }
17111
+ return {
17112
+ success: true,
17113
+ shouldApplyPatch: true
17114
+ };
17115
+ }
16739
17116
  async function handleExternalUpload() {
16740
17117
  return {
16741
17118
  success: true,
@@ -16767,7 +17144,8 @@ const currentFixHandlers = {
16767
17144
  "images:check-all-files": handleCheckAllFiles,
16768
17145
  "files:check-all-files": handleCheckAllFiles,
16769
17146
  "jsonValues:extract-entry": handleJsonValuesExtractEntry,
16770
- "external:upload": handleExternalUpload
17147
+ "external:upload": handleExternalUpload,
17148
+ "view:check-module": handleViewCheckModule
16771
17149
  };
16772
17150
  const deprecatedFixHandlers = {
16773
17151
  "image:replace-metadata": handleFileMetadata