@fluid-app/fluid-cli-mist 0.1.11 → 0.1.12
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 +1578 -1496
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -3,10 +3,10 @@ import chalk from "chalk";
|
|
|
3
3
|
import { getAuthToken } from "@fluid-app/fluid-cli";
|
|
4
4
|
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
5
|
import path, { join, resolve } from "node:path";
|
|
6
|
-
import ora from "ora";
|
|
7
|
-
import prompts from "prompts";
|
|
8
6
|
import os from "node:os";
|
|
9
7
|
import { execa } from "execa";
|
|
8
|
+
import ora from "ora";
|
|
9
|
+
import prompts from "prompts";
|
|
10
10
|
import open from "open";
|
|
11
11
|
//#region src/api/client.ts
|
|
12
12
|
/**
|
|
@@ -210,1670 +210,1752 @@ function canonicalizeHostableType(typePart) {
|
|
|
210
210
|
throw new HostableParseError(`Unknown hostable type "${typePart}". Use one of: ${SHORTHAND_LIST}.`);
|
|
211
211
|
}
|
|
212
212
|
//#endregion
|
|
213
|
-
//#region src/lib/
|
|
213
|
+
//#region src/lib/git.ts
|
|
214
214
|
/**
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
215
|
+
* Git wrapper — shells out to system `git` via execa. Spec §11.1: we
|
|
216
|
+
* deliberately do NOT bundle git or use a pure-JS impl; users are expected
|
|
217
|
+
* to have it installed.
|
|
218
|
+
*
|
|
219
|
+
* Security: token-bearing `remote_url` from POST /git_credential must never
|
|
220
|
+
* be logged, persisted, *or* exposed in the process table. The token is
|
|
221
|
+
* stripped from the URL and handed to git through a GIT_ASKPASS script via
|
|
222
|
+
* env vars (MIST_GIT_USER / MIST_GIT_PASS) — so it never appears in argv
|
|
223
|
+
* (visible to `ps aux` on macOS, `/proc/<pid>/cmdline` on Linux) and is
|
|
224
|
+
* never written to `.git/config`.
|
|
220
225
|
*/
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
constructor(message) {
|
|
226
|
+
var GitError = class extends Error {
|
|
227
|
+
exitCode;
|
|
228
|
+
constructor(message, exitCode = 1) {
|
|
224
229
|
super(message);
|
|
225
|
-
this.name = "
|
|
230
|
+
this.name = "GitError";
|
|
231
|
+
this.exitCode = exitCode;
|
|
226
232
|
}
|
|
227
233
|
};
|
|
228
|
-
function readMistFile(dir) {
|
|
229
|
-
const filePath = join(dir, MIST_FILE);
|
|
230
|
-
if (!existsSync(filePath)) return null;
|
|
231
|
-
try {
|
|
232
|
-
const raw = readFileSync(filePath, "utf8");
|
|
233
|
-
const parsed = JSON.parse(raw);
|
|
234
|
-
if (!parsed || typeof parsed !== "object") return null;
|
|
235
|
-
const obj = parsed;
|
|
236
|
-
if (typeof obj["slug"] !== "string") return null;
|
|
237
|
-
return {
|
|
238
|
-
slug: obj["slug"],
|
|
239
|
-
vendor_name: typeof obj["vendor_name"] === "string" ? obj["vendor_name"] : "",
|
|
240
|
-
public_url: typeof obj["public_url"] === "string" ? obj["public_url"] : null
|
|
241
|
-
};
|
|
242
|
-
} catch {
|
|
243
|
-
return null;
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
/** Returns the slug from arg or `.mist` file; throws if neither is present. */
|
|
247
|
-
function resolveSlug(arg, cwd = process.cwd()) {
|
|
248
|
-
if (arg && arg.trim().length > 0) return arg.trim();
|
|
249
|
-
const file = readMistFile(cwd);
|
|
250
|
-
if (file) return file.slug;
|
|
251
|
-
throw new SlugResolutionError("No slug provided and no `.mist` file in this directory. Pass the slug as an argument or cd into a cloned mist.");
|
|
252
|
-
}
|
|
253
|
-
/** Write a `.mist` file after `fluid mist clone`. */
|
|
254
|
-
function writeMistFile(dir, mist) {
|
|
255
|
-
const payload = {
|
|
256
|
-
slug: mist.slug,
|
|
257
|
-
vendor_name: mist.vendor_name,
|
|
258
|
-
public_url: mist.public_url
|
|
259
|
-
};
|
|
260
|
-
writeFileSync(join(dir, MIST_FILE), JSON.stringify(payload, null, 2) + "\n", "utf8");
|
|
261
|
-
}
|
|
262
|
-
//#endregion
|
|
263
|
-
//#region src/lib/error-handler.ts
|
|
264
234
|
/**
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
235
|
+
* Git author for Mist publish commits. Mist app repos are connected to the
|
|
236
|
+
* we-commerce Vercel team via Vercel's GitHub integration, and Vercel
|
|
237
|
+
* attributes a git-triggered deploy to the *commit author's* GitHub account.
|
|
238
|
+
* If that author is a user's personal GitHub account (not a member of the
|
|
239
|
+
* Vercel team), Vercel blocks the deployment ("… does not have a Vercel
|
|
240
|
+
* account linked to their GitHub account"). Authoring every publish commit as
|
|
241
|
+
* the Mist GitHub App bot — which the Vercel integration recognizes — keeps
|
|
242
|
+
* deploys unblocked regardless of the user's local/global git identity. Human
|
|
243
|
+
* attribution is preserved in the commit *message* (see push.ts), not the
|
|
244
|
+
* author field.
|
|
268
245
|
*
|
|
269
|
-
*
|
|
270
|
-
*
|
|
246
|
+
* These MUST match the identity set on cloned repos in Mist Desktop
|
|
247
|
+
* (apps/mist-desktop/src/main/ipc.ts) so both paths produce the same author.
|
|
271
248
|
*/
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
console.error(chalk.red(err.message));
|
|
283
|
-
process.exit(1);
|
|
284
|
-
}
|
|
285
|
-
if (err instanceof HostableParseError) {
|
|
286
|
-
console.error(chalk.red(err.message));
|
|
287
|
-
process.exit(1);
|
|
288
|
-
}
|
|
289
|
-
if (err instanceof MistApiError) {
|
|
290
|
-
if (err.status === 403) {
|
|
291
|
-
const action = err.details?.["permission"];
|
|
292
|
-
console.error(chalk.red(action ? `Your role doesn't include \`mist.${String(action)}\` — ask your Fluid admin to grant it.` : err.message));
|
|
293
|
-
} else if (err.status === 409) {
|
|
294
|
-
const date = err.details?.["scheduled_destroy_at"];
|
|
295
|
-
console.error(chalk.yellow(typeof date === "string" ? `This mist is scheduled for destruction on ${date.slice(0, 10)}. Run \`fluid mist restore <slug>\` first.` : err.message));
|
|
296
|
-
} else if (err.status === 422) {
|
|
297
|
-
const details = err.details ?? {};
|
|
298
|
-
const idErrors = details["integratable_id"] ?? details["hostable_id"];
|
|
299
|
-
const managedKey = typeof err.message === "string" && /managed_by|managed by mist|FLUID_DROPLET|FLUID_WEBHOOK/i.test(err.message);
|
|
300
|
-
if (Array.isArray(idErrors) && idErrors.some((e) => /already taken|already attached/i.test(String(e)))) console.error(chalk.yellow("That droplet is already attached to another mist. Detach it first or pick a different one."));
|
|
301
|
-
else if (managedKey) console.error(chalk.yellow("That env var is Mist-managed (FLUID_DROPLET_UUID / FLUID_DROPLET_SECRET / FLUID_WEBHOOK_AUTH_TOKEN) — it's set automatically when a droplet is attached and can't be written directly."));
|
|
302
|
-
else if (typeof err.message === "string" && /Droplet not found|integratable/i.test(err.message)) {
|
|
303
|
-
const id = details["integratable_id"] ?? details["hostable_id"];
|
|
304
|
-
console.error(chalk.yellow(`Couldn't find droplet${id != null ? ` #${String(id)}` : ""} in this company.`));
|
|
305
|
-
} else console.error(chalk.red(err.message));
|
|
306
|
-
} else if (err.status === 410) console.error(chalk.red("Grace period expired — vendor teardown is in flight. Create a fresh mist with `fluid mist create`."));
|
|
307
|
-
else console.error(chalk.red(err.message));
|
|
308
|
-
process.exit(err.exitCode);
|
|
309
|
-
}
|
|
310
|
-
if (err instanceof Error) console.error(chalk.red(err.message));
|
|
311
|
-
else console.error(chalk.red(String(err)));
|
|
312
|
-
process.exit(1);
|
|
249
|
+
const MIST_GIT_AUTHOR_NAME = "mist-we-commerce[bot]";
|
|
250
|
+
const MIST_GIT_AUTHOR_EMAIL = "293596226+mist-we-commerce[bot]@users.noreply.github.com";
|
|
251
|
+
/** execa throws ENOENT when the git binary itself is missing — the
|
|
252
|
+
* single most common failure on non-developer machines. Translate it
|
|
253
|
+
* into an actionable message instead of "git <verb> failed (exit 1)".
|
|
254
|
+
* Returns null for every other failure so call sites keep their
|
|
255
|
+
* specific messages. */
|
|
256
|
+
function gitMissingError(err) {
|
|
257
|
+
if (err?.code !== "ENOENT") return null;
|
|
258
|
+
return new GitError("Git isn't installed on this machine. In Mist Desktop, use the Install button in the yellow banner at the top of the app. From a terminal: macOS `xcode-select --install`, or download from https://git-scm.com/downloads.", 127);
|
|
313
259
|
}
|
|
314
|
-
//#endregion
|
|
315
|
-
//#region src/lib/output.ts
|
|
316
260
|
/**
|
|
317
|
-
*
|
|
318
|
-
*
|
|
261
|
+
* Split a token-bearing remote URL into the userless URL + creds. Used
|
|
262
|
+
* to keep the token off git's argv: we pass `cleanUrl` to git and feed
|
|
263
|
+
* `username` / `password` back through GIT_ASKPASS env vars.
|
|
264
|
+
*
|
|
265
|
+
* If the URL has no embedded credentials, returns empty strings — fine
|
|
266
|
+
* for public repos where git won't even invoke askpass.
|
|
319
267
|
*/
|
|
320
|
-
function
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
const
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
};
|
|
331
|
-
function colorState(state) {
|
|
332
|
-
return STATE_COLOR$1[state](state);
|
|
268
|
+
function splitRemoteCredentials(remoteUrl) {
|
|
269
|
+
const u = new URL(remoteUrl);
|
|
270
|
+
const username = decodeURIComponent(u.username);
|
|
271
|
+
const password = decodeURIComponent(u.password);
|
|
272
|
+
u.username = "";
|
|
273
|
+
u.password = "";
|
|
274
|
+
return {
|
|
275
|
+
cleanUrl: u.toString(),
|
|
276
|
+
username,
|
|
277
|
+
password
|
|
278
|
+
};
|
|
333
279
|
}
|
|
334
280
|
/**
|
|
335
|
-
*
|
|
336
|
-
*
|
|
337
|
-
*
|
|
338
|
-
*
|
|
281
|
+
* Write a stub GIT_ASKPASS shell script to a per-process tmpdir and
|
|
282
|
+
* return its path. The script itself contains no secret — it just
|
|
283
|
+
* echoes back whichever env var (MIST_GIT_USER / MIST_GIT_PASS)
|
|
284
|
+
* matches git's prompt ("Username for …" / "Password for …"). The
|
|
285
|
+
* credentials only live in the spawned git process's env for the
|
|
286
|
+
* duration of the call.
|
|
287
|
+
*
|
|
288
|
+
* One POSIX script for all platforms: Git for Windows executes
|
|
289
|
+
* GIT_ASKPASS through its bundled sh, so a .cmd askpass fails with
|
|
290
|
+
* "unable to read askpass response" (git 2.52.0.windows.1) while a
|
|
291
|
+
* plain sh script works.
|
|
292
|
+
*
|
|
293
|
+
* Idempotent: subsequent calls reuse the same script path.
|
|
339
294
|
*/
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
295
|
+
let askpassPath = null;
|
|
296
|
+
function ensureAskpassScript() {
|
|
297
|
+
if (askpassPath && existsSync(askpassPath)) return askpassPath;
|
|
298
|
+
const dir = path.join(os.tmpdir(), `mist-cli-askpass-${process.pid}`);
|
|
299
|
+
if (!existsSync(dir)) mkdirSync(dir, {
|
|
300
|
+
recursive: true,
|
|
301
|
+
mode: 448
|
|
302
|
+
});
|
|
303
|
+
const p = path.join(dir, "askpass.sh");
|
|
304
|
+
writeFileSync(p, `#!/bin/sh\ncase "$1" in\n Username*) printf '%s' "$MIST_GIT_USER" ;;\n *) printf '%s' "$MIST_GIT_PASS" ;;\nesac\n`, { mode: 448 });
|
|
305
|
+
chmodSync(p, 448);
|
|
306
|
+
askpassPath = p;
|
|
307
|
+
return askpassPath;
|
|
343
308
|
}
|
|
344
309
|
/**
|
|
345
|
-
*
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
*
|
|
350
|
-
* empty
|
|
310
|
+
* Build the spawn env + cleaned URL for a credentialed git invocation.
|
|
311
|
+
* Returns:
|
|
312
|
+
* - `cleanUrl`: URL with userinfo stripped — safe to pass as argv.
|
|
313
|
+
* - `env`: parent env plus GIT_ASKPASS + MIST_GIT_USER / MIST_GIT_PASS.
|
|
314
|
+
* GIT_TERMINAL_PROMPT=0 prevents the TTY-prompt fallback if
|
|
315
|
+
* the credentials are empty (we want auth to fail fast, not
|
|
316
|
+
* hang the desktop waiting for interactive input).
|
|
351
317
|
*/
|
|
352
|
-
function
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
});
|
|
365
|
-
process.stdout.write(padded.join(" ") + "\n");
|
|
366
|
-
}
|
|
318
|
+
function gitAuthSpawn(remoteUrl) {
|
|
319
|
+
const { cleanUrl, username, password } = splitRemoteCredentials(remoteUrl);
|
|
320
|
+
return {
|
|
321
|
+
cleanUrl,
|
|
322
|
+
env: {
|
|
323
|
+
...process.env,
|
|
324
|
+
GIT_ASKPASS: ensureAskpassScript(),
|
|
325
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
326
|
+
MIST_GIT_USER: username,
|
|
327
|
+
MIST_GIT_PASS: password
|
|
328
|
+
}
|
|
329
|
+
};
|
|
367
330
|
}
|
|
368
|
-
|
|
369
|
-
|
|
331
|
+
/** Confirm git is installed. Cheap call once at startup. */
|
|
332
|
+
async function ensureGitAvailable() {
|
|
333
|
+
try {
|
|
334
|
+
await execa("git", ["--version"]);
|
|
335
|
+
} catch {
|
|
336
|
+
throw new GitError("`git` is required but not found on your PATH. Install it from https://git-scm.com/downloads or via `brew install git`.");
|
|
337
|
+
}
|
|
370
338
|
}
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
const
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
console.log(chalk.dim("No mists found."));
|
|
402
|
-
return;
|
|
339
|
+
/**
|
|
340
|
+
* `git clone <cleanUrl> <targetDir>`. The original remoteUrl carries a
|
|
341
|
+
* token; gitAuthSpawn strips it from the URL and re-injects it via
|
|
342
|
+
* GIT_ASKPASS so it never appears in argv. `credential.helper=` stops
|
|
343
|
+
* git from caching what askpass returned to any system credential
|
|
344
|
+
* store.
|
|
345
|
+
*/
|
|
346
|
+
async function gitClone(remoteUrl, targetDir) {
|
|
347
|
+
const { cleanUrl, env } = gitAuthSpawn(remoteUrl);
|
|
348
|
+
try {
|
|
349
|
+
await execa("git", [
|
|
350
|
+
"-c",
|
|
351
|
+
"credential.helper=",
|
|
352
|
+
"clone",
|
|
353
|
+
"--quiet",
|
|
354
|
+
cleanUrl,
|
|
355
|
+
targetDir
|
|
356
|
+
], {
|
|
357
|
+
env,
|
|
358
|
+
stdio: [
|
|
359
|
+
"ignore",
|
|
360
|
+
"inherit",
|
|
361
|
+
"inherit"
|
|
362
|
+
]
|
|
363
|
+
});
|
|
364
|
+
} catch (err) {
|
|
365
|
+
const missing = gitMissingError(err);
|
|
366
|
+
if (missing) throw missing;
|
|
367
|
+
const e = err;
|
|
368
|
+
throw new GitError(`git clone failed${e.exitCode != null ? ` (exit ${e.exitCode})` : ""}`, e.exitCode ?? 1);
|
|
403
369
|
}
|
|
404
|
-
|
|
405
|
-
chalk.bold("SLUG"),
|
|
406
|
-
chalk.bold("NAME"),
|
|
407
|
-
chalk.bold("STATE"),
|
|
408
|
-
chalk.bold("ATTACHED"),
|
|
409
|
-
chalk.bold("UPDATED")
|
|
410
|
-
], ...mists.map((m) => [
|
|
411
|
-
m.slug,
|
|
412
|
-
m.name ?? m.droplet?.name ?? m.vendor_name ?? chalk.dim("(unnamed)"),
|
|
413
|
-
colorState(m.state),
|
|
414
|
-
m.hostable_type ?? chalk.dim("standalone"),
|
|
415
|
-
relativeDate(m.updated_at)
|
|
416
|
-
])]);
|
|
417
|
-
}));
|
|
418
|
-
//#endregion
|
|
419
|
-
//#region src/api/integration_points.ts
|
|
420
|
-
function basePath(mistId) {
|
|
421
|
-
return `/api/v202604/mists/${mistId}/integration_points`;
|
|
422
|
-
}
|
|
423
|
-
async function listIntegrationPoints(client, slug) {
|
|
424
|
-
const id = await resolveMistId(client, slug);
|
|
425
|
-
return client.get(basePath(id));
|
|
370
|
+
await ensureBotIdentity(targetDir);
|
|
426
371
|
}
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
372
|
+
/** `git push origin <branch>` — credentials supplied via GIT_ASKPASS,
|
|
373
|
+
* never in argv. The `.extraheader=` override clears any cached
|
|
374
|
+
* Authorization header that a CI-style git config might inject. */
|
|
375
|
+
async function gitPush(cwd, remoteUrl, branch = "main") {
|
|
376
|
+
const { cleanUrl, env } = gitAuthSpawn(remoteUrl);
|
|
377
|
+
try {
|
|
378
|
+
await execa("git", [
|
|
379
|
+
"-c",
|
|
380
|
+
"credential.helper=",
|
|
381
|
+
"-c",
|
|
382
|
+
`http.https://github.com/.extraheader=`,
|
|
383
|
+
"push",
|
|
384
|
+
cleanUrl,
|
|
385
|
+
`HEAD:${branch}`
|
|
386
|
+
], {
|
|
387
|
+
cwd,
|
|
388
|
+
env,
|
|
389
|
+
stdio: [
|
|
390
|
+
"ignore",
|
|
391
|
+
"inherit",
|
|
392
|
+
"inherit"
|
|
393
|
+
]
|
|
394
|
+
});
|
|
395
|
+
} catch (err) {
|
|
396
|
+
const missing = gitMissingError(err);
|
|
397
|
+
if (missing) throw missing;
|
|
398
|
+
const e = err;
|
|
399
|
+
throw new GitError(`git push failed${e.exitCode != null ? ` (exit ${e.exitCode})` : ""}`, e.exitCode ?? 1);
|
|
400
|
+
}
|
|
430
401
|
}
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
402
|
+
/** `git pull origin <branch>`. Token supplied via GIT_ASKPASS, same as push. */
|
|
403
|
+
async function gitPull(cwd, remoteUrl, branch = "main") {
|
|
404
|
+
const { cleanUrl, env } = gitAuthSpawn(remoteUrl);
|
|
405
|
+
try {
|
|
406
|
+
await execa("git", [
|
|
407
|
+
"-c",
|
|
408
|
+
"credential.helper=",
|
|
409
|
+
"pull",
|
|
410
|
+
"--ff-only",
|
|
411
|
+
cleanUrl,
|
|
412
|
+
branch
|
|
413
|
+
], {
|
|
414
|
+
cwd,
|
|
415
|
+
env,
|
|
416
|
+
stdio: [
|
|
417
|
+
"ignore",
|
|
418
|
+
"inherit",
|
|
419
|
+
"inherit"
|
|
420
|
+
]
|
|
421
|
+
});
|
|
422
|
+
} catch (err) {
|
|
423
|
+
const missing = gitMissingError(err);
|
|
424
|
+
if (missing) throw missing;
|
|
425
|
+
const e = err;
|
|
426
|
+
throw new GitError(`git pull failed${e.exitCode != null ? ` (exit ${e.exitCode})` : ""}`, e.exitCode ?? 1);
|
|
427
|
+
}
|
|
434
428
|
}
|
|
435
429
|
/**
|
|
436
|
-
*
|
|
437
|
-
*
|
|
438
|
-
*
|
|
430
|
+
* Are there uncommitted changes (working tree or index) in this repo?
|
|
431
|
+
* `git status --porcelain` is empty when everything is clean, non-empty
|
|
432
|
+
* otherwise — so the count is just "is the output non-trivial".
|
|
439
433
|
*/
|
|
440
|
-
function
|
|
441
|
-
|
|
434
|
+
async function hasUncommittedChanges(cwd) {
|
|
435
|
+
try {
|
|
436
|
+
const { stdout } = await execa("git", ["status", "--porcelain"], { cwd });
|
|
437
|
+
return stdout.trim().length > 0;
|
|
438
|
+
} catch {
|
|
439
|
+
return false;
|
|
440
|
+
}
|
|
442
441
|
}
|
|
443
442
|
/**
|
|
444
|
-
*
|
|
445
|
-
*
|
|
446
|
-
*
|
|
447
|
-
*
|
|
443
|
+
* Fetch the given branch from the token-bearing remote URL, writing
|
|
444
|
+
* `FETCH_HEAD` so `git rev-list HEAD...FETCH_HEAD` is meaningful.
|
|
445
|
+
*
|
|
446
|
+
* Same security stance as gitPush — installation token comes through
|
|
447
|
+
* GIT_ASKPASS, never argv or `.git/config`.
|
|
448
448
|
*/
|
|
449
|
-
function
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
}
|
|
468
|
-
const showCommand = new Command("show").description("Show one mist by slug").argument("[slug]", "The mist slug (defaults to .mist file in CWD)").option("--json", "Emit raw API JSON").action((slug, opts) => runAction(async () => {
|
|
469
|
-
const resolved = resolveSlug(slug);
|
|
470
|
-
const client = createMistClient();
|
|
471
|
-
const { mist } = await showMist(client, resolved);
|
|
472
|
-
const integrationPoints = (await listIntegrationPoints(client, resolved)).integration_points;
|
|
473
|
-
if (opts.json) return printJson({
|
|
474
|
-
mist,
|
|
475
|
-
integration_points: integrationPoints
|
|
476
|
-
});
|
|
477
|
-
const lines = [];
|
|
478
|
-
const label = mistLabel(mist.name, mist.vendor_name);
|
|
479
|
-
lines.push(`${label} ${chalk.dim(mist.slug)}`);
|
|
480
|
-
if (mist.vendor_name && mist.name && mist.name !== mist.vendor_name) lines.push(` vendor ${chalk.dim(mist.vendor_name)}`);
|
|
481
|
-
lines.push(` state ${colorState(mist.state)}`);
|
|
482
|
-
if (mist.kind) lines.push(` kind ${mist.kind}`);
|
|
483
|
-
if (mist.public_url) lines.push(` url ${chalk.cyan(mist.public_url)}`);
|
|
484
|
-
if (integrationPoints.length > 0) {
|
|
485
|
-
lines.push(` surfaces ${integrationPoints.length}`);
|
|
486
|
-
for (const ip of integrationPoints) lines.push(` ${chalk.dim("·")} ${ip.kind} ${surfaceRef(ip)} ${chalk.dim(`integration ${ip.id}`)}`);
|
|
449
|
+
async function gitFetch(cwd, remoteUrl, branch = "main") {
|
|
450
|
+
const { cleanUrl, env } = gitAuthSpawn(remoteUrl);
|
|
451
|
+
try {
|
|
452
|
+
await execa("git", [
|
|
453
|
+
"-c",
|
|
454
|
+
"credential.helper=",
|
|
455
|
+
"fetch",
|
|
456
|
+
"--quiet",
|
|
457
|
+
cleanUrl,
|
|
458
|
+
branch
|
|
459
|
+
], {
|
|
460
|
+
cwd,
|
|
461
|
+
env
|
|
462
|
+
});
|
|
463
|
+
} catch (err) {
|
|
464
|
+
const missing = gitMissingError(err);
|
|
465
|
+
if (missing) throw missing;
|
|
466
|
+
const e = err;
|
|
467
|
+
throw new GitError(`git fetch failed${e.exitCode != null ? ` (exit ${e.exitCode})` : ""}`, e.exitCode ?? 1);
|
|
487
468
|
}
|
|
488
|
-
|
|
489
|
-
lines.push(` updated ${relativeDate(mist.updated_at)}`);
|
|
490
|
-
if (mist.scheduled_destroy_at) lines.push(chalk.magenta(` destroy scheduled ${formatAbsolute(mist.scheduled_destroy_at)} (run \`fluid mist restore ${mist.slug}\` to undo)`));
|
|
491
|
-
console.log(lines.join("\n"));
|
|
492
|
-
}));
|
|
493
|
-
//#endregion
|
|
494
|
-
//#region src/lib/ora-reporter.ts
|
|
469
|
+
}
|
|
495
470
|
/**
|
|
496
|
-
*
|
|
497
|
-
* `
|
|
498
|
-
*
|
|
471
|
+
* Count commits in local HEAD vs `FETCH_HEAD` (after `gitFetch`). Returns
|
|
472
|
+
* `{ahead, behind}`: how many commits HEAD has that the remote doesn't,
|
|
473
|
+
* and vice versa. A `0/0` result means "in sync".
|
|
499
474
|
*/
|
|
500
|
-
function
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
},
|
|
521
|
-
warn(text) {
|
|
522
|
-
spinner.warn(text);
|
|
523
|
-
},
|
|
524
|
-
fail(text) {
|
|
525
|
-
spinner.fail(text);
|
|
526
|
-
}
|
|
527
|
-
};
|
|
528
|
-
}
|
|
529
|
-
//#endregion
|
|
530
|
-
//#region src/lib/step-reporter.ts
|
|
531
|
-
/** No-op reporter for `--json` mode (final payload printed by the command). */
|
|
532
|
-
function createSilentReporter() {
|
|
533
|
-
return {
|
|
534
|
-
start() {},
|
|
535
|
-
update() {},
|
|
536
|
-
succeed() {},
|
|
537
|
-
skip() {},
|
|
538
|
-
info() {},
|
|
539
|
-
warn() {},
|
|
540
|
-
fail() {}
|
|
541
|
-
};
|
|
542
|
-
}
|
|
543
|
-
//#endregion
|
|
544
|
-
//#region src/api/droplets.ts
|
|
545
|
-
const BASE$3 = "/api/droplets";
|
|
546
|
-
/**
|
|
547
|
-
* Full scope set from `Droplet::Installation::VALID_SCOPES`. We request
|
|
548
|
-
* them all by default so a Mist-created droplet's installations carry write
|
|
549
|
-
* access out of the box — matching what Mist Desktop sends. Callers can
|
|
550
|
-
* narrow this via the flow's `requestedScopes` option.
|
|
551
|
-
*/
|
|
552
|
-
const DEFAULT_DROPLET_SCOPES = [
|
|
553
|
-
"main",
|
|
554
|
-
"prospects",
|
|
555
|
-
"members",
|
|
556
|
-
"web",
|
|
557
|
-
"settings",
|
|
558
|
-
"products",
|
|
559
|
-
"orders",
|
|
560
|
-
"users",
|
|
561
|
-
"website",
|
|
562
|
-
"header_menus",
|
|
563
|
-
"payments"
|
|
564
|
-
];
|
|
565
|
-
/** Lifecycle webhook paths a Mist Next app serves (mirrors Mist Desktop). */
|
|
566
|
-
function dropletWebhookUrls(embedUrl) {
|
|
567
|
-
const base = embedUrl.replace(/\/+$/, "");
|
|
568
|
-
return {
|
|
569
|
-
install_webhook_url: `${base}/api/webhooks/installed`,
|
|
570
|
-
uninstall_webhook_url: `${base}/api/webhooks/uninstalled`
|
|
571
|
-
};
|
|
572
|
-
}
|
|
573
|
-
/**
|
|
574
|
-
* Build a droplet create body from a name + (optional) embed URL. When the
|
|
575
|
-
* embed URL is known we also derive the lifecycle webhook URLs from it.
|
|
576
|
-
*/
|
|
577
|
-
function buildDropletCreateInput(opts) {
|
|
578
|
-
const scopes = [...opts.requestedScopes ?? DEFAULT_DROPLET_SCOPES];
|
|
579
|
-
const summary = "Created by Fluid Mist";
|
|
580
|
-
const input = {
|
|
581
|
-
name: opts.name,
|
|
582
|
-
requested_scopes: scopes,
|
|
583
|
-
settings: {
|
|
584
|
-
marketplace_page: { summary },
|
|
585
|
-
details_page: { summary }
|
|
586
|
-
}
|
|
587
|
-
};
|
|
588
|
-
if (opts.embedUrl) {
|
|
589
|
-
input.embed_url = opts.embedUrl;
|
|
590
|
-
Object.assign(input, dropletWebhookUrls(opts.embedUrl));
|
|
475
|
+
async function gitAheadBehindVsFetchHead(cwd) {
|
|
476
|
+
try {
|
|
477
|
+
const { stdout } = await execa("git", [
|
|
478
|
+
"rev-list",
|
|
479
|
+
"--left-right",
|
|
480
|
+
"--count",
|
|
481
|
+
"HEAD...FETCH_HEAD"
|
|
482
|
+
], { cwd });
|
|
483
|
+
const [a, b] = stdout.trim().split(/\s+/);
|
|
484
|
+
const ahead = Number.parseInt(a ?? "0", 10);
|
|
485
|
+
const behind = Number.parseInt(b ?? "0", 10);
|
|
486
|
+
return {
|
|
487
|
+
ahead: Number.isFinite(ahead) ? ahead : 0,
|
|
488
|
+
behind: Number.isFinite(behind) ? behind : 0
|
|
489
|
+
};
|
|
490
|
+
} catch {
|
|
491
|
+
return {
|
|
492
|
+
ahead: 0,
|
|
493
|
+
behind: 0
|
|
494
|
+
};
|
|
591
495
|
}
|
|
592
|
-
return input;
|
|
593
496
|
}
|
|
594
|
-
|
|
595
|
-
|
|
497
|
+
/** Fast-forward `HEAD` to `FETCH_HEAD`. Fails (non-zero exit) if not
|
|
498
|
+
* fast-forwardable — caller must check ahead-count first. */
|
|
499
|
+
async function gitMergeFastForward(cwd) {
|
|
500
|
+
try {
|
|
501
|
+
await execa("git", [
|
|
502
|
+
"merge",
|
|
503
|
+
"--ff-only",
|
|
504
|
+
"FETCH_HEAD"
|
|
505
|
+
], { cwd });
|
|
506
|
+
} catch (err) {
|
|
507
|
+
const missing = gitMissingError(err);
|
|
508
|
+
if (missing) throw missing;
|
|
509
|
+
const e = err;
|
|
510
|
+
throw new GitError(`git fast-forward merge failed${e.exitCode != null ? ` (exit ${e.exitCode})` : ""}`, e.exitCode ?? 1);
|
|
511
|
+
}
|
|
596
512
|
}
|
|
597
|
-
/** Page size + runaway guard for walking the page-paginated droplets list. */
|
|
598
|
-
const PER_PAGE$1 = 100;
|
|
599
|
-
const MAX_PAGES$1 = 25;
|
|
600
513
|
/**
|
|
601
|
-
*
|
|
602
|
-
*
|
|
603
|
-
* `
|
|
604
|
-
* points that only echo `integratable_id`: the show endpoint resolves by
|
|
605
|
-
* uuid, so a numeric id can't be fetched directly. Returns null when no
|
|
606
|
-
* droplet matches within the page cap.
|
|
514
|
+
* Stash working-tree + untracked changes with a Mist-tagged message.
|
|
515
|
+
* Returns the stash ref (`stash@{0}`) we created. Callers should pair
|
|
516
|
+
* this with `gitStashPop` after the dangerous step is done.
|
|
607
517
|
*/
|
|
608
|
-
async function
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
518
|
+
async function gitStash(cwd, message) {
|
|
519
|
+
try {
|
|
520
|
+
await execa("git", [
|
|
521
|
+
"stash",
|
|
522
|
+
"push",
|
|
523
|
+
"--include-untracked",
|
|
524
|
+
"--message",
|
|
525
|
+
message
|
|
526
|
+
], { cwd });
|
|
527
|
+
} catch (err) {
|
|
528
|
+
const missing = gitMissingError(err);
|
|
529
|
+
if (missing) throw missing;
|
|
530
|
+
throw new GitError(`git stash push failed`, err.exitCode ?? 1);
|
|
616
531
|
}
|
|
617
|
-
return null;
|
|
618
|
-
}
|
|
619
|
-
/** PATCH the droplet's embed URL (+ derived webhook URLs) once known. */
|
|
620
|
-
function updateDroplet(client, uuid, input) {
|
|
621
|
-
return client.patch(`${BASE$3}/${encodeURIComponent(uuid)}`, { droplet: input });
|
|
622
532
|
}
|
|
623
|
-
/** Convenience: set embed URL + derived webhook URLs in one PATCH. */
|
|
624
|
-
function setDropletEmbedUrl(client, uuid, embedUrl) {
|
|
625
|
-
return updateDroplet(client, uuid, {
|
|
626
|
-
embed_url: embedUrl,
|
|
627
|
-
...dropletWebhookUrls(embedUrl)
|
|
628
|
-
});
|
|
629
|
-
}
|
|
630
|
-
//#endregion
|
|
631
|
-
//#region src/api/droplet_installations.ts
|
|
632
|
-
const BASE$2 = "/api/droplet_installations";
|
|
633
|
-
/** Page size + runaway guard for walking the page-paginated list. */
|
|
634
|
-
const PER_PAGE = 100;
|
|
635
|
-
const MAX_PAGES = 25;
|
|
636
533
|
/**
|
|
637
|
-
*
|
|
638
|
-
*
|
|
639
|
-
*
|
|
640
|
-
* beyond the first page, making the idempotency check (and the duplicate-
|
|
641
|
-
* rejection confirmation) report a false "not installed" forever.
|
|
534
|
+
* Pop the most recent stash. Throws on conflict so callers can surface
|
|
535
|
+
* the situation (the working tree will have conflict markers; the user
|
|
536
|
+
* needs to resolve manually).
|
|
642
537
|
*/
|
|
643
|
-
async function
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
const
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
if (totalPages != null ? page >= totalPages : batch.length < PER_PAGE) break;
|
|
538
|
+
async function gitStashPop(cwd) {
|
|
539
|
+
try {
|
|
540
|
+
await execa("git", ["stash", "pop"], { cwd });
|
|
541
|
+
} catch (err) {
|
|
542
|
+
const missing = gitMissingError(err);
|
|
543
|
+
if (missing) throw missing;
|
|
544
|
+
throw new GitError(`Couldn't restore your local changes after pulling — the file edits collided with a teammate's. Run \`git stash list\` and resolve manually.`, err.exitCode ?? 1);
|
|
651
545
|
}
|
|
652
|
-
return rows;
|
|
653
|
-
}
|
|
654
|
-
/** Install a droplet (by drp_ uuid) for the current company. */
|
|
655
|
-
function createDropletInstallation(client, input) {
|
|
656
|
-
return client.post(BASE$2, input);
|
|
657
546
|
}
|
|
658
547
|
/**
|
|
659
|
-
*
|
|
660
|
-
*
|
|
661
|
-
*
|
|
662
|
-
*
|
|
548
|
+
* Persist the Mist bot as the repo's LOCAL git identity
|
|
549
|
+
* (`git config --local user.{name,email}`).
|
|
550
|
+
*
|
|
551
|
+
* `gitAutoCommit` already forces the bot author on the publish commit
|
|
552
|
+
* via `-c`/env, but that only covers commits WE make. Commits made any
|
|
553
|
+
* other way against the same checkout — the Mist chat agent shelling out
|
|
554
|
+
* `git commit` via run_cli, or the user in a terminal — inherit the
|
|
555
|
+
* repo's configured identity, which is the person's personal GitHub
|
|
556
|
+
* account. Vercel rejects a deploy whose commit author isn't a
|
|
557
|
+
* we-commerce team member, so those personal-authored commits silently
|
|
558
|
+
* block the deploy (exactly the www-* "stuck project" case).
|
|
559
|
+
*
|
|
560
|
+
* Setting the identity at the repo level makes EVERY commit path
|
|
561
|
+
* bot-authored, and heals repos cloned before this was in place on their
|
|
562
|
+
* next publish. Best-effort: a config failure must never abort a push —
|
|
563
|
+
* the publish commit is still bot-forced by gitAutoCommit regardless.
|
|
663
564
|
*/
|
|
664
|
-
function
|
|
665
|
-
|
|
565
|
+
async function ensureBotIdentity(cwd) {
|
|
566
|
+
try {
|
|
567
|
+
await execa("git", [
|
|
568
|
+
"config",
|
|
569
|
+
"--local",
|
|
570
|
+
"user.name",
|
|
571
|
+
MIST_GIT_AUTHOR_NAME
|
|
572
|
+
], { cwd });
|
|
573
|
+
await execa("git", [
|
|
574
|
+
"config",
|
|
575
|
+
"--local",
|
|
576
|
+
"user.email",
|
|
577
|
+
MIST_GIT_AUTHOR_EMAIL
|
|
578
|
+
], { cwd });
|
|
579
|
+
} catch {}
|
|
666
580
|
}
|
|
667
|
-
//#endregion
|
|
668
|
-
//#region src/api/drop_zones.ts
|
|
669
|
-
const BASE$1 = "/api/drop_zones";
|
|
670
581
|
/**
|
|
671
|
-
*
|
|
672
|
-
*
|
|
673
|
-
* `
|
|
674
|
-
*
|
|
582
|
+
* Append `.mist` to `.git/info/exclude` if it isn't already listed, so
|
|
583
|
+
* git treats the marker file (spec §3 — the local-only `.mist` file that
|
|
584
|
+
* `writeMistFile` drops after `fluid mist clone`) as ignored without ever
|
|
585
|
+
* touching the template's tracked `.gitignore`.
|
|
586
|
+
*
|
|
587
|
+
* Why local exclude and not `.gitignore`: `.gitignore` lives in the
|
|
588
|
+
* template repo and would need a template release to reach existing
|
|
589
|
+
* clones. `.git/info/exclude` is per-checkout, so writing it here (at
|
|
590
|
+
* clone time) AND defensively at the top of every `gitAutoCommit` (at
|
|
591
|
+
* push time) heals pre-existing clones on their very next push, with no
|
|
592
|
+
* template change required.
|
|
593
|
+
*
|
|
594
|
+
* Idempotent and best-effort: never throws. A repo with an unusual
|
|
595
|
+
* `.git` layout (missing `info/`, `.git` as a worktree pointer file,
|
|
596
|
+
* etc.) just skips the exclude — `gitAutoCommit`'s `git reset -- .mist`
|
|
597
|
+
* after staging is the second line of defense that keeps `.mist` out of
|
|
598
|
+
* the commit regardless.
|
|
675
599
|
*/
|
|
676
|
-
function
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
}
|
|
687
|
-
function createDropZone(client, body) {
|
|
688
|
-
return client.post(BASE$1, body);
|
|
600
|
+
function excludeMistMarkerFromGit(cwd) {
|
|
601
|
+
try {
|
|
602
|
+
const gitDir = path.join(cwd, ".git");
|
|
603
|
+
if (!existsSync(gitDir)) return;
|
|
604
|
+
const infoDir = path.join(gitDir, "info");
|
|
605
|
+
if (!existsSync(infoDir)) mkdirSync(infoDir, { recursive: true });
|
|
606
|
+
const excludePath = path.join(infoDir, "exclude");
|
|
607
|
+
const existing = existsSync(excludePath) ? readFileSync(excludePath, "utf8") : "";
|
|
608
|
+
if (existing.split("\n").some((line) => line.trim() === ".mist")) return;
|
|
609
|
+
writeFileSync(excludePath, `${existing}${existing.length > 0 && !existing.endsWith("\n") ? "\n" : ""}.mist\n`, "utf8");
|
|
610
|
+
} catch {}
|
|
689
611
|
}
|
|
690
|
-
//#endregion
|
|
691
|
-
//#region src/api/mobile_widgets.ts
|
|
692
|
-
const BASE = "/api/company/mobile_widgets";
|
|
693
612
|
/**
|
|
694
|
-
*
|
|
695
|
-
*
|
|
696
|
-
*
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
613
|
+
* `git add -A && git commit -m <message>` in `cwd`.
|
|
614
|
+
*
|
|
615
|
+
* Mist Desktop's "Publish" UX target is non-technical: the user expects
|
|
616
|
+
* "ship my changes" to ship the changes they made, not to know about
|
|
617
|
+
* staging or commit messages. This helper bundles working-tree edits
|
|
618
|
+
* into one commit per publish so push has something to send.
|
|
619
|
+
*
|
|
620
|
+
* The commit author is ALWAYS forced to the Mist GitHub App bot identity
|
|
621
|
+
* (MIST_GIT_AUTHOR_*), overriding whatever local/global git identity the
|
|
622
|
+
* machine has. This is what keeps Vercel from blocking the deploy: a commit
|
|
623
|
+
* authored by a user's personal GitHub account that isn't a we-commerce Vercel
|
|
624
|
+
* team member gets rejected. Passing `-c user.name=… -c user.email=…` beats
|
|
625
|
+
* even a `--local` config, so every publish is bot-authored regardless of how
|
|
626
|
+
* the repo landed on disk (fresh clone, pre-existing clone, or terminal CLI).
|
|
627
|
+
* The real person is still recorded in the commit *message* (see push.ts).
|
|
628
|
+
*
|
|
629
|
+
* `.mist` marker handling: `writeMistFile` already writes `.mist` to
|
|
630
|
+
* `.git/info/exclude` at clone time, but this heals two other cases —
|
|
631
|
+
* a clone made before that existed, or a repo whose exclude write
|
|
632
|
+
* silently failed. Belt AND suspenders:
|
|
633
|
+
* 1. Re-run the exclude write here (idempotent) — heals pre-existing
|
|
634
|
+
* clones on their very next push. Anchored to the repository root
|
|
635
|
+
* (`git rev-parse --show-toplevel`), not cwd, so a push run from a
|
|
636
|
+
* repo subdirectory heals the real `.git/info/exclude`.
|
|
637
|
+
* 2. After `git add -A`, run `git reset -- ':(top).mist' .mist`
|
|
638
|
+
* (root-anchored + cwd-relative pathspecs; best-effort,
|
|
639
|
+
* ignored if it fails or matches nothing) to unstage the marker in
|
|
640
|
+
* case the exclude write above failed and `.mist` got swept in
|
|
641
|
+
* anyway. We deliberately do NOT ask `git add` to exclude `.mist`
|
|
642
|
+
* via a `:(exclude)` pathspec — when a path is *also* already
|
|
643
|
+
* listed in `.git/info/exclude`, combining `-A` with an explicit
|
|
644
|
+
* exclude pathspec on that same path makes git exit 1 with
|
|
645
|
+
* "paths are ignored by one of your .gitignore files" (a real git
|
|
646
|
+
* quirk, confirmed against git 2.41), which would turn the normal
|
|
647
|
+
* case (exclude already written) into a hard failure. Add-then-
|
|
648
|
+
* reset avoids that entirely and is a no-op when there's nothing
|
|
649
|
+
* to unstage.
|
|
650
|
+
* Note: a `.mist` file already committed in an existing repo's history
|
|
651
|
+
* is left alone — this only prevents *new* adds, it does not untrack
|
|
652
|
+
* anything or rewrite history.
|
|
653
|
+
*/
|
|
654
|
+
async function gitAutoCommit(cwd, message) {
|
|
655
|
+
await ensureBotIdentity(cwd);
|
|
656
|
+
let repoRoot = cwd;
|
|
657
|
+
try {
|
|
658
|
+
const { stdout } = await execa("git", ["rev-parse", "--show-toplevel"], { cwd });
|
|
659
|
+
if (stdout.trim().length > 0) repoRoot = stdout.trim();
|
|
660
|
+
} catch {}
|
|
661
|
+
excludeMistMarkerFromGit(repoRoot);
|
|
662
|
+
try {
|
|
663
|
+
await execa("git", ["add", "-A"], { cwd });
|
|
664
|
+
} catch (err) {
|
|
665
|
+
const missing = gitMissingError(err);
|
|
666
|
+
if (missing) throw missing;
|
|
667
|
+
throw new GitError(`git add failed`, err.exitCode ?? 1);
|
|
668
|
+
}
|
|
669
|
+
try {
|
|
670
|
+
await execa("git", [
|
|
671
|
+
"reset",
|
|
672
|
+
"-q",
|
|
673
|
+
"--",
|
|
674
|
+
":(top).mist",
|
|
675
|
+
".mist"
|
|
676
|
+
], { cwd });
|
|
677
|
+
} catch {}
|
|
678
|
+
const identityArgs = [
|
|
679
|
+
"-c",
|
|
680
|
+
`user.name=${MIST_GIT_AUTHOR_NAME}`,
|
|
681
|
+
"-c",
|
|
682
|
+
`user.email=${MIST_GIT_AUTHOR_EMAIL}`
|
|
683
|
+
];
|
|
684
|
+
const identityEnv = {
|
|
685
|
+
GIT_AUTHOR_NAME: MIST_GIT_AUTHOR_NAME,
|
|
686
|
+
GIT_AUTHOR_EMAIL: MIST_GIT_AUTHOR_EMAIL,
|
|
687
|
+
GIT_COMMITTER_NAME: MIST_GIT_AUTHOR_NAME,
|
|
688
|
+
GIT_COMMITTER_EMAIL: MIST_GIT_AUTHOR_EMAIL
|
|
705
689
|
};
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
690
|
+
try {
|
|
691
|
+
await execa("git", [
|
|
692
|
+
...identityArgs,
|
|
693
|
+
"commit",
|
|
694
|
+
"-m",
|
|
695
|
+
message
|
|
696
|
+
], {
|
|
697
|
+
cwd,
|
|
698
|
+
env: {
|
|
699
|
+
...process.env,
|
|
700
|
+
...identityEnv
|
|
701
|
+
},
|
|
702
|
+
stdio: [
|
|
703
|
+
"ignore",
|
|
704
|
+
"inherit",
|
|
705
|
+
"inherit"
|
|
706
|
+
]
|
|
707
|
+
});
|
|
708
|
+
} catch (err) {
|
|
709
|
+
const missing = gitMissingError(err);
|
|
710
|
+
if (missing) throw missing;
|
|
711
|
+
const e = err;
|
|
712
|
+
if (typeof e.stderr === "string" && /nothing to commit/i.test(e.stderr)) return;
|
|
713
|
+
throw new GitError(`git commit failed${e.exitCode != null ? ` (exit ${e.exitCode})` : ""}`, e.exitCode ?? 1);
|
|
714
|
+
}
|
|
709
715
|
}
|
|
710
716
|
//#endregion
|
|
711
|
-
//#region src/
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
717
|
+
//#region src/lib/slug-resolver.ts
|
|
718
|
+
/**
|
|
719
|
+
* Slug resolution — spec §3 conventions:
|
|
720
|
+
* 1. If a slug arg is passed, use it.
|
|
721
|
+
* 2. Otherwise look for a `.mist` file in CWD (written by `fluid mist
|
|
722
|
+
* clone`) and use the slug from it.
|
|
723
|
+
* 3. Otherwise error: tell the user to pass a slug or cd into a cloned dir.
|
|
724
|
+
*/
|
|
725
|
+
const MIST_FILE = ".mist";
|
|
726
|
+
var SlugResolutionError = class extends Error {
|
|
727
|
+
constructor(message) {
|
|
728
|
+
super(message);
|
|
729
|
+
this.name = "SlugResolutionError";
|
|
730
|
+
}
|
|
731
|
+
};
|
|
732
|
+
function readMistFile(dir) {
|
|
733
|
+
const filePath = join(dir, MIST_FILE);
|
|
734
|
+
if (!existsSync(filePath)) return null;
|
|
735
|
+
try {
|
|
736
|
+
const raw = readFileSync(filePath, "utf8");
|
|
737
|
+
const parsed = JSON.parse(raw);
|
|
738
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
739
|
+
const obj = parsed;
|
|
740
|
+
if (typeof obj["slug"] !== "string") return null;
|
|
741
|
+
return {
|
|
742
|
+
slug: obj["slug"],
|
|
743
|
+
vendor_name: typeof obj["vendor_name"] === "string" ? obj["vendor_name"] : "",
|
|
744
|
+
public_url: typeof obj["public_url"] === "string" ? obj["public_url"] : null
|
|
745
|
+
};
|
|
746
|
+
} catch {
|
|
747
|
+
return null;
|
|
748
|
+
}
|
|
735
749
|
}
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
750
|
+
/** Returns the slug from arg or `.mist` file; throws if neither is present. */
|
|
751
|
+
function resolveSlug(arg, cwd = process.cwd()) {
|
|
752
|
+
if (arg && arg.trim().length > 0) return arg.trim();
|
|
753
|
+
const file = readMistFile(cwd);
|
|
754
|
+
if (file) return file.slug;
|
|
755
|
+
throw new SlugResolutionError("No slug provided and no `.mist` file in this directory. Pass the slug as an argument or cd into a cloned mist. (if you reverted a commit that removed .mist, pass the slug explicitly or re-run fluid mist clone)");
|
|
739
756
|
}
|
|
740
|
-
//#endregion
|
|
741
|
-
//#region src/types.ts
|
|
742
757
|
/**
|
|
743
|
-
*
|
|
744
|
-
* attached (backfill + redeploy). The CLI VERIFIES these appear with
|
|
745
|
-
* `managed_by: "mist"` — it never sets them (the API 422s on write).
|
|
758
|
+
* Write a `.mist` file after `fluid mist clone`.
|
|
746
759
|
*
|
|
747
|
-
*
|
|
748
|
-
*
|
|
760
|
+
* Also excludes `.mist` via `.git/info/exclude` so it can never be swept
|
|
761
|
+
* into a publish commit by `gitAutoCommit`'s `git add -A` — a commit
|
|
762
|
+
* containing `.mist` that later gets `git revert`-ed deletes the marker
|
|
763
|
+
* and locks every subsequent `fluid mist <cmd>` out of slug resolution
|
|
764
|
+
* in that clone. Best-effort: if the exclude write fails (e.g. `.git`
|
|
765
|
+
* isn't set up yet), `writeFileSync` below still runs — this is a
|
|
766
|
+
* defense-in-depth step, not a precondition for the marker itself.
|
|
749
767
|
*/
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
768
|
+
function writeMistFile(dir, mist) {
|
|
769
|
+
excludeMistMarkerFromGit(dir);
|
|
770
|
+
const payload = {
|
|
771
|
+
slug: mist.slug,
|
|
772
|
+
vendor_name: mist.vendor_name,
|
|
773
|
+
public_url: mist.public_url
|
|
774
|
+
};
|
|
775
|
+
writeFileSync(join(dir, MIST_FILE), JSON.stringify(payload, null, 2) + "\n", "utf8");
|
|
776
|
+
}
|
|
755
777
|
//#endregion
|
|
756
|
-
//#region src/lib/
|
|
778
|
+
//#region src/lib/error-handler.ts
|
|
757
779
|
/**
|
|
758
|
-
*
|
|
759
|
-
*
|
|
760
|
-
*
|
|
761
|
-
* (create a bare mist first, then run this), `fluid mist attach` (any kind),
|
|
762
|
-
* and `fluid mist attach-droplet` (droplet shortcut).
|
|
763
|
-
*
|
|
764
|
-
* Three surface kinds, each with its own create endpoint + attach identifier:
|
|
765
|
-
* - droplet → POST /api/droplets → drp_ uuid → integratable_uuid
|
|
766
|
-
* - drop-zone → POST /api/drop_zones → dz_ uuid → integratable_uuid
|
|
767
|
-
* - mobile-embed→ POST /api/company/mobile_widgets→ numeric id → integratable_id
|
|
780
|
+
* Centralized error handling — maps thrown errors to spec'd exit codes
|
|
781
|
+
* and friendly messages. Imported by every command's `.action()` so each
|
|
782
|
+
* file doesn't repeat the same try/catch.
|
|
768
783
|
*
|
|
769
|
-
*
|
|
770
|
-
*
|
|
771
|
-
* - Attach-existing is idempotent: if an integration point already links the
|
|
772
|
-
* target surface (matched via the #19689 integratable_uuid echo, or the
|
|
773
|
-
* numeric id for mobile), skip. A droplet is 1-per-mist, so creating a new
|
|
774
|
-
* droplet also skips when one is already attached. Drop-zones/mobile
|
|
775
|
-
* embeds are additive (a mist can have many).
|
|
776
|
-
* - Only a droplet attach provisions managed env keys
|
|
777
|
-
* (FLUID_DROPLET_UUID/_SECRET/_WEBHOOK_AUTH_TOKEN); this flow only
|
|
778
|
-
* VERIFIES they landed — it never writes them (the API 422s on write).
|
|
779
|
-
* - A droplet must also be INSTALLED (POST /api/droplet_installations) —
|
|
780
|
-
* create + link alone leave it not-connected/inactive, so install-scoped
|
|
781
|
-
* features (dit/dex exchange, admin embeds, install webhooks) don't work.
|
|
782
|
-
* The install step is idempotent (skips when an installation exists).
|
|
784
|
+
* Exit codes (spec §3 "Conventions"):
|
|
785
|
+
* 0 success | 1 user error | 2 API 4xx | 3 API 5xx / vendor | 4 network
|
|
783
786
|
*/
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
787
|
+
/** Run an async action and translate failures into pretty output + exit. */
|
|
788
|
+
async function runAction(fn) {
|
|
789
|
+
try {
|
|
790
|
+
await fn();
|
|
791
|
+
} catch (err) {
|
|
792
|
+
handle(err);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
function handle(err) {
|
|
796
|
+
if (err instanceof SlugResolutionError) {
|
|
797
|
+
console.error(chalk.red(err.message));
|
|
798
|
+
process.exit(1);
|
|
799
|
+
}
|
|
800
|
+
if (err instanceof HostableParseError) {
|
|
801
|
+
console.error(chalk.red(err.message));
|
|
802
|
+
process.exit(1);
|
|
803
|
+
}
|
|
804
|
+
if (err instanceof MistApiError) {
|
|
805
|
+
if (err.status === 403) {
|
|
806
|
+
const action = err.details?.["permission"];
|
|
807
|
+
console.error(chalk.red(action ? `Your role doesn't include \`mist.${String(action)}\` — ask your Fluid admin to grant it.` : err.message));
|
|
808
|
+
} else if (err.status === 409) {
|
|
809
|
+
const date = err.details?.["scheduled_destroy_at"];
|
|
810
|
+
console.error(chalk.yellow(typeof date === "string" ? `This mist is scheduled for destruction on ${date.slice(0, 10)}. Run \`fluid mist restore <slug>\` first.` : err.message));
|
|
811
|
+
} else if (err.status === 422) {
|
|
812
|
+
const details = err.details ?? {};
|
|
813
|
+
const idErrors = details["integratable_id"] ?? details["hostable_id"];
|
|
814
|
+
const managedKey = typeof err.message === "string" && /managed_by|managed by mist|FLUID_DROPLET|FLUID_WEBHOOK/i.test(err.message);
|
|
815
|
+
if (Array.isArray(idErrors) && idErrors.some((e) => /already taken|already attached/i.test(String(e)))) console.error(chalk.yellow("That droplet is already attached to another mist. Detach it first or pick a different one."));
|
|
816
|
+
else if (managedKey) console.error(chalk.yellow("That env var is Mist-managed (FLUID_DROPLET_UUID / FLUID_DROPLET_SECRET / FLUID_WEBHOOK_AUTH_TOKEN) — it's set automatically when a droplet is attached and can't be written directly."));
|
|
817
|
+
else if (typeof err.message === "string" && /Droplet not found|integratable/i.test(err.message)) {
|
|
818
|
+
const id = details["integratable_id"] ?? details["hostable_id"];
|
|
819
|
+
console.error(chalk.yellow(`Couldn't find droplet${id != null ? ` #${String(id)}` : ""} in this company.`));
|
|
820
|
+
} else console.error(chalk.red(err.message));
|
|
821
|
+
} else if (err.status === 410) console.error(chalk.red("Grace period expired — vendor teardown is in flight. Create a fresh mist with `fluid mist create`."));
|
|
822
|
+
else console.error(chalk.red(err.message));
|
|
823
|
+
process.exit(err.exitCode);
|
|
824
|
+
}
|
|
825
|
+
if (err instanceof Error) console.error(chalk.red(err.message));
|
|
826
|
+
else console.error(chalk.red(String(err)));
|
|
827
|
+
process.exit(1);
|
|
828
|
+
}
|
|
829
|
+
//#endregion
|
|
830
|
+
//#region src/lib/output.ts
|
|
796
831
|
/**
|
|
797
|
-
*
|
|
798
|
-
*
|
|
832
|
+
* Output helpers — human-readable by default, `--json` emits raw API
|
|
833
|
+
* payload. NO_COLOR respected via chalk's built-in detection.
|
|
799
834
|
*/
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
let surfaceUuid = null;
|
|
814
|
-
let surfaceNumericId = null;
|
|
815
|
-
let surfaceName = null;
|
|
816
|
-
let surfaceCreated = false;
|
|
817
|
-
let embedUrlSet = false;
|
|
818
|
-
let integrationPoint;
|
|
819
|
-
const attachingExisting = options.existingUuid != null || options.existingId != null;
|
|
820
|
-
const alreadyLinked = attachingExisting ? findIntegrationPointBySurface(points, {
|
|
821
|
-
kind,
|
|
822
|
-
uuid: options.existingUuid,
|
|
823
|
-
id: options.existingId
|
|
824
|
-
}) : kind === "droplet" ? findDropletIntegrationPoint(points) : void 0;
|
|
825
|
-
if (alreadyLinked) {
|
|
826
|
-
reporter.skip(`${label} already attached (integration #${alreadyLinked.id}) — skipping`);
|
|
827
|
-
surfaceUuid = alreadyLinked.integratable_uuid ?? options.existingUuid ?? null;
|
|
828
|
-
surfaceNumericId = alreadyLinked.integratable_id ?? options.existingId ?? null;
|
|
829
|
-
integrationPoint = {
|
|
830
|
-
id: alreadyLinked.id,
|
|
831
|
-
integratableUuid: alreadyLinked.integratable_uuid ?? null,
|
|
832
|
-
created: false
|
|
833
|
-
};
|
|
834
|
-
} else {
|
|
835
|
-
reporter.succeed("No matching integration yet");
|
|
836
|
-
let attachUuid = options.existingUuid;
|
|
837
|
-
let attachId = options.existingId;
|
|
838
|
-
if (!attachingExisting) {
|
|
839
|
-
const embedUrl = await resolvePublicUrl(client, {
|
|
840
|
-
slugOrId,
|
|
841
|
-
initial: mist.public_url,
|
|
842
|
-
reporter,
|
|
843
|
-
timing,
|
|
844
|
-
sleep
|
|
845
|
-
});
|
|
846
|
-
const name = options.name?.trim() || mist.name?.trim() || mist.vendor_name || mist.slug;
|
|
847
|
-
reporter.start(`Creating ${label} "${name}"…`);
|
|
848
|
-
if (kind === "droplet") {
|
|
849
|
-
const created = await createDroplet(client, { droplet: buildDropletCreateInput({
|
|
850
|
-
name,
|
|
851
|
-
embedUrl,
|
|
852
|
-
requestedScopes: options.requestedScopes
|
|
853
|
-
}) });
|
|
854
|
-
surfaceUuid = created.droplet.uuid;
|
|
855
|
-
surfaceNumericId = created.droplet.id ?? null;
|
|
856
|
-
surfaceName = created.droplet.name;
|
|
857
|
-
attachUuid = created.droplet.uuid;
|
|
858
|
-
embedUrlSet = Boolean(embedUrl);
|
|
859
|
-
reporter.succeed(`Created droplet ${surfaceUuid}${embedUrl ? "" : " (embed URL pending)"}`);
|
|
860
|
-
if (!embedUrl && surfaceUuid) {
|
|
861
|
-
const laterUrl = await resolvePublicUrl(client, {
|
|
862
|
-
slugOrId,
|
|
863
|
-
initial: null,
|
|
864
|
-
reporter,
|
|
865
|
-
timing,
|
|
866
|
-
sleep
|
|
867
|
-
});
|
|
868
|
-
if (laterUrl) {
|
|
869
|
-
reporter.start("Backfilling droplet embed URL…");
|
|
870
|
-
await setDropletEmbedUrl(client, surfaceUuid, laterUrl);
|
|
871
|
-
embedUrlSet = true;
|
|
872
|
-
reporter.succeed("Embed URL set");
|
|
873
|
-
} else reporter.warn("Mist public_url not ready yet — re-run once the first deploy finishes to set the embed URL");
|
|
874
|
-
}
|
|
875
|
-
} else if (kind === "drop-zone") {
|
|
876
|
-
if (!embedUrl) throw new Error("The mist has no public_url yet — wait for the first deploy, then attach the drop zone.");
|
|
877
|
-
const created = await createDropZone(client, { drop_zone: buildDropZoneCreateInput({
|
|
878
|
-
name,
|
|
879
|
-
embedUrl,
|
|
880
|
-
zone: options.zone,
|
|
881
|
-
heightPx: options.heightPx
|
|
882
|
-
}) });
|
|
883
|
-
surfaceUuid = created.drop_zone.uuid;
|
|
884
|
-
surfaceName = created.drop_zone.name;
|
|
885
|
-
attachUuid = created.drop_zone.uuid;
|
|
886
|
-
embedUrlSet = true;
|
|
887
|
-
reporter.succeed(`Created drop zone ${surfaceUuid}`);
|
|
888
|
-
} else {
|
|
889
|
-
if (!embedUrl) throw new Error("The mist has no public_url yet — wait for the first deploy, then attach the mobile embed.");
|
|
890
|
-
const created = await createMobileWidget(client, { mobile_widget: buildMobileWidgetCreateInput({
|
|
891
|
-
name,
|
|
892
|
-
embedUrl,
|
|
893
|
-
coverImageUrl: options.coverImageUrl,
|
|
894
|
-
heightPx: options.heightPx
|
|
895
|
-
}) });
|
|
896
|
-
surfaceNumericId = created.mobile_widget.id;
|
|
897
|
-
surfaceName = created.mobile_widget.name;
|
|
898
|
-
attachId = created.mobile_widget.id;
|
|
899
|
-
embedUrlSet = true;
|
|
900
|
-
reporter.succeed(`Created mobile embed #${surfaceNumericId}`);
|
|
901
|
-
}
|
|
902
|
-
surfaceCreated = true;
|
|
903
|
-
} else {
|
|
904
|
-
surfaceUuid = options.existingUuid ?? null;
|
|
905
|
-
surfaceNumericId = options.existingId ?? null;
|
|
906
|
-
embedUrlSet = true;
|
|
907
|
-
}
|
|
908
|
-
const attachInput = kind === "mobile-embed" ? {
|
|
909
|
-
kind,
|
|
910
|
-
integratable_id: attachId
|
|
911
|
-
} : {
|
|
912
|
-
kind,
|
|
913
|
-
integratable_uuid: attachUuid
|
|
914
|
-
};
|
|
915
|
-
reporter.start(`Attaching ${label}…`);
|
|
916
|
-
let ipResp;
|
|
917
|
-
try {
|
|
918
|
-
ipResp = await createIntegrationPoint(client, slugOrId, attachInput);
|
|
919
|
-
} catch (err) {
|
|
920
|
-
reporter.fail("Attach failed");
|
|
921
|
-
throw normalizeAttachError(err, attachUuid ?? String(attachId), kind);
|
|
922
|
-
}
|
|
923
|
-
integrationPoint = {
|
|
924
|
-
id: ipResp.integration_point.id,
|
|
925
|
-
integratableUuid: ipResp.integration_point.integratable_uuid ?? null,
|
|
926
|
-
created: true
|
|
927
|
-
};
|
|
928
|
-
if (!surfaceUuid) surfaceUuid = ipResp.integration_point.integratable_uuid ?? attachUuid ?? null;
|
|
929
|
-
if (surfaceNumericId == null) surfaceNumericId = ipResp.integration_point.integratable_id ?? null;
|
|
930
|
-
reporter.succeed(kind === "droplet" ? "Droplet attached — provisioning env + redeploy…" : `${label} attached`);
|
|
931
|
-
}
|
|
932
|
-
let installation = null;
|
|
933
|
-
if (kind === "droplet") {
|
|
934
|
-
if (!surfaceUuid && surfaceNumericId != null) try {
|
|
935
|
-
surfaceUuid = await findDropletUuidByNumericId(client, surfaceNumericId);
|
|
936
|
-
} catch {}
|
|
937
|
-
if (!surfaceUuid) {
|
|
938
|
-
reporter.fail("Droplet uuid unknown — cannot install");
|
|
939
|
-
throw new Error("Couldn't determine the droplet uuid, so the droplet is linked but NOT installed — it stays inactive until an installation exists. Re-run with `--existing <droplet-uuid>`, or install manually: POST /api/droplet_installations with {\"droplet_uuid\":\"<uuid>\"}.");
|
|
940
|
-
}
|
|
941
|
-
installation = await ensureDropletInstalled(client, {
|
|
942
|
-
dropletUuid: surfaceUuid,
|
|
943
|
-
scopes: options.requestedScopes ?? DEFAULT_DROPLET_SCOPES,
|
|
944
|
-
reporter
|
|
945
|
-
});
|
|
946
|
-
}
|
|
947
|
-
const managedEnv = kind === "droplet" ? await verifyManagedEnv(client, {
|
|
948
|
-
slugOrId,
|
|
949
|
-
reporter,
|
|
950
|
-
timing,
|
|
951
|
-
sleep,
|
|
952
|
-
enabled: options.verifyEnv !== false
|
|
953
|
-
}) : null;
|
|
954
|
-
return {
|
|
955
|
-
mist: {
|
|
956
|
-
id: mist.id,
|
|
957
|
-
slug: mist.slug,
|
|
958
|
-
name: mist.name,
|
|
959
|
-
public_url: mist.public_url,
|
|
960
|
-
state: mist.state
|
|
961
|
-
},
|
|
962
|
-
kind,
|
|
963
|
-
surface: {
|
|
964
|
-
uuid: surfaceUuid,
|
|
965
|
-
id: surfaceNumericId,
|
|
966
|
-
name: surfaceName
|
|
967
|
-
},
|
|
968
|
-
integrationPoint: integrationPoint ?? {
|
|
969
|
-
id: "",
|
|
970
|
-
integratableUuid: null,
|
|
971
|
-
created: false
|
|
972
|
-
},
|
|
973
|
-
surfaceCreated,
|
|
974
|
-
embedUrlSet,
|
|
975
|
-
installation,
|
|
976
|
-
managedEnv
|
|
977
|
-
};
|
|
835
|
+
function printJson(payload) {
|
|
836
|
+
process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
|
|
837
|
+
}
|
|
838
|
+
const STATE_COLOR$1 = {
|
|
839
|
+
pending: chalk.gray,
|
|
840
|
+
provisioning: chalk.yellow,
|
|
841
|
+
live: chalk.green,
|
|
842
|
+
failed: chalk.red,
|
|
843
|
+
pending_destroy: chalk.magenta,
|
|
844
|
+
archived: chalk.dim
|
|
845
|
+
};
|
|
846
|
+
function colorState(state) {
|
|
847
|
+
return STATE_COLOR$1[state](state);
|
|
978
848
|
}
|
|
979
849
|
/**
|
|
980
|
-
*
|
|
981
|
-
*
|
|
982
|
-
*
|
|
983
|
-
*
|
|
984
|
-
* in place, so an idempotent re-run finishes the job.
|
|
850
|
+
* Bold display label for a mist, falling back to a dim "(unnamed)"
|
|
851
|
+
* placeholder when both `name` and `vendorName` are missing. Never nests
|
|
852
|
+
* `chalk.bold(chalk.dim(...))` — most terminals render that as plain bold,
|
|
853
|
+
* losing the "this is a placeholder" muted intent.
|
|
985
854
|
*/
|
|
986
|
-
|
|
987
|
-
const
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
const confirmed = await lookupInstallation(client, dropletUuid);
|
|
1013
|
-
if (confirmed) {
|
|
1014
|
-
reporter.skip("Droplet already installed — skipping");
|
|
1015
|
-
return {
|
|
1016
|
-
uuid: confirmed.uuid ?? null,
|
|
1017
|
-
created: false
|
|
1018
|
-
};
|
|
1019
|
-
}
|
|
1020
|
-
}
|
|
1021
|
-
reporter.fail("Droplet install failed");
|
|
1022
|
-
throw normalizeInstallError(err, dropletUuid);
|
|
855
|
+
function mistLabel(name, vendorName) {
|
|
856
|
+
const raw = name ?? vendorName;
|
|
857
|
+
return raw ? chalk.bold(raw) : chalk.dim("(unnamed)");
|
|
858
|
+
}
|
|
859
|
+
/**
|
|
860
|
+
* Pad columns to width — cheap manual table; avoids cli-table3 dep for v1.
|
|
861
|
+
* Cells are defensively normalized: API records can legitimately have every
|
|
862
|
+
* optional label field (`name`, `vendor_name`, `droplet.name`, ...) come back
|
|
863
|
+
* null/absent at once (e.g. a `pending_destroy` mist), so callers sometimes
|
|
864
|
+
* hand us `undefined`/`null` after chaining `??` — that must render as an
|
|
865
|
+
* empty cell, not crash the whole table.
|
|
866
|
+
*/
|
|
867
|
+
function printTable(rows) {
|
|
868
|
+
if (rows.length === 0) return;
|
|
869
|
+
const widths = [];
|
|
870
|
+
for (const row of rows) row.forEach((cell, i) => {
|
|
871
|
+
const visible = stripAnsi(cell).length;
|
|
872
|
+
widths[i] = Math.max(widths[i] ?? 0, visible);
|
|
873
|
+
});
|
|
874
|
+
for (const row of rows) {
|
|
875
|
+
const padded = row.map((cell, i) => {
|
|
876
|
+
const w = widths[i] ?? 0;
|
|
877
|
+
const text = cell ?? "";
|
|
878
|
+
return text + " ".repeat(Math.max(0, w - stripAnsi(text).length));
|
|
879
|
+
});
|
|
880
|
+
process.stdout.write(padded.join(" ") + "\n");
|
|
1023
881
|
}
|
|
1024
882
|
}
|
|
1025
|
-
function
|
|
1026
|
-
return
|
|
883
|
+
function stripAnsi(s) {
|
|
884
|
+
return (s ?? "").replace(/\x1b\[[0-9;]*m/g, "");
|
|
1027
885
|
}
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
}
|
|
886
|
+
function relativeDate(iso) {
|
|
887
|
+
if (!iso) return "—";
|
|
888
|
+
const diff = Date.now() - new Date(iso).getTime();
|
|
889
|
+
const minutes = Math.floor(diff / 6e4);
|
|
890
|
+
if (minutes < 1) return "just now";
|
|
891
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
892
|
+
const hours = Math.floor(minutes / 60);
|
|
893
|
+
if (hours < 24) return `${hours}h ago`;
|
|
894
|
+
const days = Math.floor(hours / 24);
|
|
895
|
+
if (days < 30) return `${days}d ago`;
|
|
896
|
+
return new Date(iso).toISOString().slice(0, 10);
|
|
1036
897
|
}
|
|
1037
|
-
/**
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
* work. Surface the droplet uuid + recovery path; keep the MistApiError
|
|
1041
|
-
* status so the error handler exits with the spec'd non-zero code.
|
|
1042
|
-
*/
|
|
1043
|
-
function normalizeInstallError(err, dropletUuid) {
|
|
1044
|
-
const message = `Droplet ${dropletUuid} was created and linked to the mist, but installing it failed: ${err instanceof Error ? err.message : String(err)}. The droplet and its link were left in place — re-run this command to finish the install, or install manually: POST /api/droplet_installations with {"droplet_uuid":"${dropletUuid}"}.`;
|
|
1045
|
-
return err instanceof MistApiError ? new MistApiError(message, err.status, err.details) : new Error(message);
|
|
898
|
+
/** Human-friendly absolute date for one-off prints (scheduled_destroy_at). */
|
|
899
|
+
function formatAbsolute(iso) {
|
|
900
|
+
return new Date(iso).toISOString().slice(0, 10);
|
|
1046
901
|
}
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
902
|
+
//#endregion
|
|
903
|
+
//#region src/commands/list.ts
|
|
904
|
+
/** `fluid mist list` — GET /mists with optional filters. Spec §3. */
|
|
905
|
+
const listCommand = new Command("list").description("List the mists you have access to").option("--state <state>", "Filter by state: live | provisioning | failed | pending_destroy | archived").option("--kind <kind>", "Filter by kind (default: next_app)").option("--limit <n>", "Page size 1..100 (default 25)").option("--json", "Emit raw API JSON").action((opts) => runAction(async () => {
|
|
906
|
+
const client = createMistClient();
|
|
907
|
+
const limit = opts.limit ? parseInt(opts.limit, 10) : void 0;
|
|
908
|
+
const response = await listMists(client, {
|
|
909
|
+
...opts.state ? { state: opts.state } : {},
|
|
910
|
+
...opts.kind ? { kind: opts.kind } : {},
|
|
911
|
+
...limit && Number.isFinite(limit) ? { limit } : {}
|
|
912
|
+
});
|
|
913
|
+
if (opts.json) return printJson(response);
|
|
914
|
+
const mists = response.mists ?? [];
|
|
915
|
+
if (mists.length === 0) {
|
|
916
|
+
console.log(chalk.dim("No mists found."));
|
|
917
|
+
return;
|
|
1056
918
|
}
|
|
1057
|
-
|
|
919
|
+
printTable([[
|
|
920
|
+
chalk.bold("SLUG"),
|
|
921
|
+
chalk.bold("NAME"),
|
|
922
|
+
chalk.bold("STATE"),
|
|
923
|
+
chalk.bold("ATTACHED"),
|
|
924
|
+
chalk.bold("UPDATED")
|
|
925
|
+
], ...mists.map((m) => [
|
|
926
|
+
m.slug,
|
|
927
|
+
m.name ?? m.droplet?.name ?? m.vendor_name ?? chalk.dim("(unnamed)"),
|
|
928
|
+
colorState(m.state),
|
|
929
|
+
m.hostable_type ?? chalk.dim("standalone"),
|
|
930
|
+
relativeDate(m.updated_at)
|
|
931
|
+
])]);
|
|
932
|
+
}));
|
|
933
|
+
//#endregion
|
|
934
|
+
//#region src/api/integration_points.ts
|
|
935
|
+
function basePath(mistId) {
|
|
936
|
+
return `/api/v202604/mists/${mistId}/integration_points`;
|
|
937
|
+
}
|
|
938
|
+
async function listIntegrationPoints(client, slug) {
|
|
939
|
+
const id = await resolveMistId(client, slug);
|
|
940
|
+
return client.get(basePath(id));
|
|
941
|
+
}
|
|
942
|
+
async function createIntegrationPoint(client, slug, input) {
|
|
943
|
+
const id = await resolveMistId(client, slug);
|
|
944
|
+
return client.post(basePath(id), { integration_point: input });
|
|
945
|
+
}
|
|
946
|
+
async function deleteIntegrationPoint(client, slug, integrationPointId) {
|
|
947
|
+
const id = await resolveMistId(client, slug);
|
|
948
|
+
return client.delete(`${basePath(id)}/${integrationPointId}`);
|
|
1058
949
|
}
|
|
1059
950
|
/**
|
|
1060
|
-
*
|
|
1061
|
-
*
|
|
951
|
+
* First droplet-kind integration point in a list, or undefined. A droplet is
|
|
952
|
+
* 1-per-mist (it provisions the managed env), so this is the idempotency key
|
|
953
|
+
* for the droplet create+attach flow.
|
|
1062
954
|
*/
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
args.reporter.start("Waiting for the mist's public URL…");
|
|
1066
|
-
const deadline = Date.now() + args.timing.publicUrlTimeoutMs;
|
|
1067
|
-
for (;;) {
|
|
1068
|
-
const url = (await showMist(client, args.slugOrId)).mist.public_url;
|
|
1069
|
-
if (url) {
|
|
1070
|
-
args.reporter.succeed(`Public URL ready: ${url}`);
|
|
1071
|
-
return url;
|
|
1072
|
-
}
|
|
1073
|
-
if (Date.now() >= deadline) break;
|
|
1074
|
-
await args.sleep(args.timing.publicUrlIntervalMs);
|
|
1075
|
-
}
|
|
1076
|
-
return null;
|
|
955
|
+
function findDropletIntegrationPoint(integrationPoints) {
|
|
956
|
+
return (integrationPoints ?? []).find((ip) => ip.kind === "droplet");
|
|
1077
957
|
}
|
|
1078
958
|
/**
|
|
1079
|
-
*
|
|
1080
|
-
*
|
|
1081
|
-
*
|
|
959
|
+
* Find an integration point that already links a specific surface — matched
|
|
960
|
+
* by uuid (droplet/drop-zone) or numeric id (mobile-embed), optionally scoped
|
|
961
|
+
* to a kind. Used to make attach-existing idempotent (avoid the server's 422
|
|
962
|
+
* "already taken"). Uses the `integratable_uuid` echo (#19689) when present.
|
|
1082
963
|
*/
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
};
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
964
|
+
function findIntegrationPointBySurface(integrationPoints, target) {
|
|
965
|
+
return (integrationPoints ?? []).find((ip) => {
|
|
966
|
+
if (target.kind && ip.kind !== target.kind) return false;
|
|
967
|
+
if (target.uuid && ip.integratable_uuid === target.uuid) return true;
|
|
968
|
+
if (target.id != null && ip.integratable_id === target.id) return true;
|
|
969
|
+
return false;
|
|
970
|
+
});
|
|
971
|
+
}
|
|
972
|
+
//#endregion
|
|
973
|
+
//#region src/commands/show.ts
|
|
974
|
+
/** `fluid mist show <slug>` — single mist detail, including its attached
|
|
975
|
+
* surfaces (integration points). Surfaces scheduled_destroy_at when state is
|
|
976
|
+
* pending_destroy. */
|
|
977
|
+
/** Display ref for an attached surface: the uuid echo when present, else id. */
|
|
978
|
+
function surfaceRef(ip) {
|
|
979
|
+
if (ip.integratable_uuid) return ip.integratable_uuid;
|
|
980
|
+
if (ip.integratable_id != null) return `#${ip.integratable_id}`;
|
|
981
|
+
return "(unknown)";
|
|
982
|
+
}
|
|
983
|
+
const showCommand = new Command("show").description("Show one mist by slug").argument("[slug]", "The mist slug (defaults to .mist file in CWD)").option("--json", "Emit raw API JSON").action((slug, opts) => runAction(async () => {
|
|
984
|
+
const resolved = resolveSlug(slug);
|
|
985
|
+
const client = createMistClient();
|
|
986
|
+
const { mist } = await showMist(client, resolved);
|
|
987
|
+
const integrationPoints = (await listIntegrationPoints(client, resolved)).integration_points;
|
|
988
|
+
if (opts.json) return printJson({
|
|
989
|
+
mist,
|
|
990
|
+
integration_points: integrationPoints
|
|
991
|
+
});
|
|
992
|
+
const lines = [];
|
|
993
|
+
const label = mistLabel(mist.name, mist.vendor_name);
|
|
994
|
+
lines.push(`${label} ${chalk.dim(mist.slug)}`);
|
|
995
|
+
if (mist.vendor_name && mist.name && mist.name !== mist.vendor_name) lines.push(` vendor ${chalk.dim(mist.vendor_name)}`);
|
|
996
|
+
lines.push(` state ${colorState(mist.state)}`);
|
|
997
|
+
if (mist.kind) lines.push(` kind ${mist.kind}`);
|
|
998
|
+
if (mist.public_url) lines.push(` url ${chalk.cyan(mist.public_url)}`);
|
|
999
|
+
if (integrationPoints.length > 0) {
|
|
1000
|
+
lines.push(` surfaces ${integrationPoints.length}`);
|
|
1001
|
+
for (const ip of integrationPoints) lines.push(` ${chalk.dim("·")} ${ip.kind} ${surfaceRef(ip)} ${chalk.dim(`integration ${ip.id}`)}`);
|
|
1108
1002
|
}
|
|
1109
|
-
|
|
1110
|
-
|
|
1003
|
+
if (mist.provisioned_at) lines.push(` live ${relativeDate(mist.provisioned_at)}`);
|
|
1004
|
+
lines.push(` updated ${relativeDate(mist.updated_at)}`);
|
|
1005
|
+
if (mist.scheduled_destroy_at) lines.push(chalk.magenta(` destroy scheduled ${formatAbsolute(mist.scheduled_destroy_at)} (run \`fluid mist restore ${mist.slug}\` to undo)`));
|
|
1006
|
+
console.log(lines.join("\n"));
|
|
1007
|
+
}));
|
|
1008
|
+
//#endregion
|
|
1009
|
+
//#region src/lib/ora-reporter.ts
|
|
1010
|
+
/**
|
|
1011
|
+
* Spinner-backed `StepReporter` for interactive human runs. Split from
|
|
1012
|
+
* `step-reporter.ts` so tests can use the recording reporter without pulling
|
|
1013
|
+
* in `ora`'s ESM-only dependency chain.
|
|
1014
|
+
*/
|
|
1015
|
+
function createOraReporter() {
|
|
1016
|
+
const spinner = ora();
|
|
1111
1017
|
return {
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1018
|
+
start(text) {
|
|
1019
|
+
spinner.start(text);
|
|
1020
|
+
},
|
|
1021
|
+
update(text) {
|
|
1022
|
+
spinner.text = text;
|
|
1023
|
+
},
|
|
1024
|
+
succeed(text) {
|
|
1025
|
+
spinner.succeed(text);
|
|
1026
|
+
},
|
|
1027
|
+
skip(text) {
|
|
1028
|
+
spinner.info(text);
|
|
1029
|
+
},
|
|
1030
|
+
info(text) {
|
|
1031
|
+
spinner.stopAndPersist({
|
|
1032
|
+
symbol: "•",
|
|
1033
|
+
text
|
|
1034
|
+
});
|
|
1035
|
+
},
|
|
1036
|
+
warn(text) {
|
|
1037
|
+
spinner.warn(text);
|
|
1038
|
+
},
|
|
1039
|
+
fail(text) {
|
|
1040
|
+
spinner.fail(text);
|
|
1041
|
+
}
|
|
1115
1042
|
};
|
|
1116
1043
|
}
|
|
1117
1044
|
//#endregion
|
|
1118
|
-
//#region src/lib/
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
mobilewidget: "mobile-embed"
|
|
1131
|
-
};
|
|
1132
|
-
const SURFACE_KIND_LIST = "droplet, drop-zone, mobile-embed";
|
|
1133
|
-
function parseSurfaceKind(raw) {
|
|
1134
|
-
const hit = KIND_ALIASES[raw.trim().toLowerCase()];
|
|
1135
|
-
if (!hit) throw new Error(`Unknown surface kind "${raw}". Use one of: ${SURFACE_KIND_LIST}.`);
|
|
1136
|
-
return hit;
|
|
1045
|
+
//#region src/lib/step-reporter.ts
|
|
1046
|
+
/** No-op reporter for `--json` mode (final payload printed by the command). */
|
|
1047
|
+
function createSilentReporter() {
|
|
1048
|
+
return {
|
|
1049
|
+
start() {},
|
|
1050
|
+
update() {},
|
|
1051
|
+
succeed() {},
|
|
1052
|
+
skip() {},
|
|
1053
|
+
info() {},
|
|
1054
|
+
warn() {},
|
|
1055
|
+
fail() {}
|
|
1056
|
+
};
|
|
1137
1057
|
}
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1058
|
+
//#endregion
|
|
1059
|
+
//#region src/api/droplets.ts
|
|
1060
|
+
const BASE$3 = "/api/droplets";
|
|
1061
|
+
/**
|
|
1062
|
+
* Full scope set from `Droplet::Installation::VALID_SCOPES`. We request
|
|
1063
|
+
* them all by default so a Mist-created droplet's installations carry write
|
|
1064
|
+
* access out of the box — matching what Mist Desktop sends. Callers can
|
|
1065
|
+
* narrow this via the flow's `requestedScopes` option.
|
|
1066
|
+
*/
|
|
1067
|
+
const DEFAULT_DROPLET_SCOPES = [
|
|
1068
|
+
"main",
|
|
1069
|
+
"prospects",
|
|
1070
|
+
"members",
|
|
1071
|
+
"web",
|
|
1072
|
+
"settings",
|
|
1073
|
+
"products",
|
|
1074
|
+
"orders",
|
|
1075
|
+
"users",
|
|
1076
|
+
"website",
|
|
1077
|
+
"header_menus",
|
|
1078
|
+
"payments"
|
|
1079
|
+
];
|
|
1080
|
+
/** Lifecycle webhook paths a Mist Next app serves (mirrors Mist Desktop). */
|
|
1081
|
+
function dropletWebhookUrls(embedUrl) {
|
|
1082
|
+
const base = embedUrl.replace(/\/+$/, "");
|
|
1083
|
+
return {
|
|
1084
|
+
install_webhook_url: `${base}/api/webhooks/installed`,
|
|
1085
|
+
uninstall_webhook_url: `${base}/api/webhooks/uninstalled`
|
|
1086
|
+
};
|
|
1143
1087
|
}
|
|
1144
1088
|
/**
|
|
1145
|
-
*
|
|
1146
|
-
*
|
|
1147
|
-
* - drop-zone requires `--zone <page__zone>` (else it never renders).
|
|
1148
|
-
* - mobile-embed requires `--cover-image <url>` (the API 422s without one; the
|
|
1149
|
-
* CLI has no DAM picker, so the user must supply a url).
|
|
1089
|
+
* Build a droplet create body from a name + (optional) embed URL. When the
|
|
1090
|
+
* embed URL is known we also derive the lifecycle webhook URLs from it.
|
|
1150
1091
|
*/
|
|
1151
|
-
function
|
|
1152
|
-
const
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
};
|
|
1092
|
+
function buildDropletCreateInput(opts) {
|
|
1093
|
+
const scopes = [...opts.requestedScopes ?? DEFAULT_DROPLET_SCOPES];
|
|
1094
|
+
const summary = "Created by Fluid Mist";
|
|
1095
|
+
const input = {
|
|
1096
|
+
name: opts.name,
|
|
1097
|
+
requested_scopes: scopes,
|
|
1098
|
+
settings: {
|
|
1099
|
+
marketplace_page: { summary },
|
|
1100
|
+
details_page: { summary }
|
|
1101
|
+
}
|
|
1102
|
+
};
|
|
1103
|
+
if (opts.embedUrl) {
|
|
1104
|
+
input.embed_url = opts.embedUrl;
|
|
1105
|
+
Object.assign(input, dropletWebhookUrls(opts.embedUrl));
|
|
1166
1106
|
}
|
|
1167
|
-
return
|
|
1107
|
+
return input;
|
|
1168
1108
|
}
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
/** Shared human-readable summary for a surface provision result. */
|
|
1172
|
-
const KIND_LABEL = {
|
|
1173
|
-
droplet: "droplet",
|
|
1174
|
-
"drop-zone": "drop zone",
|
|
1175
|
-
"mobile-embed": "mobile embed"
|
|
1176
|
-
};
|
|
1177
|
-
function printSurfaceSummary(result) {
|
|
1178
|
-
const ref = result.surface.uuid ?? (result.surface.id != null ? `#${result.surface.id}` : "(unknown)");
|
|
1179
|
-
const state = result.surfaceCreated ? "created" : "existing";
|
|
1180
|
-
console.log(chalk.dim(` ${KIND_LABEL[result.kind]}: ${ref} (${state}) · integration ${result.integrationPoint.id}`));
|
|
1181
|
-
if (result.kind === "droplet" && result.installation) {
|
|
1182
|
-
const ref = result.installation.uuid ?? "(uuid unknown)";
|
|
1183
|
-
console.log(chalk.dim(` installation: ${ref} (${result.installation.created ? "installed" : "already installed"})`));
|
|
1184
|
-
}
|
|
1185
|
-
if (result.kind === "droplet" && result.managedEnv) if (result.managedEnv.ready) console.log(chalk.dim(" env: managed keys provisioned ✓"));
|
|
1186
|
-
else console.log(chalk.yellow(" env: redeploy in progress — run `fluid mist env list` shortly to confirm managed keys"));
|
|
1109
|
+
function createDroplet(client, body) {
|
|
1110
|
+
return client.post(BASE$3, body);
|
|
1187
1111
|
}
|
|
1188
|
-
|
|
1189
|
-
|
|
1112
|
+
/** Page size + runaway guard for walking the page-paginated droplets list. */
|
|
1113
|
+
const PER_PAGE$1 = 100;
|
|
1114
|
+
const MAX_PAGES$1 = 25;
|
|
1190
1115
|
/**
|
|
1191
|
-
*
|
|
1192
|
-
*
|
|
1193
|
-
*
|
|
1194
|
-
*
|
|
1195
|
-
*
|
|
1196
|
-
*
|
|
1197
|
-
* "one command that sets everything up" path.
|
|
1198
|
-
* --kind <kind> [flags] Create + attach a surface of any kind:
|
|
1199
|
-
* droplet | drop-zone | mobile-embed. Kind flags:
|
|
1200
|
-
* drop-zone --zone <page__zone> --height <px>
|
|
1201
|
-
* mobile-embed --cover-image <url> --height <px>
|
|
1202
|
-
* --hostable droplet:<uuid> Attach an EXISTING droplet by its drp_… uuid.
|
|
1203
|
-
*
|
|
1204
|
-
* With none of these, creates a plain standalone mist.
|
|
1116
|
+
* Recover a droplet's drp_ uuid from its numeric id by paging the company's
|
|
1117
|
+
* droplets list (`GET /api/droplets?page=&per_page=` — the list rows carry
|
|
1118
|
+
* `id`, unlike the create/show responses). Needed for legacy integration
|
|
1119
|
+
* points that only echo `integratable_id`: the show endpoint resolves by
|
|
1120
|
+
* uuid, so a numeric id can't be fetched directly. Returns null when no
|
|
1121
|
+
* droplet matches within the page cap.
|
|
1205
1122
|
*/
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
].filter(Boolean).length > 1) throw new Error("Pass only one of `--droplet`, `--kind`, or `--hostable` — they're mutually exclusive.");
|
|
1215
|
-
let kind;
|
|
1216
|
-
let existingDropletUuid;
|
|
1217
|
-
let nameOverride;
|
|
1218
|
-
if (wantDroplet) {
|
|
1219
|
-
kind = "droplet";
|
|
1220
|
-
nameOverride = typeof opts.droplet === "string" ? opts.droplet.trim() : void 0;
|
|
1221
|
-
} else if (wantKind) kind = parseSurfaceKind(opts.kind);
|
|
1222
|
-
else if (wantHostable) {
|
|
1223
|
-
kind = "droplet";
|
|
1224
|
-
existingDropletUuid = parseDropletRef(opts.hostable);
|
|
1225
|
-
}
|
|
1226
|
-
const mistBody = { kind: opts.mistKind ?? "next_app" };
|
|
1227
|
-
if (opts.name?.trim()) mistBody.name = opts.name.trim();
|
|
1228
|
-
const client = createMistClient();
|
|
1229
|
-
const reporter = opts.json ? createSilentReporter() : createOraReporter();
|
|
1230
|
-
reporter.start("Setting up your Mist Cloud environment…");
|
|
1231
|
-
let created;
|
|
1232
|
-
try {
|
|
1233
|
-
created = await createMist(client, { mist: mistBody });
|
|
1234
|
-
} catch (err) {
|
|
1235
|
-
reporter.fail("Provisioning failed");
|
|
1236
|
-
throw err;
|
|
1123
|
+
async function findDropletUuidByNumericId(client, id) {
|
|
1124
|
+
for (let page = 1; page <= MAX_PAGES$1; page++) {
|
|
1125
|
+
const resp = await client.get(`${BASE$3}?page=${page}&per_page=${PER_PAGE$1}`);
|
|
1126
|
+
const batch = resp.droplets ?? [];
|
|
1127
|
+
const match = batch.find((d) => d.id === id);
|
|
1128
|
+
if (match) return match.uuid ?? null;
|
|
1129
|
+
const totalPages = resp.meta?.pagination?.total_pages;
|
|
1130
|
+
if (totalPages != null ? page >= totalPages : batch.length < PER_PAGE$1) break;
|
|
1237
1131
|
}
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
...
|
|
1249
|
-
});
|
|
1250
|
-
|
|
1251
|
-
mist,
|
|
1252
|
-
provision
|
|
1253
|
-
} : created);
|
|
1254
|
-
const label = mistLabel(mist.name, mist.vendor_name);
|
|
1255
|
-
console.log();
|
|
1256
|
-
console.log(`${label} ${chalk.dim(mist.slug)} ${colorState(mist.state)}`);
|
|
1257
|
-
if (mist.public_url) console.log(` ${chalk.cyan(mist.public_url)}`);
|
|
1258
|
-
if (provision) printSurfaceSummary(provision);
|
|
1259
|
-
console.log();
|
|
1260
|
-
console.log(chalk.dim(`Clone with: fluid mist clone ${mist.slug}`));
|
|
1261
|
-
}));
|
|
1132
|
+
return null;
|
|
1133
|
+
}
|
|
1134
|
+
/** PATCH the droplet's embed URL (+ derived webhook URLs) once known. */
|
|
1135
|
+
function updateDroplet(client, uuid, input) {
|
|
1136
|
+
return client.patch(`${BASE$3}/${encodeURIComponent(uuid)}`, { droplet: input });
|
|
1137
|
+
}
|
|
1138
|
+
/** Convenience: set embed URL + derived webhook URLs in one PATCH. */
|
|
1139
|
+
function setDropletEmbedUrl(client, uuid, embedUrl) {
|
|
1140
|
+
return updateDroplet(client, uuid, {
|
|
1141
|
+
embed_url: embedUrl,
|
|
1142
|
+
...dropletWebhookUrls(embedUrl)
|
|
1143
|
+
});
|
|
1144
|
+
}
|
|
1262
1145
|
//#endregion
|
|
1263
|
-
//#region src/
|
|
1146
|
+
//#region src/api/droplet_installations.ts
|
|
1147
|
+
const BASE$2 = "/api/droplet_installations";
|
|
1148
|
+
/** Page size + runaway guard for walking the page-paginated list. */
|
|
1149
|
+
const PER_PAGE = 100;
|
|
1150
|
+
const MAX_PAGES = 25;
|
|
1264
1151
|
/**
|
|
1265
|
-
*
|
|
1266
|
-
*
|
|
1267
|
-
*
|
|
1268
|
-
*
|
|
1269
|
-
*
|
|
1270
|
-
* --kind mobile-embed --cover-image <url> [--height <px>]
|
|
1271
|
-
*
|
|
1272
|
-
* Attach an EXISTING surface instead of creating one with `--existing <ref>`:
|
|
1273
|
-
* droplet/drop-zone → droplet:drp_… / drop-zone:dz_… / bare uuid
|
|
1274
|
-
* mobile-embed → mobile-embed:<numericId>
|
|
1275
|
-
*
|
|
1276
|
-
* Idempotent for droplet + attach-existing (skips when already linked).
|
|
1152
|
+
* List ALL of the current company's droplet installations, following the
|
|
1153
|
+
* endpoint's page-number pagination (`?page=&per_page=` — same shape as
|
|
1154
|
+
* fluid-admin's `getCompanyDroplets`). A single-page fetch would miss rows
|
|
1155
|
+
* beyond the first page, making the idempotency check (and the duplicate-
|
|
1156
|
+
* rejection confirmation) report a false "not installed" forever.
|
|
1277
1157
|
*/
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1158
|
+
async function listAllDropletInstallations(client) {
|
|
1159
|
+
const rows = [];
|
|
1160
|
+
for (let page = 1; page <= MAX_PAGES; page++) {
|
|
1161
|
+
const resp = await client.get(`${BASE$2}?page=${page}&per_page=${PER_PAGE}`);
|
|
1162
|
+
const batch = resp.droplet_installations ?? [];
|
|
1163
|
+
rows.push(...batch);
|
|
1164
|
+
const totalPages = resp.meta?.pagination?.total_pages;
|
|
1165
|
+
if (totalPages != null ? page >= totalPages : batch.length < PER_PAGE) break;
|
|
1286
1166
|
}
|
|
1287
|
-
return
|
|
1167
|
+
return rows;
|
|
1168
|
+
}
|
|
1169
|
+
/** Install a droplet (by drp_ uuid) for the current company. */
|
|
1170
|
+
function createDropletInstallation(client, input) {
|
|
1171
|
+
return client.post(BASE$2, input);
|
|
1288
1172
|
}
|
|
1289
|
-
const attachCommand = new Command("attach").description(`Create a surface (${SURFACE_KIND_LIST}) and wire it to an existing mist`).argument("[slug]", "Mist slug (defaults to the .mist file in CWD)").requiredOption("--kind <kind>", `Surface kind: ${SURFACE_KIND_LIST}`).option("--name <label>", "Surface name (defaults to the mist's name)").option("--zone <slot>", "drop-zone slot id: page__zone (e.g. checkout__below_total)").option("--height <px>", "Embed height in px (drop-zone + mobile-embed)").option("--cover-image <url>", "Mobile-embed cover image url (required for mobile-embed)").option("--existing <ref>", "Attach an existing surface instead of creating one (uuid, or numeric id for mobile-embed)").option("--no-wait", "Don't wait for managed env keys to appear after attach").option("--json", "Emit raw JSON").action((slug, opts) => runAction(async () => {
|
|
1290
|
-
const kind = parseSurfaceKind(opts.kind);
|
|
1291
|
-
const resolved = resolveSlug(slug);
|
|
1292
|
-
const client = createMistClient();
|
|
1293
|
-
const reporter = opts.json ? createSilentReporter() : createOraReporter();
|
|
1294
|
-
const existing = opts.existing ? parseExisting(kind, opts.existing) : void 0;
|
|
1295
|
-
const result = await provisionSurface(client, {
|
|
1296
|
-
slugOrId: resolved,
|
|
1297
|
-
kind,
|
|
1298
|
-
name: opts.name?.trim(),
|
|
1299
|
-
verifyEnv: opts.wait !== false,
|
|
1300
|
-
reporter,
|
|
1301
|
-
...existing,
|
|
1302
|
-
...existing ? {} : surfaceProvisionOptionsFromFlags(kind, opts)
|
|
1303
|
-
});
|
|
1304
|
-
if (opts.json) return printJson(result);
|
|
1305
|
-
console.log();
|
|
1306
|
-
console.log(`${chalk.bold(result.mist.name ?? result.mist.slug)} ${chalk.dim(result.mist.slug)}`);
|
|
1307
|
-
printSurfaceSummary(result);
|
|
1308
|
-
}));
|
|
1309
|
-
//#endregion
|
|
1310
|
-
//#region src/commands/attach-droplet.ts
|
|
1311
1173
|
/**
|
|
1312
|
-
*
|
|
1313
|
-
*
|
|
1314
|
-
*
|
|
1315
|
-
*
|
|
1316
|
-
*
|
|
1317
|
-
* name Optional droplet name (defaults to the mist's name).
|
|
1318
|
-
* slug Optional mist slug (defaults to the `.mist` file in CWD).
|
|
1174
|
+
* The company's installation row for a droplet drp_ uuid, or undefined.
|
|
1175
|
+
* The idempotency key for the install step: an existing row means the
|
|
1176
|
+
* droplet is already connected (installed via a previous run, Mist Desktop,
|
|
1177
|
+
* or the admin marketplace).
|
|
1319
1178
|
*/
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
const reporter = opts.json ? createSilentReporter() : createOraReporter();
|
|
1324
|
-
const result = await provisionSurface(client, {
|
|
1325
|
-
slugOrId: resolved,
|
|
1326
|
-
kind: "droplet",
|
|
1327
|
-
name: name?.trim() || void 0,
|
|
1328
|
-
verifyEnv: opts.wait !== false,
|
|
1329
|
-
reporter
|
|
1330
|
-
});
|
|
1331
|
-
if (opts.json) return printJson(result);
|
|
1332
|
-
console.log();
|
|
1333
|
-
console.log(`${chalk.bold(result.mist.name ?? result.mist.slug)} ${chalk.dim(result.mist.slug)}`);
|
|
1334
|
-
printSurfaceSummary(result);
|
|
1335
|
-
}));
|
|
1179
|
+
function findInstallationForDroplet(installations, dropletUuid) {
|
|
1180
|
+
return (installations ?? []).find((i) => i.droplet_uuid === dropletUuid);
|
|
1181
|
+
}
|
|
1336
1182
|
//#endregion
|
|
1337
|
-
//#region src/
|
|
1183
|
+
//#region src/api/drop_zones.ts
|
|
1184
|
+
const BASE$1 = "/api/drop_zones";
|
|
1338
1185
|
/**
|
|
1339
|
-
*
|
|
1340
|
-
*
|
|
1341
|
-
*
|
|
1342
|
-
*
|
|
1343
|
-
* --hostable droplet:<uuid> Attach a droplet by uuid. No-ops if that same
|
|
1344
|
-
* droplet is already attached; replaces it if a
|
|
1345
|
-
* different droplet is attached (droplet is
|
|
1346
|
-
* 1-per-mist — it provisions the managed env).
|
|
1347
|
-
* --detach Remove the droplet integration point.
|
|
1348
|
-
*
|
|
1349
|
-
* The integration-point link is immutable (no update route, no `active`
|
|
1350
|
-
* field) — enablement lives on the surface record, so there's no toggle here.
|
|
1351
|
-
* All ops re-derive state from the integration-points index (which echoes
|
|
1352
|
-
* `integratable_uuid` since #19689) and only make the calls that change it.
|
|
1186
|
+
* Build a drop-zone create body. The slot id is `page__zone` (same split the
|
|
1187
|
+
* storefront preview uses, e.g. `checkout__below_total`) → `settings.page` +
|
|
1188
|
+
* `settings.zone`; height (px) → `settings.height`. Without a slot the zone
|
|
1189
|
+
* never renders, so the backend needs it to know WHERE to inject.
|
|
1353
1190
|
*/
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
if (opts.
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
if (opts.hostable || opts.detach) {
|
|
1369
|
-
const existing = findDropletIntegrationPoint((await listIntegrationPoints(client, resolved)).integration_points);
|
|
1370
|
-
if (opts.hostable) {
|
|
1371
|
-
const dropletUuid = parseDropletRef(opts.hostable);
|
|
1372
|
-
if (existing && existing.integratable_uuid === dropletUuid) notes.push(`droplet ${dropletUuid} already attached (integration ${existing.id})`);
|
|
1373
|
-
else {
|
|
1374
|
-
if (existing) {
|
|
1375
|
-
await deleteIntegrationPoint(client, resolved, existing.id);
|
|
1376
|
-
notes.push(`replaced integration ${existing.id}`);
|
|
1377
|
-
}
|
|
1378
|
-
const ip = await createIntegrationPoint(client, resolved, {
|
|
1379
|
-
kind: "droplet",
|
|
1380
|
-
integratable_uuid: dropletUuid
|
|
1381
|
-
});
|
|
1382
|
-
notes.push(`attached droplet ${dropletUuid} (integration ${ip.integration_point.id})`);
|
|
1383
|
-
}
|
|
1384
|
-
} else if (opts.detach) if (existing) {
|
|
1385
|
-
await deleteIntegrationPoint(client, resolved, existing.id);
|
|
1386
|
-
notes.push(`detached droplet (removed integration ${existing.id})`);
|
|
1387
|
-
} else notes.push("already standalone (no droplet integration)");
|
|
1388
|
-
}
|
|
1389
|
-
if (opts.json) return printJson(renameResp ? {
|
|
1390
|
-
...renameResp,
|
|
1391
|
-
slug: resolved,
|
|
1392
|
-
notes
|
|
1393
|
-
} : {
|
|
1394
|
-
slug: resolved,
|
|
1395
|
-
notes
|
|
1396
|
-
});
|
|
1397
|
-
for (const note of notes) console.log(chalk.dim(` ${note}`));
|
|
1398
|
-
}));
|
|
1191
|
+
function buildDropZoneCreateInput(opts) {
|
|
1192
|
+
const [page, zone] = (opts.zone ?? "").split("__");
|
|
1193
|
+
const settings = {};
|
|
1194
|
+
if (page) settings.page = page;
|
|
1195
|
+
if (zone) settings.zone = zone;
|
|
1196
|
+
if (opts.heightPx != null) settings.height = opts.heightPx;
|
|
1197
|
+
const input = { name: opts.name };
|
|
1198
|
+
if (opts.embedUrl) input.embed_url = opts.embedUrl;
|
|
1199
|
+
if (Object.keys(settings).length > 0) input.settings = settings;
|
|
1200
|
+
return input;
|
|
1201
|
+
}
|
|
1202
|
+
function createDropZone(client, body) {
|
|
1203
|
+
return client.post(BASE$1, body);
|
|
1204
|
+
}
|
|
1399
1205
|
//#endregion
|
|
1400
|
-
//#region src/
|
|
1206
|
+
//#region src/api/mobile_widgets.ts
|
|
1207
|
+
const BASE = "/api/company/mobile_widgets";
|
|
1401
1208
|
/**
|
|
1402
|
-
*
|
|
1403
|
-
*
|
|
1209
|
+
* Build a mobile-widget create body. `cover_image_url` and `height` are
|
|
1210
|
+
* REQUIRED by the API (422 without them); we fall back to the Fluid
|
|
1211
|
+
* placeholder + default height when the caller doesn't supply them.
|
|
1404
1212
|
*/
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
message: "Continue?",
|
|
1418
|
-
initial: false
|
|
1419
|
-
}, { onCancel: () => process.exit(130) });
|
|
1420
|
-
if (!confirmed) {
|
|
1421
|
-
console.log(chalk.dim("Aborted."));
|
|
1422
|
-
process.exit(0);
|
|
1423
|
-
}
|
|
1424
|
-
}
|
|
1425
|
-
const { mist } = await deleteMist(client, resolved);
|
|
1426
|
-
if (opts.json) return printJson({ mist });
|
|
1427
|
-
console.log(chalk.magenta(`Scheduled for destruction on ${mist.scheduled_destroy_at ? formatAbsolute(mist.scheduled_destroy_at) : "the 15-day mark"}.`));
|
|
1428
|
-
console.log(chalk.dim(`Vendor resources stay alive until then; run \`fluid mist restore ${mist.slug}\` to undo.`));
|
|
1429
|
-
}));
|
|
1430
|
-
//#endregion
|
|
1431
|
-
//#region src/commands/restore.ts
|
|
1432
|
-
/** `fluid mist restore` — un-delete within the 15-day window. Spec §3. */
|
|
1433
|
-
const restoreCommand = new Command("restore").description("Cancel a scheduled mist destruction (15-day window)").argument("[slug]", "The mist slug (defaults to .mist file in CWD)").option("--json", "Emit raw API JSON").action((slug, opts) => runAction(async () => {
|
|
1434
|
-
const resolved = resolveSlug(slug);
|
|
1435
|
-
const { mist } = await restoreMist(createMistClient(), resolved);
|
|
1436
|
-
if (opts.json) return printJson({ mist });
|
|
1437
|
-
console.log(`Restored: ${mist.public_url ? chalk.cyan(mist.public_url) : mist.name ?? mist.vendor_name ?? mist.slug} (${colorState(mist.state)})`);
|
|
1438
|
-
}));
|
|
1213
|
+
function buildMobileWidgetCreateInput(opts) {
|
|
1214
|
+
return {
|
|
1215
|
+
name: opts.name,
|
|
1216
|
+
embed_url: opts.embedUrl,
|
|
1217
|
+
cover_image_url: opts.coverImageUrl || "https://ik.imagekit.io/fluid/s3/app/image-placeholder.svg",
|
|
1218
|
+
height: opts.heightPx ?? 600,
|
|
1219
|
+
scrollable: true
|
|
1220
|
+
};
|
|
1221
|
+
}
|
|
1222
|
+
function createMobileWidget(client, body) {
|
|
1223
|
+
return client.post(BASE, body);
|
|
1224
|
+
}
|
|
1439
1225
|
//#endregion
|
|
1440
|
-
//#region src/api/
|
|
1441
|
-
|
|
1226
|
+
//#region src/api/env_vars.ts
|
|
1227
|
+
function path$1(id, key) {
|
|
1228
|
+
return key ? `/api/v202604/mists/${id}/env_vars/${encodeURIComponent(key)}` : `/api/v202604/mists/${id}/env_vars`;
|
|
1229
|
+
}
|
|
1230
|
+
async function listEnvVars(client, slug) {
|
|
1231
|
+
const id = await resolveMistId(client, slug);
|
|
1232
|
+
return client.get(path$1(id));
|
|
1233
|
+
}
|
|
1234
|
+
/** Create a new env var (the spec routes `set` to POST for new keys). */
|
|
1235
|
+
async function createEnvVar(client, slug, key, value, targets) {
|
|
1236
|
+
const id = await resolveMistId(client, slug);
|
|
1237
|
+
return client.post(path$1(id), { env_var: {
|
|
1238
|
+
key,
|
|
1239
|
+
value,
|
|
1240
|
+
targets
|
|
1241
|
+
} });
|
|
1242
|
+
}
|
|
1243
|
+
/** Update an existing env var (the spec routes `set` to PATCH for existing keys). */
|
|
1244
|
+
async function updateEnvVar(client, slug, key, value, targets) {
|
|
1442
1245
|
const id = await resolveMistId(client, slug);
|
|
1443
|
-
return client.
|
|
1246
|
+
return client.patch(path$1(id, key), { env_var: {
|
|
1247
|
+
value,
|
|
1248
|
+
targets
|
|
1249
|
+
} });
|
|
1250
|
+
}
|
|
1251
|
+
async function deleteEnvVar(client, slug, key) {
|
|
1252
|
+
const id = await resolveMistId(client, slug);
|
|
1253
|
+
return client.delete(path$1(id, key));
|
|
1444
1254
|
}
|
|
1445
1255
|
//#endregion
|
|
1446
|
-
//#region src/
|
|
1256
|
+
//#region src/types.ts
|
|
1447
1257
|
/**
|
|
1448
|
-
*
|
|
1449
|
-
*
|
|
1450
|
-
*
|
|
1258
|
+
* Managed env keys the backend writes onto a Mist once a droplet is
|
|
1259
|
+
* attached (backfill + redeploy). The CLI VERIFIES these appear with
|
|
1260
|
+
* `managed_by: "mist"` — it never sets them (the API 422s on write).
|
|
1451
1261
|
*
|
|
1452
|
-
*
|
|
1453
|
-
*
|
|
1454
|
-
* stripped from the URL and handed to git through a GIT_ASKPASS script via
|
|
1455
|
-
* env vars (MIST_GIT_USER / MIST_GIT_PASS) — so it never appears in argv
|
|
1456
|
-
* (visible to `ps aux` on macOS, `/proc/<pid>/cmdline` on Linux) and is
|
|
1457
|
-
* never written to `.git/config`.
|
|
1262
|
+
* Source: settled backend contract (#19685 auto-provisions the droplet
|
|
1263
|
+
* env on integration-point create).
|
|
1458
1264
|
*/
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
};
|
|
1265
|
+
const MANAGED_DROPLET_ENV_KEYS = [
|
|
1266
|
+
"FLUID_DROPLET_UUID",
|
|
1267
|
+
"FLUID_DROPLET_SECRET",
|
|
1268
|
+
"FLUID_WEBHOOK_AUTH_TOKEN"
|
|
1269
|
+
];
|
|
1270
|
+
//#endregion
|
|
1271
|
+
//#region src/lib/surface-provision.ts
|
|
1467
1272
|
/**
|
|
1468
|
-
*
|
|
1469
|
-
*
|
|
1470
|
-
*
|
|
1471
|
-
*
|
|
1472
|
-
*
|
|
1473
|
-
* account linked to their GitHub account"). Authoring every publish commit as
|
|
1474
|
-
* the Mist GitHub App bot — which the Vercel integration recognizes — keeps
|
|
1475
|
-
* deploys unblocked regardless of the user's local/global git identity. Human
|
|
1476
|
-
* attribution is preserved in the commit *message* (see push.ts), not the
|
|
1477
|
-
* author field.
|
|
1273
|
+
* Surface provisioning engine — the one place the "create a Fluid surface and
|
|
1274
|
+
* wire it to a mist" flow lives, at parity with Mist Desktop's
|
|
1275
|
+
* mist-integration-points-api.service.ts. Exposed via `fluid mist create`
|
|
1276
|
+
* (create a bare mist first, then run this), `fluid mist attach` (any kind),
|
|
1277
|
+
* and `fluid mist attach-droplet` (droplet shortcut).
|
|
1478
1278
|
*
|
|
1479
|
-
*
|
|
1480
|
-
*
|
|
1279
|
+
* Three surface kinds, each with its own create endpoint + attach identifier:
|
|
1280
|
+
* - droplet → POST /api/droplets → drp_ uuid → integratable_uuid
|
|
1281
|
+
* - drop-zone → POST /api/drop_zones → dz_ uuid → integratable_uuid
|
|
1282
|
+
* - mobile-embed→ POST /api/company/mobile_widgets→ numeric id → integratable_id
|
|
1283
|
+
*
|
|
1284
|
+
* Design tenets (repo philosophy — "server is source of truth"):
|
|
1285
|
+
* - No local progress file. State is re-derived from the API each run.
|
|
1286
|
+
* - Attach-existing is idempotent: if an integration point already links the
|
|
1287
|
+
* target surface (matched via the #19689 integratable_uuid echo, or the
|
|
1288
|
+
* numeric id for mobile), skip. A droplet is 1-per-mist, so creating a new
|
|
1289
|
+
* droplet also skips when one is already attached. Drop-zones/mobile
|
|
1290
|
+
* embeds are additive (a mist can have many).
|
|
1291
|
+
* - Only a droplet attach provisions managed env keys
|
|
1292
|
+
* (FLUID_DROPLET_UUID/_SECRET/_WEBHOOK_AUTH_TOKEN); this flow only
|
|
1293
|
+
* VERIFIES they landed — it never writes them (the API 422s on write).
|
|
1294
|
+
* - A droplet must also be INSTALLED (POST /api/droplet_installations) —
|
|
1295
|
+
* create + link alone leave it not-connected/inactive, so install-scoped
|
|
1296
|
+
* features (dit/dex exchange, admin embeds, install webhooks) don't work.
|
|
1297
|
+
* The install step is idempotent (skips when an installation exists).
|
|
1481
1298
|
*/
|
|
1482
|
-
const
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1299
|
+
const DEFAULT_TIMING = {
|
|
1300
|
+
publicUrlTimeoutMs: 9e4,
|
|
1301
|
+
publicUrlIntervalMs: 3e3,
|
|
1302
|
+
envTimeoutMs: 12e4,
|
|
1303
|
+
envIntervalMs: 5e3
|
|
1304
|
+
};
|
|
1305
|
+
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
1306
|
+
const KIND_LABEL$1 = {
|
|
1307
|
+
droplet: "droplet",
|
|
1308
|
+
"drop-zone": "drop zone",
|
|
1309
|
+
"mobile-embed": "mobile embed"
|
|
1310
|
+
};
|
|
1493
1311
|
/**
|
|
1494
|
-
*
|
|
1495
|
-
*
|
|
1496
|
-
* `username` / `password` back through GIT_ASKPASS env vars.
|
|
1497
|
-
*
|
|
1498
|
-
* If the URL has no embedded credentials, returns empty strings — fine
|
|
1499
|
-
* for public repos where git won't even invoke askpass.
|
|
1312
|
+
* Create (or reuse) a surface of `kind` and wire it to the mist. Idempotent
|
|
1313
|
+
* for droplet + attach-existing; additive for new drop-zones/mobile embeds.
|
|
1500
1314
|
*/
|
|
1501
|
-
function
|
|
1502
|
-
const
|
|
1503
|
-
const
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1315
|
+
async function provisionSurface(client, options) {
|
|
1316
|
+
const { reporter, slugOrId, kind } = options;
|
|
1317
|
+
const timing = {
|
|
1318
|
+
...DEFAULT_TIMING,
|
|
1319
|
+
...options.timing
|
|
1320
|
+
};
|
|
1321
|
+
const sleep = options.sleep ?? defaultSleep;
|
|
1322
|
+
const label = KIND_LABEL$1[kind];
|
|
1323
|
+
reporter.start("Resolving mist…");
|
|
1324
|
+
const mist = (await showMist(client, slugOrId)).mist;
|
|
1325
|
+
reporter.succeed(`Mist ${mist.slug} (${mist.state})`);
|
|
1326
|
+
reporter.start("Checking existing integrations…");
|
|
1327
|
+
const points = (await listIntegrationPoints(client, slugOrId)).integration_points;
|
|
1328
|
+
let surfaceUuid = null;
|
|
1329
|
+
let surfaceNumericId = null;
|
|
1330
|
+
let surfaceName = null;
|
|
1331
|
+
let surfaceCreated = false;
|
|
1332
|
+
let embedUrlSet = false;
|
|
1333
|
+
let integrationPoint;
|
|
1334
|
+
const attachingExisting = options.existingUuid != null || options.existingId != null;
|
|
1335
|
+
const alreadyLinked = attachingExisting ? findIntegrationPointBySurface(points, {
|
|
1336
|
+
kind,
|
|
1337
|
+
uuid: options.existingUuid,
|
|
1338
|
+
id: options.existingId
|
|
1339
|
+
}) : kind === "droplet" ? findDropletIntegrationPoint(points) : void 0;
|
|
1340
|
+
if (alreadyLinked) {
|
|
1341
|
+
reporter.skip(`${label} already attached (integration #${alreadyLinked.id}) — skipping`);
|
|
1342
|
+
surfaceUuid = alreadyLinked.integratable_uuid ?? options.existingUuid ?? null;
|
|
1343
|
+
surfaceNumericId = alreadyLinked.integratable_id ?? options.existingId ?? null;
|
|
1344
|
+
integrationPoint = {
|
|
1345
|
+
id: alreadyLinked.id,
|
|
1346
|
+
integratableUuid: alreadyLinked.integratable_uuid ?? null,
|
|
1347
|
+
created: false
|
|
1348
|
+
};
|
|
1349
|
+
} else {
|
|
1350
|
+
reporter.succeed("No matching integration yet");
|
|
1351
|
+
let attachUuid = options.existingUuid;
|
|
1352
|
+
let attachId = options.existingId;
|
|
1353
|
+
if (!attachingExisting) {
|
|
1354
|
+
const embedUrl = await resolvePublicUrl(client, {
|
|
1355
|
+
slugOrId,
|
|
1356
|
+
initial: mist.public_url,
|
|
1357
|
+
reporter,
|
|
1358
|
+
timing,
|
|
1359
|
+
sleep
|
|
1360
|
+
});
|
|
1361
|
+
const name = options.name?.trim() || mist.name?.trim() || mist.vendor_name || mist.slug;
|
|
1362
|
+
reporter.start(`Creating ${label} "${name}"…`);
|
|
1363
|
+
if (kind === "droplet") {
|
|
1364
|
+
const created = await createDroplet(client, { droplet: buildDropletCreateInput({
|
|
1365
|
+
name,
|
|
1366
|
+
embedUrl,
|
|
1367
|
+
requestedScopes: options.requestedScopes
|
|
1368
|
+
}) });
|
|
1369
|
+
surfaceUuid = created.droplet.uuid;
|
|
1370
|
+
surfaceNumericId = created.droplet.id ?? null;
|
|
1371
|
+
surfaceName = created.droplet.name;
|
|
1372
|
+
attachUuid = created.droplet.uuid;
|
|
1373
|
+
embedUrlSet = Boolean(embedUrl);
|
|
1374
|
+
reporter.succeed(`Created droplet ${surfaceUuid}${embedUrl ? "" : " (embed URL pending)"}`);
|
|
1375
|
+
if (!embedUrl && surfaceUuid) {
|
|
1376
|
+
const laterUrl = await resolvePublicUrl(client, {
|
|
1377
|
+
slugOrId,
|
|
1378
|
+
initial: null,
|
|
1379
|
+
reporter,
|
|
1380
|
+
timing,
|
|
1381
|
+
sleep
|
|
1382
|
+
});
|
|
1383
|
+
if (laterUrl) {
|
|
1384
|
+
reporter.start("Backfilling droplet embed URL…");
|
|
1385
|
+
await setDropletEmbedUrl(client, surfaceUuid, laterUrl);
|
|
1386
|
+
embedUrlSet = true;
|
|
1387
|
+
reporter.succeed("Embed URL set");
|
|
1388
|
+
} else reporter.warn("Mist public_url not ready yet — re-run once the first deploy finishes to set the embed URL");
|
|
1389
|
+
}
|
|
1390
|
+
} else if (kind === "drop-zone") {
|
|
1391
|
+
if (!embedUrl) throw new Error("The mist has no public_url yet — wait for the first deploy, then attach the drop zone.");
|
|
1392
|
+
const created = await createDropZone(client, { drop_zone: buildDropZoneCreateInput({
|
|
1393
|
+
name,
|
|
1394
|
+
embedUrl,
|
|
1395
|
+
zone: options.zone,
|
|
1396
|
+
heightPx: options.heightPx
|
|
1397
|
+
}) });
|
|
1398
|
+
surfaceUuid = created.drop_zone.uuid;
|
|
1399
|
+
surfaceName = created.drop_zone.name;
|
|
1400
|
+
attachUuid = created.drop_zone.uuid;
|
|
1401
|
+
embedUrlSet = true;
|
|
1402
|
+
reporter.succeed(`Created drop zone ${surfaceUuid}`);
|
|
1403
|
+
} else {
|
|
1404
|
+
if (!embedUrl) throw new Error("The mist has no public_url yet — wait for the first deploy, then attach the mobile embed.");
|
|
1405
|
+
const created = await createMobileWidget(client, { mobile_widget: buildMobileWidgetCreateInput({
|
|
1406
|
+
name,
|
|
1407
|
+
embedUrl,
|
|
1408
|
+
coverImageUrl: options.coverImageUrl,
|
|
1409
|
+
heightPx: options.heightPx
|
|
1410
|
+
}) });
|
|
1411
|
+
surfaceNumericId = created.mobile_widget.id;
|
|
1412
|
+
surfaceName = created.mobile_widget.name;
|
|
1413
|
+
attachId = created.mobile_widget.id;
|
|
1414
|
+
embedUrlSet = true;
|
|
1415
|
+
reporter.succeed(`Created mobile embed #${surfaceNumericId}`);
|
|
1416
|
+
}
|
|
1417
|
+
surfaceCreated = true;
|
|
1418
|
+
} else {
|
|
1419
|
+
surfaceUuid = options.existingUuid ?? null;
|
|
1420
|
+
surfaceNumericId = options.existingId ?? null;
|
|
1421
|
+
embedUrlSet = true;
|
|
1422
|
+
}
|
|
1423
|
+
const attachInput = kind === "mobile-embed" ? {
|
|
1424
|
+
kind,
|
|
1425
|
+
integratable_id: attachId
|
|
1426
|
+
} : {
|
|
1427
|
+
kind,
|
|
1428
|
+
integratable_uuid: attachUuid
|
|
1429
|
+
};
|
|
1430
|
+
reporter.start(`Attaching ${label}…`);
|
|
1431
|
+
let ipResp;
|
|
1432
|
+
try {
|
|
1433
|
+
ipResp = await createIntegrationPoint(client, slugOrId, attachInput);
|
|
1434
|
+
} catch (err) {
|
|
1435
|
+
reporter.fail("Attach failed");
|
|
1436
|
+
throw normalizeAttachError(err, attachUuid ?? String(attachId), kind);
|
|
1437
|
+
}
|
|
1438
|
+
integrationPoint = {
|
|
1439
|
+
id: ipResp.integration_point.id,
|
|
1440
|
+
integratableUuid: ipResp.integration_point.integratable_uuid ?? null,
|
|
1441
|
+
created: true
|
|
1442
|
+
};
|
|
1443
|
+
if (!surfaceUuid) surfaceUuid = ipResp.integration_point.integratable_uuid ?? attachUuid ?? null;
|
|
1444
|
+
if (surfaceNumericId == null) surfaceNumericId = ipResp.integration_point.integratable_id ?? null;
|
|
1445
|
+
reporter.succeed(kind === "droplet" ? "Droplet attached — provisioning env + redeploy…" : `${label} attached`);
|
|
1446
|
+
}
|
|
1447
|
+
let installation = null;
|
|
1448
|
+
if (kind === "droplet") {
|
|
1449
|
+
if (!surfaceUuid && surfaceNumericId != null) try {
|
|
1450
|
+
surfaceUuid = await findDropletUuidByNumericId(client, surfaceNumericId);
|
|
1451
|
+
} catch {}
|
|
1452
|
+
if (!surfaceUuid) {
|
|
1453
|
+
reporter.fail("Droplet uuid unknown — cannot install");
|
|
1454
|
+
throw new Error("Couldn't determine the droplet uuid, so the droplet is linked but NOT installed — it stays inactive until an installation exists. Re-run with `--existing <droplet-uuid>`, or install manually: POST /api/droplet_installations with {\"droplet_uuid\":\"<uuid>\"}.");
|
|
1455
|
+
}
|
|
1456
|
+
installation = await ensureDropletInstalled(client, {
|
|
1457
|
+
dropletUuid: surfaceUuid,
|
|
1458
|
+
scopes: options.requestedScopes ?? DEFAULT_DROPLET_SCOPES,
|
|
1459
|
+
reporter
|
|
1460
|
+
});
|
|
1461
|
+
}
|
|
1462
|
+
const managedEnv = kind === "droplet" ? await verifyManagedEnv(client, {
|
|
1463
|
+
slugOrId,
|
|
1464
|
+
reporter,
|
|
1465
|
+
timing,
|
|
1466
|
+
sleep,
|
|
1467
|
+
enabled: options.verifyEnv !== false
|
|
1468
|
+
}) : null;
|
|
1507
1469
|
return {
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1470
|
+
mist: {
|
|
1471
|
+
id: mist.id,
|
|
1472
|
+
slug: mist.slug,
|
|
1473
|
+
name: mist.name,
|
|
1474
|
+
public_url: mist.public_url,
|
|
1475
|
+
state: mist.state
|
|
1476
|
+
},
|
|
1477
|
+
kind,
|
|
1478
|
+
surface: {
|
|
1479
|
+
uuid: surfaceUuid,
|
|
1480
|
+
id: surfaceNumericId,
|
|
1481
|
+
name: surfaceName
|
|
1482
|
+
},
|
|
1483
|
+
integrationPoint: integrationPoint ?? {
|
|
1484
|
+
id: "",
|
|
1485
|
+
integratableUuid: null,
|
|
1486
|
+
created: false
|
|
1487
|
+
},
|
|
1488
|
+
surfaceCreated,
|
|
1489
|
+
embedUrlSet,
|
|
1490
|
+
installation,
|
|
1491
|
+
managedEnv
|
|
1511
1492
|
};
|
|
1512
1493
|
}
|
|
1513
1494
|
/**
|
|
1514
|
-
*
|
|
1515
|
-
*
|
|
1516
|
-
*
|
|
1517
|
-
*
|
|
1518
|
-
*
|
|
1519
|
-
* duration of the call.
|
|
1520
|
-
*
|
|
1521
|
-
* One POSIX script for all platforms: Git for Windows executes
|
|
1522
|
-
* GIT_ASKPASS through its bundled sh, so a .cmd askpass fails with
|
|
1523
|
-
* "unable to read askpass response" (git 2.52.0.windows.1) while a
|
|
1524
|
-
* plain sh script works.
|
|
1525
|
-
*
|
|
1526
|
-
* Idempotent: subsequent calls reuse the same script path.
|
|
1527
|
-
*/
|
|
1528
|
-
let askpassPath = null;
|
|
1529
|
-
function ensureAskpassScript() {
|
|
1530
|
-
if (askpassPath && existsSync(askpassPath)) return askpassPath;
|
|
1531
|
-
const dir = path.join(os.tmpdir(), `mist-cli-askpass-${process.pid}`);
|
|
1532
|
-
if (!existsSync(dir)) mkdirSync(dir, {
|
|
1533
|
-
recursive: true,
|
|
1534
|
-
mode: 448
|
|
1535
|
-
});
|
|
1536
|
-
const p = path.join(dir, "askpass.sh");
|
|
1537
|
-
writeFileSync(p, `#!/bin/sh\ncase "$1" in\n Username*) printf '%s' "$MIST_GIT_USER" ;;\n *) printf '%s' "$MIST_GIT_PASS" ;;\nesac\n`, { mode: 448 });
|
|
1538
|
-
chmodSync(p, 448);
|
|
1539
|
-
askpassPath = p;
|
|
1540
|
-
return askpassPath;
|
|
1541
|
-
}
|
|
1542
|
-
/**
|
|
1543
|
-
* Build the spawn env + cleaned URL for a credentialed git invocation.
|
|
1544
|
-
* Returns:
|
|
1545
|
-
* - `cleanUrl`: URL with userinfo stripped — safe to pass as argv.
|
|
1546
|
-
* - `env`: parent env plus GIT_ASKPASS + MIST_GIT_USER / MIST_GIT_PASS.
|
|
1547
|
-
* GIT_TERMINAL_PROMPT=0 prevents the TTY-prompt fallback if
|
|
1548
|
-
* the credentials are empty (we want auth to fail fast, not
|
|
1549
|
-
* hang the desktop waiting for interactive input).
|
|
1495
|
+
* Install the droplet for the current company (idempotent). Checks the
|
|
1496
|
+
* installations list first; treats the API's already-installed rejection as
|
|
1497
|
+
* success too, so re-runs and races both converge. A hard failure throws with
|
|
1498
|
+
* the droplet uuid + recovery steps — the created droplet and mist link stay
|
|
1499
|
+
* in place, so an idempotent re-run finishes the job.
|
|
1550
1500
|
*/
|
|
1551
|
-
function
|
|
1552
|
-
const {
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
env: {
|
|
1556
|
-
...process.env,
|
|
1557
|
-
GIT_ASKPASS: ensureAskpassScript(),
|
|
1558
|
-
GIT_TERMINAL_PROMPT: "0",
|
|
1559
|
-
MIST_GIT_USER: username,
|
|
1560
|
-
MIST_GIT_PASS: password
|
|
1561
|
-
}
|
|
1562
|
-
};
|
|
1563
|
-
}
|
|
1564
|
-
/** Confirm git is installed. Cheap call once at startup. */
|
|
1565
|
-
async function ensureGitAvailable() {
|
|
1501
|
+
async function ensureDropletInstalled(client, args) {
|
|
1502
|
+
const { reporter, dropletUuid } = args;
|
|
1503
|
+
reporter.start("Installing droplet…");
|
|
1504
|
+
let existing;
|
|
1566
1505
|
try {
|
|
1567
|
-
await
|
|
1568
|
-
} catch {
|
|
1569
|
-
|
|
1506
|
+
existing = findInstallationForDroplet(await listAllDropletInstallations(client), dropletUuid);
|
|
1507
|
+
} catch {}
|
|
1508
|
+
if (existing) {
|
|
1509
|
+
reporter.skip(`Droplet already installed${existing.uuid ? ` (${existing.uuid})` : ""} — skipping`);
|
|
1510
|
+
return {
|
|
1511
|
+
uuid: existing.uuid ?? null,
|
|
1512
|
+
created: false
|
|
1513
|
+
};
|
|
1570
1514
|
}
|
|
1571
|
-
}
|
|
1572
|
-
/**
|
|
1573
|
-
* `git clone <cleanUrl> <targetDir>`. The original remoteUrl carries a
|
|
1574
|
-
* token; gitAuthSpawn strips it from the URL and re-injects it via
|
|
1575
|
-
* GIT_ASKPASS so it never appears in argv. `credential.helper=` stops
|
|
1576
|
-
* git from caching what askpass returned to any system credential
|
|
1577
|
-
* store.
|
|
1578
|
-
*/
|
|
1579
|
-
async function gitClone(remoteUrl, targetDir) {
|
|
1580
|
-
const { cleanUrl, env } = gitAuthSpawn(remoteUrl);
|
|
1581
1515
|
try {
|
|
1582
|
-
await
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
stdio: [
|
|
1592
|
-
"ignore",
|
|
1593
|
-
"inherit",
|
|
1594
|
-
"inherit"
|
|
1595
|
-
]
|
|
1596
|
-
});
|
|
1516
|
+
const uuid = (await createDropletInstallation(client, {
|
|
1517
|
+
droplet_uuid: dropletUuid,
|
|
1518
|
+
scopes: [...args.scopes]
|
|
1519
|
+
})).droplet_installation?.uuid ?? null;
|
|
1520
|
+
reporter.succeed(`Droplet installed${uuid ? ` (${uuid})` : ""}`);
|
|
1521
|
+
return {
|
|
1522
|
+
uuid,
|
|
1523
|
+
created: true
|
|
1524
|
+
};
|
|
1597
1525
|
} catch (err) {
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1526
|
+
if (isAlreadyInstalledError(err)) {
|
|
1527
|
+
const confirmed = await lookupInstallation(client, dropletUuid);
|
|
1528
|
+
if (confirmed) {
|
|
1529
|
+
reporter.skip("Droplet already installed — skipping");
|
|
1530
|
+
return {
|
|
1531
|
+
uuid: confirmed.uuid ?? null,
|
|
1532
|
+
created: false
|
|
1533
|
+
};
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
reporter.fail("Droplet install failed");
|
|
1537
|
+
throw normalizeInstallError(err, dropletUuid);
|
|
1602
1538
|
}
|
|
1603
|
-
await ensureBotIdentity(targetDir);
|
|
1604
1539
|
}
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1540
|
+
function isAlreadyInstalledError(err) {
|
|
1541
|
+
return err instanceof MistApiError && (err.status === 409 || err.status === 422) && /already|taken|installed|exists/i.test(`${err.message} ${JSON.stringify(err.details ?? {})}`);
|
|
1542
|
+
}
|
|
1543
|
+
/** Best-effort installation lookup for a droplet; null when the row can't
|
|
1544
|
+
* be found or the list call fails. */
|
|
1545
|
+
async function lookupInstallation(client, dropletUuid) {
|
|
1610
1546
|
try {
|
|
1611
|
-
await
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
"-c",
|
|
1615
|
-
`http.https://github.com/.extraheader=`,
|
|
1616
|
-
"push",
|
|
1617
|
-
cleanUrl,
|
|
1618
|
-
`HEAD:${branch}`
|
|
1619
|
-
], {
|
|
1620
|
-
cwd,
|
|
1621
|
-
env,
|
|
1622
|
-
stdio: [
|
|
1623
|
-
"ignore",
|
|
1624
|
-
"inherit",
|
|
1625
|
-
"inherit"
|
|
1626
|
-
]
|
|
1627
|
-
});
|
|
1628
|
-
} catch (err) {
|
|
1629
|
-
const missing = gitMissingError(err);
|
|
1630
|
-
if (missing) throw missing;
|
|
1631
|
-
const e = err;
|
|
1632
|
-
throw new GitError(`git push failed${e.exitCode != null ? ` (exit ${e.exitCode})` : ""}`, e.exitCode ?? 1);
|
|
1547
|
+
return findInstallationForDroplet(await listAllDropletInstallations(client), dropletUuid) ?? null;
|
|
1548
|
+
} catch {
|
|
1549
|
+
return null;
|
|
1633
1550
|
}
|
|
1634
1551
|
}
|
|
1635
|
-
/**
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
});
|
|
1655
|
-
} catch (err) {
|
|
1656
|
-
const missing = gitMissingError(err);
|
|
1657
|
-
if (missing) throw missing;
|
|
1658
|
-
const e = err;
|
|
1659
|
-
throw new GitError(`git pull failed${e.exitCode != null ? ` (exit ${e.exitCode})` : ""}`, e.exitCode ?? 1);
|
|
1552
|
+
/**
|
|
1553
|
+
* Install failures must NOT read as "wired up" — an uninstalled droplet means
|
|
1554
|
+
* dit/dex token exchange, admin embeds, and install webhooks silently don't
|
|
1555
|
+
* work. Surface the droplet uuid + recovery path; keep the MistApiError
|
|
1556
|
+
* status so the error handler exits with the spec'd non-zero code.
|
|
1557
|
+
*/
|
|
1558
|
+
function normalizeInstallError(err, dropletUuid) {
|
|
1559
|
+
const message = `Droplet ${dropletUuid} was created and linked to the mist, but installing it failed: ${err instanceof Error ? err.message : String(err)}. The droplet and its link were left in place — re-run this command to finish the install, or install manually: POST /api/droplet_installations with {"droplet_uuid":"${dropletUuid}"}.`;
|
|
1560
|
+
return err instanceof MistApiError ? new MistApiError(message, err.status, err.details) : new Error(message);
|
|
1561
|
+
}
|
|
1562
|
+
/**
|
|
1563
|
+
* If the integration-points endpoint rejects the attach identifier (e.g. an
|
|
1564
|
+
* older API that predates #19688's attach-by-uuid support), surface an
|
|
1565
|
+
* actionable message noting the surface WAS created. Other errors pass through.
|
|
1566
|
+
*/
|
|
1567
|
+
function normalizeAttachError(err, surfaceRef, kind) {
|
|
1568
|
+
if (err instanceof MistApiError && (err.status === 400 || err.status === 422)) {
|
|
1569
|
+
const blob = `${err.message} ${JSON.stringify(err.details ?? {})}`;
|
|
1570
|
+
if (/integratable_uuid|integratable_id|unpermitted|unknown|param/i.test(blob)) return new MistApiError(`The ${KIND_LABEL$1[kind]} ${surfaceRef} was created, but this API rejected the attach (needs the #19688 integration-points endpoint). Re-run once the API is updated.`, err.status, err.details);
|
|
1660
1571
|
}
|
|
1572
|
+
return err instanceof Error ? err : new Error(String(err));
|
|
1661
1573
|
}
|
|
1662
1574
|
/**
|
|
1663
|
-
*
|
|
1664
|
-
*
|
|
1665
|
-
* otherwise — so the count is just "is the output non-trivial".
|
|
1575
|
+
* Return the mist's public_url, polling briefly if it isn't ready yet
|
|
1576
|
+
* (async-first-deploy). Returns null if it never appears within the window.
|
|
1666
1577
|
*/
|
|
1667
|
-
async function
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1578
|
+
async function resolvePublicUrl(client, args) {
|
|
1579
|
+
if (args.initial) return args.initial;
|
|
1580
|
+
args.reporter.start("Waiting for the mist's public URL…");
|
|
1581
|
+
const deadline = Date.now() + args.timing.publicUrlTimeoutMs;
|
|
1582
|
+
for (;;) {
|
|
1583
|
+
const url = (await showMist(client, args.slugOrId)).mist.public_url;
|
|
1584
|
+
if (url) {
|
|
1585
|
+
args.reporter.succeed(`Public URL ready: ${url}`);
|
|
1586
|
+
return url;
|
|
1587
|
+
}
|
|
1588
|
+
if (Date.now() >= deadline) break;
|
|
1589
|
+
await args.sleep(args.timing.publicUrlIntervalMs);
|
|
1673
1590
|
}
|
|
1591
|
+
return null;
|
|
1674
1592
|
}
|
|
1675
1593
|
/**
|
|
1676
|
-
*
|
|
1677
|
-
* `
|
|
1678
|
-
*
|
|
1679
|
-
* Same security stance as gitPush — installation token comes through
|
|
1680
|
-
* GIT_ASKPASS, never argv or `.git/config`.
|
|
1594
|
+
* Poll `GET …/env_vars` until the Mist-managed droplet keys appear with
|
|
1595
|
+
* `managed_by: "mist"`. Never fails the flow — a redeploy in progress is a
|
|
1596
|
+
* normal, transient state we simply report.
|
|
1681
1597
|
*/
|
|
1682
|
-
async function
|
|
1683
|
-
const
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1598
|
+
async function verifyManagedEnv(client, args) {
|
|
1599
|
+
const required = [...MANAGED_DROPLET_ENV_KEYS];
|
|
1600
|
+
if (!args.enabled) return {
|
|
1601
|
+
ready: false,
|
|
1602
|
+
present: [],
|
|
1603
|
+
missing: required
|
|
1604
|
+
};
|
|
1605
|
+
args.reporter.start("Verifying managed environment…");
|
|
1606
|
+
const deadline = Date.now() + args.timing.envTimeoutMs;
|
|
1607
|
+
let present = [];
|
|
1608
|
+
for (;;) {
|
|
1609
|
+
const envVars = (await listEnvVars(client, args.slugOrId)).env_vars ?? [];
|
|
1610
|
+
const managed = new Set(envVars.filter((e) => e.managed_by === "mist").map((e) => e.key));
|
|
1611
|
+
present = required.filter((k) => managed.has(k));
|
|
1612
|
+
if (present.length === required.length) {
|
|
1613
|
+
args.reporter.succeed("Managed env keys present — droplet is wired up");
|
|
1614
|
+
return {
|
|
1615
|
+
ready: true,
|
|
1616
|
+
present,
|
|
1617
|
+
missing: []
|
|
1618
|
+
};
|
|
1619
|
+
}
|
|
1620
|
+
if (Date.now() >= deadline) break;
|
|
1621
|
+
args.reporter.update(`Verifying managed environment… (${present.length}/${required.length} keys, redeploy in progress)`);
|
|
1622
|
+
await args.sleep(args.timing.envIntervalMs);
|
|
1701
1623
|
}
|
|
1624
|
+
const missing = required.filter((k) => !present.includes(k));
|
|
1625
|
+
args.reporter.warn(`Redeploy in progress — managed keys not visible yet (${present.length}/${required.length}). Check later with \`fluid mist env list\`. Missing: ${missing.join(", ")}`);
|
|
1626
|
+
return {
|
|
1627
|
+
ready: false,
|
|
1628
|
+
present,
|
|
1629
|
+
missing
|
|
1630
|
+
};
|
|
1631
|
+
}
|
|
1632
|
+
//#endregion
|
|
1633
|
+
//#region src/lib/surface-flags.ts
|
|
1634
|
+
const KIND_ALIASES = {
|
|
1635
|
+
droplet: "droplet",
|
|
1636
|
+
droplets: "droplet",
|
|
1637
|
+
"drop-zone": "drop-zone",
|
|
1638
|
+
drop_zone: "drop-zone",
|
|
1639
|
+
dropzone: "drop-zone",
|
|
1640
|
+
zone: "drop-zone",
|
|
1641
|
+
"mobile-embed": "mobile-embed",
|
|
1642
|
+
mobile_embed: "mobile-embed",
|
|
1643
|
+
mobile: "mobile-embed",
|
|
1644
|
+
"mobile-widget": "mobile-embed",
|
|
1645
|
+
mobilewidget: "mobile-embed"
|
|
1646
|
+
};
|
|
1647
|
+
const SURFACE_KIND_LIST = "droplet, drop-zone, mobile-embed";
|
|
1648
|
+
function parseSurfaceKind(raw) {
|
|
1649
|
+
const hit = KIND_ALIASES[raw.trim().toLowerCase()];
|
|
1650
|
+
if (!hit) throw new Error(`Unknown surface kind "${raw}". Use one of: ${SURFACE_KIND_LIST}.`);
|
|
1651
|
+
return hit;
|
|
1652
|
+
}
|
|
1653
|
+
function parseHeight(raw) {
|
|
1654
|
+
if (raw == null) return void 0;
|
|
1655
|
+
const n = Number(raw);
|
|
1656
|
+
if (!Number.isInteger(n) || n <= 0) throw new Error(`Invalid --height "${raw}". Must be a positive integer (px).`);
|
|
1657
|
+
return n;
|
|
1702
1658
|
}
|
|
1703
1659
|
/**
|
|
1704
|
-
*
|
|
1705
|
-
*
|
|
1706
|
-
*
|
|
1660
|
+
* Map CLI flags to the kind-specific `provisionSurface` options, enforcing the
|
|
1661
|
+
* per-kind requirements the API needs:
|
|
1662
|
+
* - drop-zone requires `--zone <page__zone>` (else it never renders).
|
|
1663
|
+
* - mobile-embed requires `--cover-image <url>` (the API 422s without one; the
|
|
1664
|
+
* CLI has no DAM picker, so the user must supply a url).
|
|
1707
1665
|
*/
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
"--left-right",
|
|
1713
|
-
"--count",
|
|
1714
|
-
"HEAD...FETCH_HEAD"
|
|
1715
|
-
], { cwd });
|
|
1716
|
-
const [a, b] = stdout.trim().split(/\s+/);
|
|
1717
|
-
const ahead = Number.parseInt(a ?? "0", 10);
|
|
1718
|
-
const behind = Number.parseInt(b ?? "0", 10);
|
|
1666
|
+
function surfaceProvisionOptionsFromFlags(kind, flags) {
|
|
1667
|
+
const heightPx = parseHeight(flags.height);
|
|
1668
|
+
if (kind === "drop-zone") {
|
|
1669
|
+
if (!flags.zone?.trim()) throw new Error("A drop-zone needs a slot — pass `--zone <page__zone>` (e.g. --zone checkout__below_total).");
|
|
1719
1670
|
return {
|
|
1720
|
-
|
|
1721
|
-
|
|
1671
|
+
zone: flags.zone.trim(),
|
|
1672
|
+
heightPx
|
|
1722
1673
|
};
|
|
1723
|
-
}
|
|
1674
|
+
}
|
|
1675
|
+
if (kind === "mobile-embed") {
|
|
1676
|
+
if (!flags.coverImage?.trim()) throw new Error("A mobile-embed needs a cover image — pass `--cover-image <url>` (the mobile API requires one; the CLI has no image picker).");
|
|
1724
1677
|
return {
|
|
1725
|
-
|
|
1726
|
-
|
|
1678
|
+
coverImageUrl: flags.coverImage.trim(),
|
|
1679
|
+
heightPx
|
|
1727
1680
|
};
|
|
1728
1681
|
}
|
|
1682
|
+
return {};
|
|
1729
1683
|
}
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
}
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1684
|
+
//#endregion
|
|
1685
|
+
//#region src/lib/surface-summary.ts
|
|
1686
|
+
/** Shared human-readable summary for a surface provision result. */
|
|
1687
|
+
const KIND_LABEL = {
|
|
1688
|
+
droplet: "droplet",
|
|
1689
|
+
"drop-zone": "drop zone",
|
|
1690
|
+
"mobile-embed": "mobile embed"
|
|
1691
|
+
};
|
|
1692
|
+
function printSurfaceSummary(result) {
|
|
1693
|
+
const ref = result.surface.uuid ?? (result.surface.id != null ? `#${result.surface.id}` : "(unknown)");
|
|
1694
|
+
const state = result.surfaceCreated ? "created" : "existing";
|
|
1695
|
+
console.log(chalk.dim(` ${KIND_LABEL[result.kind]}: ${ref} (${state}) · integration ${result.integrationPoint.id}`));
|
|
1696
|
+
if (result.kind === "droplet" && result.installation) {
|
|
1697
|
+
const ref = result.installation.uuid ?? "(uuid unknown)";
|
|
1698
|
+
console.log(chalk.dim(` installation: ${ref} (${result.installation.created ? "installed" : "already installed"})`));
|
|
1744
1699
|
}
|
|
1700
|
+
if (result.kind === "droplet" && result.managedEnv) if (result.managedEnv.ready) console.log(chalk.dim(" env: managed keys provisioned ✓"));
|
|
1701
|
+
else console.log(chalk.yellow(" env: redeploy in progress — run `fluid mist env list` shortly to confirm managed keys"));
|
|
1745
1702
|
}
|
|
1703
|
+
//#endregion
|
|
1704
|
+
//#region src/commands/create.ts
|
|
1746
1705
|
/**
|
|
1747
|
-
*
|
|
1748
|
-
*
|
|
1749
|
-
*
|
|
1706
|
+
* `fluid mist create` — provision a new mist, optionally creating and attaching
|
|
1707
|
+
* a surface in one step. All attach paths go through integration points
|
|
1708
|
+
* (hostable is retired — #19684/#19687).
|
|
1709
|
+
*
|
|
1710
|
+
* --droplet [name] Create a NEW droplet and attach it. Bare →
|
|
1711
|
+
* droplet name defaults to the mist name. The
|
|
1712
|
+
* "one command that sets everything up" path.
|
|
1713
|
+
* --kind <kind> [flags] Create + attach a surface of any kind:
|
|
1714
|
+
* droplet | drop-zone | mobile-embed. Kind flags:
|
|
1715
|
+
* drop-zone --zone <page__zone> --height <px>
|
|
1716
|
+
* mobile-embed --cover-image <url> --height <px>
|
|
1717
|
+
* --hostable droplet:<uuid> Attach an EXISTING droplet by its drp_… uuid.
|
|
1718
|
+
*
|
|
1719
|
+
* With none of these, creates a plain standalone mist.
|
|
1750
1720
|
*/
|
|
1751
|
-
|
|
1721
|
+
const createCommand = new Command("create").description("Provision a new mist (optionally create + attach a surface in one step)").option("--droplet [name]", "Create a droplet and attach it. Bare uses the mist name; pass a value to override.").option("--kind <kind>", "Create + attach a surface: droplet | drop-zone | mobile-embed").option("--hostable <ref>", "Attach an existing droplet by uuid: droplet:<uuid> (e.g. droplet:drp_abc123)").option("--name <label>", "Display label for the mist (and the surface)").option("--zone <slot>", "drop-zone slot id: page__zone (e.g. checkout__below_total)").option("--height <px>", "Embed height in px (drop-zone + mobile-embed)").option("--cover-image <url>", "Mobile-embed cover image url (required for mobile-embed)").option("--mist-kind <kind>", "Mist kind (default: next_app)").option("--no-wait", "Don't wait for managed env keys to appear after attach").option("--json", "Emit raw JSON").action((opts) => runAction(async () => {
|
|
1722
|
+
const wantDroplet = opts.droplet !== void 0;
|
|
1723
|
+
const wantKind = !!opts.kind;
|
|
1724
|
+
const wantHostable = !!opts.hostable;
|
|
1725
|
+
if ([
|
|
1726
|
+
wantDroplet,
|
|
1727
|
+
wantKind,
|
|
1728
|
+
wantHostable
|
|
1729
|
+
].filter(Boolean).length > 1) throw new Error("Pass only one of `--droplet`, `--kind`, or `--hostable` — they're mutually exclusive.");
|
|
1730
|
+
let kind;
|
|
1731
|
+
let existingDropletUuid;
|
|
1732
|
+
let nameOverride;
|
|
1733
|
+
if (wantDroplet) {
|
|
1734
|
+
kind = "droplet";
|
|
1735
|
+
nameOverride = typeof opts.droplet === "string" ? opts.droplet.trim() : void 0;
|
|
1736
|
+
} else if (wantKind) kind = parseSurfaceKind(opts.kind);
|
|
1737
|
+
else if (wantHostable) {
|
|
1738
|
+
kind = "droplet";
|
|
1739
|
+
existingDropletUuid = parseDropletRef(opts.hostable);
|
|
1740
|
+
}
|
|
1741
|
+
const mistBody = { kind: opts.mistKind ?? "next_app" };
|
|
1742
|
+
if (opts.name?.trim()) mistBody.name = opts.name.trim();
|
|
1743
|
+
const client = createMistClient();
|
|
1744
|
+
const reporter = opts.json ? createSilentReporter() : createOraReporter();
|
|
1745
|
+
reporter.start("Setting up your Mist Cloud environment…");
|
|
1746
|
+
let created;
|
|
1752
1747
|
try {
|
|
1753
|
-
await
|
|
1754
|
-
"stash",
|
|
1755
|
-
"push",
|
|
1756
|
-
"--include-untracked",
|
|
1757
|
-
"--message",
|
|
1758
|
-
message
|
|
1759
|
-
], { cwd });
|
|
1748
|
+
created = await createMist(client, { mist: mistBody });
|
|
1760
1749
|
} catch (err) {
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
throw new GitError(`git stash push failed`, err.exitCode ?? 1);
|
|
1750
|
+
reporter.fail("Provisioning failed");
|
|
1751
|
+
throw err;
|
|
1764
1752
|
}
|
|
1765
|
-
|
|
1753
|
+
const mist = created.mist;
|
|
1754
|
+
reporter.succeed(`Provisioned ${mist.slug}`);
|
|
1755
|
+
let provision;
|
|
1756
|
+
if (kind) provision = await provisionSurface(client, {
|
|
1757
|
+
slugOrId: mist.slug,
|
|
1758
|
+
kind,
|
|
1759
|
+
name: nameOverride ?? opts.name?.trim(),
|
|
1760
|
+
existingUuid: existingDropletUuid,
|
|
1761
|
+
verifyEnv: opts.wait !== false,
|
|
1762
|
+
reporter,
|
|
1763
|
+
...surfaceProvisionOptionsFromFlags(kind, opts)
|
|
1764
|
+
});
|
|
1765
|
+
if (opts.json) return printJson(provision ? {
|
|
1766
|
+
mist,
|
|
1767
|
+
provision
|
|
1768
|
+
} : created);
|
|
1769
|
+
const label = mistLabel(mist.name, mist.vendor_name);
|
|
1770
|
+
console.log();
|
|
1771
|
+
console.log(`${label} ${chalk.dim(mist.slug)} ${colorState(mist.state)}`);
|
|
1772
|
+
if (mist.public_url) console.log(` ${chalk.cyan(mist.public_url)}`);
|
|
1773
|
+
if (provision) printSurfaceSummary(provision);
|
|
1774
|
+
console.log();
|
|
1775
|
+
console.log(chalk.dim(`Clone with: fluid mist clone ${mist.slug}`));
|
|
1776
|
+
}));
|
|
1777
|
+
//#endregion
|
|
1778
|
+
//#region src/commands/attach.ts
|
|
1766
1779
|
/**
|
|
1767
|
-
*
|
|
1768
|
-
*
|
|
1769
|
-
*
|
|
1780
|
+
* `fluid mist attach --kind <kind> [flags] [slug]` — create a surface and wire
|
|
1781
|
+
* it to an existing mist. One command for all three kinds:
|
|
1782
|
+
*
|
|
1783
|
+
* --kind droplet (managed-env-provisioning droplet)
|
|
1784
|
+
* --kind drop-zone --zone page__zone [--height <px>]
|
|
1785
|
+
* --kind mobile-embed --cover-image <url> [--height <px>]
|
|
1786
|
+
*
|
|
1787
|
+
* Attach an EXISTING surface instead of creating one with `--existing <ref>`:
|
|
1788
|
+
* droplet/drop-zone → droplet:drp_… / drop-zone:dz_… / bare uuid
|
|
1789
|
+
* mobile-embed → mobile-embed:<numericId>
|
|
1790
|
+
*
|
|
1791
|
+
* Idempotent for droplet + attach-existing (skips when already linked).
|
|
1770
1792
|
*/
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1793
|
+
/** Parse `--existing <ref>` into a uuid or numeric id for the given kind. */
|
|
1794
|
+
function parseExisting(kind, raw) {
|
|
1795
|
+
const trimmed = (raw.includes(":") ? raw.slice(raw.lastIndexOf(":") + 1) : raw).trim();
|
|
1796
|
+
if (!trimmed) throw new Error(`Empty --existing reference "${raw}".`);
|
|
1797
|
+
if (kind === "mobile-embed") {
|
|
1798
|
+
const id = Number(trimmed);
|
|
1799
|
+
if (!Number.isInteger(id) || id <= 0) throw new Error(`mobile-embed attaches by numeric id — pass \`--existing <id>\` (got "${raw}").`);
|
|
1800
|
+
return { existingId: id };
|
|
1778
1801
|
}
|
|
1802
|
+
return { existingUuid: trimmed };
|
|
1779
1803
|
}
|
|
1804
|
+
const attachCommand = new Command("attach").description(`Create a surface (${SURFACE_KIND_LIST}) and wire it to an existing mist`).argument("[slug]", "Mist slug (defaults to the .mist file in CWD)").requiredOption("--kind <kind>", `Surface kind: ${SURFACE_KIND_LIST}`).option("--name <label>", "Surface name (defaults to the mist's name)").option("--zone <slot>", "drop-zone slot id: page__zone (e.g. checkout__below_total)").option("--height <px>", "Embed height in px (drop-zone + mobile-embed)").option("--cover-image <url>", "Mobile-embed cover image url (required for mobile-embed)").option("--existing <ref>", "Attach an existing surface instead of creating one (uuid, or numeric id for mobile-embed)").option("--no-wait", "Don't wait for managed env keys to appear after attach").option("--json", "Emit raw JSON").action((slug, opts) => runAction(async () => {
|
|
1805
|
+
const kind = parseSurfaceKind(opts.kind);
|
|
1806
|
+
const resolved = resolveSlug(slug);
|
|
1807
|
+
const client = createMistClient();
|
|
1808
|
+
const reporter = opts.json ? createSilentReporter() : createOraReporter();
|
|
1809
|
+
const existing = opts.existing ? parseExisting(kind, opts.existing) : void 0;
|
|
1810
|
+
const result = await provisionSurface(client, {
|
|
1811
|
+
slugOrId: resolved,
|
|
1812
|
+
kind,
|
|
1813
|
+
name: opts.name?.trim(),
|
|
1814
|
+
verifyEnv: opts.wait !== false,
|
|
1815
|
+
reporter,
|
|
1816
|
+
...existing,
|
|
1817
|
+
...existing ? {} : surfaceProvisionOptionsFromFlags(kind, opts)
|
|
1818
|
+
});
|
|
1819
|
+
if (opts.json) return printJson(result);
|
|
1820
|
+
console.log();
|
|
1821
|
+
console.log(`${chalk.bold(result.mist.name ?? result.mist.slug)} ${chalk.dim(result.mist.slug)}`);
|
|
1822
|
+
printSurfaceSummary(result);
|
|
1823
|
+
}));
|
|
1824
|
+
//#endregion
|
|
1825
|
+
//#region src/commands/attach-droplet.ts
|
|
1780
1826
|
/**
|
|
1781
|
-
*
|
|
1782
|
-
*
|
|
1783
|
-
*
|
|
1784
|
-
*
|
|
1785
|
-
* via `-c`/env, but that only covers commits WE make. Commits made any
|
|
1786
|
-
* other way against the same checkout — the Mist chat agent shelling out
|
|
1787
|
-
* `git commit` via run_cli, or the user in a terminal — inherit the
|
|
1788
|
-
* repo's configured identity, which is the person's personal GitHub
|
|
1789
|
-
* account. Vercel rejects a deploy whose commit author isn't a
|
|
1790
|
-
* we-commerce team member, so those personal-authored commits silently
|
|
1791
|
-
* block the deploy (exactly the www-* "stuck project" case).
|
|
1827
|
+
* `fluid mist attach-droplet [name] [slug]` — droplet shortcut for
|
|
1828
|
+
* `fluid mist attach --kind droplet`. Creates a droplet and wires it to an
|
|
1829
|
+
* existing standalone mist (provisioning the managed env). Idempotent: if a
|
|
1830
|
+
* droplet is already attached, it skips create + attach and just verifies env.
|
|
1792
1831
|
*
|
|
1793
|
-
*
|
|
1794
|
-
*
|
|
1795
|
-
* next publish. Best-effort: a config failure must never abort a push —
|
|
1796
|
-
* the publish commit is still bot-forced by gitAutoCommit regardless.
|
|
1832
|
+
* name Optional droplet name (defaults to the mist's name).
|
|
1833
|
+
* slug Optional mist slug (defaults to the `.mist` file in CWD).
|
|
1797
1834
|
*/
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
}
|
|
1835
|
+
const attachDropletCommand = new Command("attach-droplet").description("Create a droplet and wire it to an existing standalone mist (idempotent)").argument("[name]", "Droplet name (defaults to the mist's name)").argument("[slug]", "Mist slug (defaults to the .mist file in CWD)").option("--no-wait", "Don't wait for managed env keys to appear after attach").option("--json", "Emit raw JSON").action((name, slug, opts) => runAction(async () => {
|
|
1836
|
+
const resolved = resolveSlug(slug);
|
|
1837
|
+
const client = createMistClient();
|
|
1838
|
+
const reporter = opts.json ? createSilentReporter() : createOraReporter();
|
|
1839
|
+
const result = await provisionSurface(client, {
|
|
1840
|
+
slugOrId: resolved,
|
|
1841
|
+
kind: "droplet",
|
|
1842
|
+
name: name?.trim() || void 0,
|
|
1843
|
+
verifyEnv: opts.wait !== false,
|
|
1844
|
+
reporter
|
|
1845
|
+
});
|
|
1846
|
+
if (opts.json) return printJson(result);
|
|
1847
|
+
console.log();
|
|
1848
|
+
console.log(`${chalk.bold(result.mist.name ?? result.mist.slug)} ${chalk.dim(result.mist.slug)}`);
|
|
1849
|
+
printSurfaceSummary(result);
|
|
1850
|
+
}));
|
|
1851
|
+
//#endregion
|
|
1852
|
+
//#region src/commands/update.ts
|
|
1814
1853
|
/**
|
|
1815
|
-
* `
|
|
1854
|
+
* `fluid mist update` — mutate an existing mist. Hostable is retired
|
|
1855
|
+
* (#19684/#19687), so attach/detach operate on integration points:
|
|
1816
1856
|
*
|
|
1817
|
-
*
|
|
1818
|
-
*
|
|
1819
|
-
*
|
|
1820
|
-
*
|
|
1857
|
+
* --name <label> Rename the mist (PATCH /mists/:id).
|
|
1858
|
+
* --hostable droplet:<uuid> Attach a droplet by uuid. No-ops if that same
|
|
1859
|
+
* droplet is already attached; replaces it if a
|
|
1860
|
+
* different droplet is attached (droplet is
|
|
1861
|
+
* 1-per-mist — it provisions the managed env).
|
|
1862
|
+
* --detach Remove the droplet integration point.
|
|
1821
1863
|
*
|
|
1822
|
-
* The
|
|
1823
|
-
*
|
|
1824
|
-
*
|
|
1825
|
-
*
|
|
1826
|
-
* team member gets rejected. Passing `-c user.name=… -c user.email=…` beats
|
|
1827
|
-
* even a `--local` config, so every publish is bot-authored regardless of how
|
|
1828
|
-
* the repo landed on disk (fresh clone, pre-existing clone, or terminal CLI).
|
|
1829
|
-
* The real person is still recorded in the commit *message* (see push.ts).
|
|
1864
|
+
* The integration-point link is immutable (no update route, no `active`
|
|
1865
|
+
* field) — enablement lives on the surface record, so there's no toggle here.
|
|
1866
|
+
* All ops re-derive state from the integration-points index (which echoes
|
|
1867
|
+
* `integratable_uuid` since #19689) and only make the calls that change it.
|
|
1830
1868
|
*/
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1869
|
+
const updateCommand = new Command("update").description("Rename a mist, or attach/detach its droplet").argument("[slug]", "The mist slug (defaults to .mist file in CWD)").option("--name <label>", "Set the display label").option("--hostable <ref>", "Attach a droplet: droplet:<uuid> (e.g. droplet:drp_abc123)").option("--detach", "Remove the droplet integration point").option("--json", "Emit raw API JSON").action((slug, opts) => runAction(async () => {
|
|
1870
|
+
if (opts.hostable && opts.detach) throw new Error("Pass either `--hostable` OR `--detach`, not both — they're mutually exclusive.");
|
|
1871
|
+
if (!opts.name && !opts.hostable && !opts.detach) throw new Error("Nothing to do — pass `--name`, `--hostable`, or `--detach`.");
|
|
1872
|
+
const resolved = resolveSlug(slug);
|
|
1873
|
+
const client = createMistClient();
|
|
1874
|
+
const notes = [];
|
|
1875
|
+
let renameResp;
|
|
1876
|
+
if (opts.name?.trim()) {
|
|
1877
|
+
renameResp = await updateMist(client, resolved, { mist: { name: opts.name.trim() } });
|
|
1878
|
+
if (!opts.json) {
|
|
1879
|
+
const m = renameResp.mist;
|
|
1880
|
+
console.log(`${mistLabel(m.name, m.vendor_name)} ${chalk.dim(m.slug)} ${colorState(m.state)}`);
|
|
1881
|
+
}
|
|
1839
1882
|
}
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1883
|
+
if (opts.hostable || opts.detach) {
|
|
1884
|
+
const existing = findDropletIntegrationPoint((await listIntegrationPoints(client, resolved)).integration_points);
|
|
1885
|
+
if (opts.hostable) {
|
|
1886
|
+
const dropletUuid = parseDropletRef(opts.hostable);
|
|
1887
|
+
if (existing && existing.integratable_uuid === dropletUuid) notes.push(`droplet ${dropletUuid} already attached (integration ${existing.id})`);
|
|
1888
|
+
else {
|
|
1889
|
+
if (existing) {
|
|
1890
|
+
await deleteIntegrationPoint(client, resolved, existing.id);
|
|
1891
|
+
notes.push(`replaced integration ${existing.id}`);
|
|
1892
|
+
}
|
|
1893
|
+
const ip = await createIntegrationPoint(client, resolved, {
|
|
1894
|
+
kind: "droplet",
|
|
1895
|
+
integratable_uuid: dropletUuid
|
|
1896
|
+
});
|
|
1897
|
+
notes.push(`attached droplet ${dropletUuid} (integration ${ip.integration_point.id})`);
|
|
1898
|
+
}
|
|
1899
|
+
} else if (opts.detach) if (existing) {
|
|
1900
|
+
await deleteIntegrationPoint(client, resolved, existing.id);
|
|
1901
|
+
notes.push(`detached droplet (removed integration ${existing.id})`);
|
|
1902
|
+
} else notes.push("already standalone (no droplet integration)");
|
|
1903
|
+
}
|
|
1904
|
+
if (opts.json) return printJson(renameResp ? {
|
|
1905
|
+
...renameResp,
|
|
1906
|
+
slug: resolved,
|
|
1907
|
+
notes
|
|
1908
|
+
} : {
|
|
1909
|
+
slug: resolved,
|
|
1910
|
+
notes
|
|
1911
|
+
});
|
|
1912
|
+
for (const note of notes) console.log(chalk.dim(` ${note}`));
|
|
1913
|
+
}));
|
|
1914
|
+
//#endregion
|
|
1915
|
+
//#region src/commands/delete.ts
|
|
1916
|
+
/**
|
|
1917
|
+
* `fluid mist delete` — scheduled teardown 15 days out (spec §3 "15-day
|
|
1918
|
+
* grace period"). DO NOT poll for completion.
|
|
1919
|
+
*/
|
|
1920
|
+
const deleteCommand = new Command("delete").description("Schedule a mist for destruction (15-day grace window)").argument("[slug]", "The mist slug (defaults to .mist file in CWD)").option("-y, --yes", "Skip confirmation prompt").option("--json", "Emit raw API JSON").action((slug, opts) => runAction(async () => {
|
|
1921
|
+
const resolved = resolveSlug(slug);
|
|
1922
|
+
const client = createMistClient();
|
|
1923
|
+
if (!opts.yes) {
|
|
1924
|
+
const { mist } = await showMist(client, resolved);
|
|
1925
|
+
const fifteenDaysOut = new Date(Date.now() + 15 * 864e5).toISOString().slice(0, 10);
|
|
1926
|
+
console.log();
|
|
1927
|
+
console.log(`About to schedule ${chalk.bold(mist.name ?? mist.vendor_name ?? mist.slug)} (${chalk.dim(mist.slug)}) for destruction on ${chalk.yellow(fifteenDaysOut)}.`);
|
|
1928
|
+
console.log(chalk.dim(`Vendor resources stay alive until then; run \`fluid mist restore ${mist.slug}\` to undo.`));
|
|
1929
|
+
const { confirmed } = await prompts({
|
|
1930
|
+
type: "confirm",
|
|
1931
|
+
name: "confirmed",
|
|
1932
|
+
message: "Continue?",
|
|
1933
|
+
initial: false
|
|
1934
|
+
}, { onCancel: () => process.exit(130) });
|
|
1935
|
+
if (!confirmed) {
|
|
1936
|
+
console.log(chalk.dim("Aborted."));
|
|
1937
|
+
process.exit(0);
|
|
1938
|
+
}
|
|
1876
1939
|
}
|
|
1940
|
+
const { mist } = await deleteMist(client, resolved);
|
|
1941
|
+
if (opts.json) return printJson({ mist });
|
|
1942
|
+
console.log(chalk.magenta(`Scheduled for destruction on ${mist.scheduled_destroy_at ? formatAbsolute(mist.scheduled_destroy_at) : "the 15-day mark"}.`));
|
|
1943
|
+
console.log(chalk.dim(`Vendor resources stay alive until then; run \`fluid mist restore ${mist.slug}\` to undo.`));
|
|
1944
|
+
}));
|
|
1945
|
+
//#endregion
|
|
1946
|
+
//#region src/commands/restore.ts
|
|
1947
|
+
/** `fluid mist restore` — un-delete within the 15-day window. Spec §3. */
|
|
1948
|
+
const restoreCommand = new Command("restore").description("Cancel a scheduled mist destruction (15-day window)").argument("[slug]", "The mist slug (defaults to .mist file in CWD)").option("--json", "Emit raw API JSON").action((slug, opts) => runAction(async () => {
|
|
1949
|
+
const resolved = resolveSlug(slug);
|
|
1950
|
+
const { mist } = await restoreMist(createMistClient(), resolved);
|
|
1951
|
+
if (opts.json) return printJson({ mist });
|
|
1952
|
+
console.log(`Restored: ${mist.public_url ? chalk.cyan(mist.public_url) : mist.name ?? mist.vendor_name ?? mist.slug} (${colorState(mist.state)})`);
|
|
1953
|
+
}));
|
|
1954
|
+
//#endregion
|
|
1955
|
+
//#region src/api/git_credential.ts
|
|
1956
|
+
async function createGitCredential(client, slug) {
|
|
1957
|
+
const id = await resolveMistId(client, slug);
|
|
1958
|
+
return client.post(`/api/v202604/mists/${id}/git_credential`);
|
|
1877
1959
|
}
|
|
1878
1960
|
//#endregion
|
|
1879
1961
|
//#region src/commands/clone.ts
|