@fluid-app/fluid-cli-mist 0.1.48 → 0.1.50

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/dist/index.mjs CHANGED
@@ -235,8 +235,7 @@ async function listBranchEnvironments(client, slug) {
235
235
  * the key before calling and retries with it.
236
236
  */
237
237
  async function requestStaging(client, slug, request) {
238
- if (!SOURCE_REVISION.test(request.sourceRevision)) throw new Error("Source revision must be a full 40-character commit SHA");
239
- if (!OPERATION_KEY.test(request.operationKey)) throw new Error("Operation key must be 1-128 of A-Z a-z 0-9 . _ : -");
238
+ checkStagingRequest(request.sourceRevision, request.operationKey);
240
239
  const id = await resolveMistId(client, slug);
241
240
  return parseOperationEnvelope(await client.post(`${base(id)}/branch-environments/staging`, { source_revision: request.sourceRevision }, { "X-Operation-Key": request.operationKey }));
242
241
  }
@@ -245,6 +244,20 @@ async function fetchBranchOperation(client, slug, operationId) {
245
244
  const id = await resolveMistId(client, slug);
246
245
  return parseOperationEnvelope(await client.get(`${base(id)}/branch-operations/${encodeURIComponent(operationId)}`));
247
246
  }
247
+ /**
248
+ * Refuse a revision or operation key the server would reject, before
249
+ * anything is sent or saved.
250
+ *
251
+ * Raises Error naming the malformed value.
252
+ */
253
+ function checkStagingRequest(sourceRevision, operationKey) {
254
+ if (!SOURCE_REVISION.test(sourceRevision)) throw new Error("Source revision must be a full 40-character commit SHA");
255
+ if (operationKey !== void 0 && !isOperationKey(operationKey)) throw new Error("Operation key must be 1-128 of A-Z a-z 0-9 . _ : -");
256
+ }
257
+ /** Whether the server would accept `key` as an operation key. */
258
+ function isOperationKey(key) {
259
+ return OPERATION_KEY.test(key);
260
+ }
248
261
  /** A fresh key for a new staging request. */
249
262
  function newOperationKey() {
250
263
  return `staging-${randomUUID()}`;
@@ -1005,6 +1018,64 @@ function readMistFileFrom(dir = process.cwd()) {
1005
1018
  return readMistFile(dir);
1006
1019
  }
1007
1020
  //#endregion
1021
+ //#region src/lib/staging-revision.ts
1022
+ /**
1023
+ * Pick the commit `fluid mist staging request` stages when no `--revision`
1024
+ * is given: the local checkout's HEAD, but only when nothing local would be
1025
+ * left out or lost.
1026
+ *
1027
+ * Staging can only serve a commit the mist's repository already has, and
1028
+ * this never pushes or switches branches to get one there; publishing to
1029
+ * main would ship the work to production. So the checkout must be clean
1030
+ * and its HEAD must already be on the remote's main branch.
1031
+ */
1032
+ /** The local checkout can't be staged as it is. Nothing was changed. */
1033
+ var StagingPreflightError = class extends Error {
1034
+ constructor(message) {
1035
+ super(`${message} Nothing was requested.`);
1036
+ this.name = "StagingPreflightError";
1037
+ }
1038
+ };
1039
+ /**
1040
+ * The full SHA of the checkout's HEAD once it's clean and on the remote's
1041
+ * main branch. `remoteUrl` may carry a short-lived token; it only reaches
1042
+ * git through `gitFetch`, never argv or config.
1043
+ *
1044
+ * Raises StagingPreflightError when the directory is not a git checkout,
1045
+ * git can't report its status, it has uncommitted or untracked files, or
1046
+ * HEAD is not on the remote's main.
1047
+ */
1048
+ async function resolveLocalStagingRevision(cwd, remoteUrl) {
1049
+ if (await git(cwd, ["rev-parse", "--is-inside-work-tree"]) !== "true") throw new StagingPreflightError("This directory isn't a git checkout of the mist. Run it from the mist's folder or pass --revision.");
1050
+ const status = await git(cwd, [
1051
+ "status",
1052
+ "--porcelain",
1053
+ "--untracked-files=all"
1054
+ ]);
1055
+ if (status === null) throw new StagingPreflightError("Git couldn't report whether this checkout has uncommitted changes. Pass --revision to stage a specific commit.");
1056
+ if (status.length > 0) throw new StagingPreflightError("This checkout has uncommitted or untracked changes, and staging only serves commits already on the mist's repository. Commit and publish them, or pass --revision.");
1057
+ const head = await git(cwd, ["rev-parse", "HEAD"]);
1058
+ if (!head || !/^[0-9a-f]{40}$/.test(head)) throw new StagingPreflightError("This checkout has no commits yet.");
1059
+ await gitFetch(cwd, remoteUrl, "main");
1060
+ if ((await execa("git", [
1061
+ "merge-base",
1062
+ "--is-ancestor",
1063
+ head,
1064
+ "FETCH_HEAD"
1065
+ ], {
1066
+ cwd,
1067
+ reject: false
1068
+ })).exitCode !== 0) throw new StagingPreflightError(`Commit ${head.slice(0, 7)} isn't on the mist's main branch yet, and staging only serves commits the repository already has.`);
1069
+ return head;
1070
+ }
1071
+ async function git(cwd, args) {
1072
+ const run = await execa("git", args, {
1073
+ cwd,
1074
+ reject: false
1075
+ });
1076
+ return run.exitCode === 0 ? run.stdout.trim() : null;
1077
+ }
1078
+ //#endregion
1008
1079
  //#region src/lib/staging.ts
1009
1080
  /** A staging step the user can act on, as opposed to a server fault. */
1010
1081
  var StagingUnavailableError = class extends Error {
@@ -1158,7 +1229,7 @@ function handle(err) {
1158
1229
  console.error(chalk.red(err.message));
1159
1230
  process.exit(1);
1160
1231
  }
1161
- if (err instanceof StagingUnavailableError || err instanceof StagingWaitTimeoutError) {
1232
+ if (err instanceof StagingPreflightError || err instanceof StagingUnavailableError || err instanceof StagingWaitTimeoutError) {
1162
1233
  console.error(chalk.yellow(err.message));
1163
1234
  process.exit(1);
1164
1235
  }
@@ -4166,6 +4237,110 @@ const stagingStatusCommand = new Command("status").description("Show the staging
4166
4237
  console.log(capability.staging.available ? "Staging can be requested with `fluid mist staging request --revision <sha>`." : chalk.yellow(describeUnavailable(capability.staging)));
4167
4238
  }));
4168
4239
  //#endregion
4240
+ //#region src/lib/staging-request-store.ts
4241
+ /**
4242
+ * Staging requests the server hasn't confirmed yet.
4243
+ *
4244
+ * `fluid mist staging request` saves its operation key here before it sends
4245
+ * the request and removes it once the server answers with an operation. If
4246
+ * the CLI dies in between (crash, lost connection, closed terminal), the
4247
+ * next request for the same revision reuses the saved key, so the server
4248
+ * resumes the same operation instead of starting a second one. After the
4249
+ * server has confirmed, `fluid mist staging wait` follows the operation.
4250
+ *
4251
+ * Records live under the Fluid config dir (`~/.fluid/mist/staging-requests`)
4252
+ * and hold only ids, a commit SHA and a timestamp, no tokens or secrets.
4253
+ */
4254
+ function createFileStagingRequestStore(dir = join(getConfigDir(), "mist", "staging-requests")) {
4255
+ const fileFor = (mistId) => join(dir, `${encodeURIComponent(mistId)}.json`);
4256
+ return {
4257
+ save(request) {
4258
+ mkdirSync(dir, {
4259
+ recursive: true,
4260
+ mode: 448
4261
+ });
4262
+ writeFileSync(fileFor(request.mistId), JSON.stringify(request, null, 2) + "\n", {
4263
+ encoding: "utf8",
4264
+ mode: 384
4265
+ });
4266
+ },
4267
+ load(mistId) {
4268
+ try {
4269
+ const parsed = JSON.parse(readFileSync(fileFor(mistId), "utf8"));
4270
+ return isPendingRequest(parsed) && parsed.mistId === mistId ? parsed : null;
4271
+ } catch {
4272
+ return null;
4273
+ }
4274
+ },
4275
+ remove(mistId) {
4276
+ rmSync(fileFor(mistId), { force: true });
4277
+ }
4278
+ };
4279
+ }
4280
+ /**
4281
+ * The key to send for `sourceRevision`: the saved one when an unconfirmed
4282
+ * request for the same revision is pending, otherwise none. A pending
4283
+ * request for another revision, or with a key the server would refuse, is
4284
+ * dropped; if the server did admit it, the capability check reports it as
4285
+ * the run in progress.
4286
+ */
4287
+ function pendingKeyFor(store, mistId, sourceRevision) {
4288
+ const pending = store.load(mistId);
4289
+ if (!pending) return null;
4290
+ if (pending.sourceRevision === sourceRevision && isOperationKey(pending.operationKey)) return pending.operationKey;
4291
+ store.remove(mistId);
4292
+ return null;
4293
+ }
4294
+ function isPendingRequest(value) {
4295
+ if (typeof value !== "object" || value === null) return false;
4296
+ const record = value;
4297
+ return typeof record["mistId"] === "string" && typeof record["operationKey"] === "string" && typeof record["sourceRevision"] === "string" && typeof record["requestedAt"] === "string";
4298
+ }
4299
+ /**
4300
+ * Request staging, remembering the key until the server answers. A saved
4301
+ * key for the same revision is reused, so a request the CLI never heard
4302
+ * back about resumes the same operation. The record is removed once the
4303
+ * server answers, whether it admitted the request or refused it, and kept
4304
+ * when the request may never have reached it.
4305
+ */
4306
+ async function requestStagingResumably(client, slug, request) {
4307
+ const { store, sourceRevision } = request;
4308
+ checkStagingRequest(sourceRevision, request.operationKey);
4309
+ const mistId = await resolveMistId(client, slug);
4310
+ const savedKey = request.operationKey ? null : pendingKeyFor(store, mistId, sourceRevision);
4311
+ if (savedKey) request.onResume?.(savedKey);
4312
+ try {
4313
+ const operation = await admitStaging(client, slug, {
4314
+ sourceRevision,
4315
+ operationKey: request.operationKey ?? savedKey ?? void 0,
4316
+ onOperationKey: (key) => {
4317
+ store.save({
4318
+ mistId,
4319
+ operationKey: key,
4320
+ sourceRevision,
4321
+ requestedAt: (/* @__PURE__ */ new Date()).toISOString()
4322
+ });
4323
+ request.onOperationKey?.(key);
4324
+ }
4325
+ });
4326
+ store.remove(mistId);
4327
+ return operation;
4328
+ } catch (err) {
4329
+ if (serverAnswered(err)) store.remove(mistId);
4330
+ throw err;
4331
+ }
4332
+ }
4333
+ /**
4334
+ * Whether the server's answer settled the request. A network failure, a
4335
+ * rate limit, a server error or a response the CLI can't read may come
4336
+ * after the operation was recorded, so the key is kept for those and the
4337
+ * next request replays it.
4338
+ */
4339
+ function serverAnswered(err) {
4340
+ if (err instanceof MistApiError) return err.status !== 0 && err.status !== 429 && err.status < 500;
4341
+ return err instanceof StagingOperationFailedError;
4342
+ }
4343
+ //#endregion
4169
4344
  //#region src/commands/staging/wait.ts
4170
4345
  /** `fluid mist staging wait` — follow a staging operation to the end. */
4171
4346
  const POLL_INTERVAL_MS = 5e3;
@@ -4193,13 +4368,17 @@ const stagingWaitCommand = new Command("wait").description("Follow a staging ope
4193
4368
  //#endregion
4194
4369
  //#region src/commands/staging/request.ts
4195
4370
  /** `fluid mist staging request` — request staging at a revision. */
4196
- const stagingRequestCommand = new Command("request").description("Request the staging environment at a pushed commit").argument("[slug]", "The mist slug (defaults to .mist file in CWD)").requiredOption("--revision <sha>", "Full 40-character SHA of a commit already on the mist's repository").option("--operation-key <key>", "Resume an interrupted request with the key it printed").option("--wait", "Follow the operation until staging is ready").option("--timeout <minutes>", "How long --wait follows it (default 30)").option("--json", "Emit the operation as JSON").action((slug, opts) => runAction(async () => {
4371
+ const stagingRequestCommand = new Command("request").description("Request the staging environment at a pushed commit").argument("[slug]", "The mist slug (defaults to .mist file in CWD)").option("--revision <sha>", "Full commit SHA to stage (defaults to this checkout's HEAD when it is clean and on main)").option("--operation-key <key>", "Resume an interrupted request with the key and --revision it printed").option("--wait", "Follow the operation until staging is ready").option("--timeout <minutes>", "How long --wait follows it (default 30)").option("--json", "Emit the operation as JSON").action((slug, opts) => runAction(async () => {
4372
+ if (opts.operationKey && !opts.revision) throw new Error("--operation-key needs the --revision it was first sent with. The interrupted request printed both.");
4197
4373
  const resolved = resolveSlug(slug);
4198
4374
  const client = createMistClient();
4199
- let operation = await admitStaging(client, resolved, {
4200
- sourceRevision: opts.revision,
4375
+ const sourceRevision = opts.revision ?? await localRevision(client, resolved);
4376
+ let operation = await requestStagingResumably(client, resolved, {
4377
+ sourceRevision,
4201
4378
  operationKey: opts.operationKey,
4202
- onOperationKey: (key) => process.stderr.write(chalk.dim(`Operation key ${key}. If this is interrupted, rerun with --operation-key ${key}.\n`))
4379
+ store: createFileStagingRequestStore(),
4380
+ onResume: (key) => process.stderr.write(chalk.dim(`Resuming the unconfirmed request for ${sourceRevision.slice(0, 7)} (key ${key}).\n`)),
4381
+ onOperationKey: (key) => process.stderr.write(chalk.dim(`Operation key ${key}. If this is interrupted, rerun with --operation-key ${key} --revision ${sourceRevision}.\n`))
4203
4382
  });
4204
4383
  process.stderr.write(`Staging operation ${operation.id}: ${describeProgress(operation)}\n`);
4205
4384
  if (opts.wait) operation = await waitForOperation(client, resolved, operation.id, waitOptions(opts.timeout));
@@ -4207,6 +4386,13 @@ const stagingRequestCommand = new Command("request").description("Request the st
4207
4386
  if (operation.state === "succeeded") console.log(`✔ Staging is ready. Run \`fluid mist staging status\` for its URL.`);
4208
4387
  else console.log(`Requested. Follow it with \`fluid mist staging wait --operation ${operation.id}\`.`);
4209
4388
  }));
4389
+ /** This checkout's HEAD, once it is clean and already on the remote's main. */
4390
+ async function localRevision(client, slug) {
4391
+ const { git_credential: credential } = await createGitCredential(client, slug);
4392
+ const revision = await resolveLocalStagingRevision(process.cwd(), credential.remote_url);
4393
+ process.stderr.write(chalk.dim(`Staging this checkout's HEAD, ${revision.slice(0, 7)}.\n`));
4394
+ return revision;
4395
+ }
4210
4396
  //#endregion
4211
4397
  //#region src/commands/staging/index.ts
4212
4398
  /** `fluid mist staging` — sub-command aggregator. */