@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 +193 -7
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
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
|
-
|
|
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)").
|
|
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
|
-
|
|
4200
|
-
|
|
4375
|
+
const sourceRevision = opts.revision ?? await localRevision(client, resolved);
|
|
4376
|
+
let operation = await requestStagingResumably(client, resolved, {
|
|
4377
|
+
sourceRevision,
|
|
4201
4378
|
operationKey: opts.operationKey,
|
|
4202
|
-
|
|
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. */
|