@fluid-app/fluid-cli-mist 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs ADDED
@@ -0,0 +1,1398 @@
1
+ import { Command } from "commander";
2
+ import chalk from "chalk";
3
+ import { getAuthToken } from "@fluid-app/fluid-cli";
4
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
+ import path, { join, resolve } from "node:path";
6
+ import ora from "ora";
7
+ import prompts from "prompts";
8
+ import os from "node:os";
9
+ import { execa } from "execa";
10
+ import open from "open";
11
+ //#region src/api/client.ts
12
+ /**
13
+ * Generic fetch wrapper for the Mist Rails API.
14
+ *
15
+ * Spec: `features/mist/cli-spec.md` §4.
16
+ * - Base URL: `https://api.fluid.app` (overridable via FLUID_API_BASE)
17
+ * - Auth: `Authorization: Bearer <token>` from the standard fluid-cli token
18
+ * store; the host CLI's PluginContext provides `getAuthToken()`.
19
+ * - Errors: maps API responses to a typed `MistApiError` that the
20
+ * error-handler turns into the spec's exit codes (1/2/3/4).
21
+ */
22
+ const DEFAULT_BASE = "https://api.fluid.app";
23
+ var MistApiError = class extends Error {
24
+ /** HTTP status, or 0 for network-level failures. */
25
+ status;
26
+ details;
27
+ /** Best-guess exit code per the spec: 2 for 4xx, 3 for 5xx/vendor, 4 for net. */
28
+ exitCode;
29
+ constructor(message, status, details) {
30
+ super(message);
31
+ this.name = "MistApiError";
32
+ this.status = status;
33
+ this.details = details;
34
+ if (status === 0) this.exitCode = 4;
35
+ else if (status >= 500) this.exitCode = 3;
36
+ else this.exitCode = 2;
37
+ }
38
+ };
39
+ function apiBase() {
40
+ return process.env["FLUID_API_BASE"] ?? DEFAULT_BASE;
41
+ }
42
+ /** Build a typed Mist API client. Throws if the user isn't logged in. */
43
+ function createMistClient() {
44
+ const token = getAuthToken();
45
+ if (!token) throw new MistApiError("No active Fluid session — run `fluid login` first.", 401);
46
+ const base = apiBase();
47
+ function buildUrl(path, query) {
48
+ const url = new URL(path.startsWith("/") ? path : `/${path}`, base);
49
+ if (query) for (const [k, v] of Object.entries(query)) {
50
+ if (v == null) continue;
51
+ url.searchParams.set(k, String(v));
52
+ }
53
+ return url.toString();
54
+ }
55
+ async function call(method, path, opts) {
56
+ const url = buildUrl(path, opts?.query);
57
+ let response;
58
+ try {
59
+ response = await fetch(url, {
60
+ method,
61
+ headers: {
62
+ Authorization: `Bearer ${token}`,
63
+ Accept: "application/json",
64
+ ...opts?.body !== void 0 ? { "Content-Type": "application/json" } : {}
65
+ },
66
+ ...opts?.body !== void 0 ? { body: JSON.stringify(opts.body) } : {}
67
+ });
68
+ } catch (err) {
69
+ throw new MistApiError(`Network error: ${err instanceof Error ? err.message : String(err)}`, 0);
70
+ }
71
+ if (response.status === 204) return void 0;
72
+ const text = await response.text();
73
+ let parsed;
74
+ try {
75
+ parsed = text.length > 0 ? JSON.parse(text) : {};
76
+ } catch {
77
+ if (!response.ok) throw new MistApiError(`HTTP ${response.status}: ${text.slice(0, 200)}`, response.status);
78
+ return;
79
+ }
80
+ if (!response.ok) {
81
+ const body = parsed;
82
+ throw new MistApiError(body.error?.message ?? `HTTP ${response.status}`, response.status, body.error?.details);
83
+ }
84
+ return parsed;
85
+ }
86
+ return {
87
+ get: (path, query) => call("GET", path, { query }),
88
+ post: (path, body) => call("POST", path, { body }),
89
+ patch: (path, body) => call("PATCH", path, { body }),
90
+ delete: (path, body) => call("DELETE", path, body !== void 0 ? { body } : void 0),
91
+ async request(path, init) {
92
+ const url = buildUrl(path);
93
+ return fetch(url, {
94
+ ...init,
95
+ headers: {
96
+ Authorization: `Bearer ${token}`,
97
+ Accept: "application/json",
98
+ ...init?.headers ?? {}
99
+ }
100
+ });
101
+ }
102
+ };
103
+ }
104
+ //#endregion
105
+ //#region src/api/id-resolver.ts
106
+ const MISTS_BASE = "/api/v202604/mists";
107
+ function listMistsForResolve(client, params = {}) {
108
+ return client.get(MISTS_BASE, params);
109
+ }
110
+ const cache = /* @__PURE__ */ new Map();
111
+ const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
112
+ /** Returns true if the input already looks like a UUID we can use as-is. */
113
+ function isUuid(s) {
114
+ return UUID_REGEX.test(s.trim());
115
+ }
116
+ /**
117
+ * Resolve a user-supplied slug-or-id to the API's canonical UUID id.
118
+ * No-op when the input already looks like a UUID.
119
+ */
120
+ async function resolveMistId(client, slugOrId) {
121
+ const key = slugOrId.trim();
122
+ if (!key) throw new Error("Empty mist identifier.");
123
+ if (isUuid(key)) return key;
124
+ const hit = cache.get(key);
125
+ if (hit) return hit;
126
+ const response = await listMistsForResolve(client, { limit: 100 });
127
+ for (const m of response.mists ?? []) if (m.slug) cache.set(m.slug, m.id);
128
+ const found = cache.get(key);
129
+ if (!found) throw new Error(`No mist found with slug "${key}". Try \`fluid mist list\` to see what's available.`);
130
+ return found;
131
+ }
132
+ //#endregion
133
+ //#region src/api/mists.ts
134
+ const BASE = "/api/v202604/mists";
135
+ function listMists(client, params = {}) {
136
+ return client.get(BASE, params);
137
+ }
138
+ async function showMist(client, slug) {
139
+ const id = await resolveMistId(client, slug);
140
+ return client.get(`${BASE}/${id}`);
141
+ }
142
+ function createMist(client, body) {
143
+ return client.post(BASE, body);
144
+ }
145
+ async function updateMist(client, slug, body) {
146
+ const id = await resolveMistId(client, slug);
147
+ return client.patch(`${BASE}/${id}`, body);
148
+ }
149
+ async function deleteMist(client, slug) {
150
+ const id = await resolveMistId(client, slug);
151
+ return client.delete(`${BASE}/${id}`);
152
+ }
153
+ async function restoreMist(client, slug) {
154
+ const id = await resolveMistId(client, slug);
155
+ return client.post(`${BASE}/${id}/restore`);
156
+ }
157
+ //#endregion
158
+ //#region src/lib/hostable.ts
159
+ const SHORTHAND = {
160
+ droplet: "Droplet::Application",
161
+ droplets: "Droplet::Application",
162
+ mobile_widget: "MobileWidget",
163
+ "mobile-widget": "MobileWidget",
164
+ mobilewidget: "MobileWidget",
165
+ widget: "MobileWidget",
166
+ drop_zone: "DropZone",
167
+ "drop-zone": "DropZone",
168
+ dropzone: "DropZone"
169
+ };
170
+ /** Friendly label for help text + error output. */
171
+ const SHORTHAND_LIST = "droplet, mobile_widget, drop_zone";
172
+ var HostableParseError = class extends Error {
173
+ constructor(message) {
174
+ super(message);
175
+ this.name = "HostableParseError";
176
+ }
177
+ };
178
+ /**
179
+ * Parse a `<type>:<id>` shorthand, or accept already-canonical
180
+ * `Droplet::Application:42` if the user prefers the Rails-style form.
181
+ */
182
+ function parseHostableRef(raw) {
183
+ const trimmed = raw.trim();
184
+ if (!trimmed) throw new HostableParseError(`Empty hostable reference. Use <type>:<id> where <type> is one of ${SHORTHAND_LIST}.`);
185
+ const lastColon = trimmed.lastIndexOf(":");
186
+ if (lastColon <= 0 || lastColon === trimmed.length - 1) throw new HostableParseError(`Invalid hostable reference "${raw}". Expected <type>:<id>, e.g. droplet:42.`);
187
+ const typePart = trimmed.slice(0, lastColon).trim();
188
+ const idPart = trimmed.slice(lastColon + 1).trim();
189
+ const id = Number(idPart);
190
+ if (!Number.isInteger(id) || id <= 0) throw new HostableParseError(`Invalid hostable id "${idPart}". Must be a positive integer.`);
191
+ return {
192
+ hostable_type: canonicalizeHostableType(typePart),
193
+ hostable_id: id
194
+ };
195
+ }
196
+ /** Map an input string to the canonical Rails class name, throwing on miss. */
197
+ function canonicalizeHostableType(typePart) {
198
+ if ([
199
+ "Droplet::Application",
200
+ "MobileWidget",
201
+ "DropZone"
202
+ ].includes(typePart)) return typePart;
203
+ const looked = SHORTHAND[typePart.toLowerCase().replace(/\s+/g, "")];
204
+ if (looked) return looked;
205
+ throw new HostableParseError(`Unknown hostable type "${typePart}". Use one of: ${SHORTHAND_LIST}.`);
206
+ }
207
+ //#endregion
208
+ //#region src/lib/slug-resolver.ts
209
+ /**
210
+ * Slug resolution — spec §3 conventions:
211
+ * 1. If a slug arg is passed, use it.
212
+ * 2. Otherwise look for a `.mist` file in CWD (written by `fluid mist
213
+ * clone`) and use the slug from it.
214
+ * 3. Otherwise error: tell the user to pass a slug or cd into a cloned dir.
215
+ */
216
+ const MIST_FILE = ".mist";
217
+ var SlugResolutionError = class extends Error {
218
+ constructor(message) {
219
+ super(message);
220
+ this.name = "SlugResolutionError";
221
+ }
222
+ };
223
+ function readMistFile(dir) {
224
+ const filePath = join(dir, MIST_FILE);
225
+ if (!existsSync(filePath)) return null;
226
+ try {
227
+ const raw = readFileSync(filePath, "utf8");
228
+ const parsed = JSON.parse(raw);
229
+ if (!parsed || typeof parsed !== "object") return null;
230
+ const obj = parsed;
231
+ if (typeof obj["slug"] !== "string") return null;
232
+ return {
233
+ slug: obj["slug"],
234
+ vendor_name: typeof obj["vendor_name"] === "string" ? obj["vendor_name"] : "",
235
+ public_url: typeof obj["public_url"] === "string" ? obj["public_url"] : null
236
+ };
237
+ } catch {
238
+ return null;
239
+ }
240
+ }
241
+ /** Returns the slug from arg or `.mist` file; throws if neither is present. */
242
+ function resolveSlug(arg, cwd = process.cwd()) {
243
+ if (arg && arg.trim().length > 0) return arg.trim();
244
+ const file = readMistFile(cwd);
245
+ if (file) return file.slug;
246
+ 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.");
247
+ }
248
+ /** Write a `.mist` file after `fluid mist clone`. */
249
+ function writeMistFile(dir, mist) {
250
+ const payload = {
251
+ slug: mist.slug,
252
+ vendor_name: mist.vendor_name,
253
+ public_url: mist.public_url
254
+ };
255
+ writeFileSync(join(dir, MIST_FILE), JSON.stringify(payload, null, 2) + "\n", "utf8");
256
+ }
257
+ //#endregion
258
+ //#region src/lib/error-handler.ts
259
+ /**
260
+ * Centralized error handling — maps thrown errors to spec'd exit codes
261
+ * and friendly messages. Imported by every command's `.action()` so each
262
+ * file doesn't repeat the same try/catch.
263
+ *
264
+ * Exit codes (spec §3 "Conventions"):
265
+ * 0 success | 1 user error | 2 API 4xx | 3 API 5xx / vendor | 4 network
266
+ */
267
+ /** Run an async action and translate failures into pretty output + exit. */
268
+ async function runAction(fn) {
269
+ try {
270
+ await fn();
271
+ } catch (err) {
272
+ handle(err);
273
+ }
274
+ }
275
+ function handle(err) {
276
+ if (err instanceof SlugResolutionError) {
277
+ console.error(chalk.red(err.message));
278
+ process.exit(1);
279
+ }
280
+ if (err instanceof HostableParseError) {
281
+ console.error(chalk.red(err.message));
282
+ process.exit(1);
283
+ }
284
+ if (err instanceof MistApiError) {
285
+ if (err.status === 403) {
286
+ const action = err.details?.["permission"];
287
+ console.error(chalk.red(action ? `Your role doesn't include \`mist.${String(action)}\` — ask your Fluid admin to grant it.` : err.message));
288
+ } else if (err.status === 409) {
289
+ const date = err.details?.["scheduled_destroy_at"];
290
+ 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));
291
+ } else if (err.status === 422) {
292
+ const details = err.details ?? {};
293
+ const idErrors = details["hostable_id"];
294
+ if (Array.isArray(idErrors) && idErrors.some((e) => /already taken/i.test(String(e)))) console.error(chalk.yellow("That hostable is already attached to another mist. Detach it first or pick a different one."));
295
+ else if (typeof err.message === "string" && /hostable_type and hostable_id/.test(err.message)) console.error(chalk.yellow("Pass `--hostable <type>:<id>` (or both `--hostable-type` and `--hostable-id`)."));
296
+ else if (typeof err.message === "string" && /Hostable not found/.test(err.message)) {
297
+ const t = details["hostable_type"];
298
+ const id = details["hostable_id"];
299
+ console.error(chalk.yellow(`Couldn't find ${typeof t === "string" ? t : "hostable"}${id != null ? ` #${String(id)}` : ""} in this company.`));
300
+ } else console.error(chalk.red(err.message));
301
+ } 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`."));
302
+ else console.error(chalk.red(err.message));
303
+ process.exit(err.exitCode);
304
+ }
305
+ if (err instanceof Error) console.error(chalk.red(err.message));
306
+ else console.error(chalk.red(String(err)));
307
+ process.exit(1);
308
+ }
309
+ //#endregion
310
+ //#region src/lib/output.ts
311
+ /**
312
+ * Output helpers — human-readable by default, `--json` emits raw API
313
+ * payload. NO_COLOR respected via chalk's built-in detection.
314
+ */
315
+ function printJson(payload) {
316
+ process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
317
+ }
318
+ const STATE_COLOR$1 = {
319
+ pending: chalk.gray,
320
+ provisioning: chalk.yellow,
321
+ live: chalk.green,
322
+ failed: chalk.red,
323
+ pending_destroy: chalk.magenta,
324
+ archived: chalk.dim
325
+ };
326
+ function colorState(state) {
327
+ return STATE_COLOR$1[state](state);
328
+ }
329
+ /** Pad columns to width — cheap manual table; avoids cli-table3 dep for v1. */
330
+ function printTable(rows) {
331
+ if (rows.length === 0) return;
332
+ const widths = [];
333
+ for (const row of rows) row.forEach((cell, i) => {
334
+ const visible = stripAnsi(cell).length;
335
+ widths[i] = Math.max(widths[i] ?? 0, visible);
336
+ });
337
+ for (const row of rows) {
338
+ const padded = row.map((cell, i) => {
339
+ const w = widths[i] ?? 0;
340
+ return cell + " ".repeat(Math.max(0, w - stripAnsi(cell).length));
341
+ });
342
+ process.stdout.write(padded.join(" ") + "\n");
343
+ }
344
+ }
345
+ function stripAnsi(s) {
346
+ return s.replace(/\x1b\[[0-9;]*m/g, "");
347
+ }
348
+ function relativeDate(iso) {
349
+ if (!iso) return "—";
350
+ const diff = Date.now() - new Date(iso).getTime();
351
+ const minutes = Math.floor(diff / 6e4);
352
+ if (minutes < 1) return "just now";
353
+ if (minutes < 60) return `${minutes}m ago`;
354
+ const hours = Math.floor(minutes / 60);
355
+ if (hours < 24) return `${hours}h ago`;
356
+ const days = Math.floor(hours / 24);
357
+ if (days < 30) return `${days}d ago`;
358
+ return new Date(iso).toISOString().slice(0, 10);
359
+ }
360
+ /** Human-friendly absolute date for one-off prints (scheduled_destroy_at). */
361
+ function formatAbsolute(iso) {
362
+ return new Date(iso).toISOString().slice(0, 10);
363
+ }
364
+ //#endregion
365
+ //#region src/commands/list.ts
366
+ /** `fluid mist list` — GET /mists with optional filters. Spec §3. */
367
+ 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 () => {
368
+ const client = createMistClient();
369
+ const limit = opts.limit ? parseInt(opts.limit, 10) : void 0;
370
+ const response = await listMists(client, {
371
+ ...opts.state ? { state: opts.state } : {},
372
+ ...opts.kind ? { kind: opts.kind } : {},
373
+ ...limit && Number.isFinite(limit) ? { limit } : {}
374
+ });
375
+ if (opts.json) return printJson(response);
376
+ const mists = response.mists ?? [];
377
+ if (mists.length === 0) {
378
+ console.log(chalk.dim("No mists found."));
379
+ return;
380
+ }
381
+ printTable([[
382
+ chalk.bold("SLUG"),
383
+ chalk.bold("NAME"),
384
+ chalk.bold("STATE"),
385
+ chalk.bold("ATTACHED"),
386
+ chalk.bold("UPDATED")
387
+ ], ...mists.map((m) => [
388
+ m.slug,
389
+ m.name ?? m.droplet?.name ?? m.vendor_name,
390
+ colorState(m.state),
391
+ m.hostable_type ?? chalk.dim("standalone"),
392
+ relativeDate(m.updated_at)
393
+ ])]);
394
+ }));
395
+ //#endregion
396
+ //#region src/commands/show.ts
397
+ /** `fluid mist show <slug>` — single mist detail. Surfaces
398
+ * scheduled_destroy_at when state is pending_destroy. Spec §3. */
399
+ 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 () => {
400
+ const resolved = resolveSlug(slug);
401
+ const { mist } = await showMist(createMistClient(), resolved);
402
+ if (opts.json) return printJson({ mist });
403
+ const lines = [];
404
+ const label = mist.name ?? mist.droplet?.name ?? mist.vendor_name;
405
+ lines.push(`${chalk.bold(label)} ${chalk.dim(mist.slug)}`);
406
+ if (mist.name && mist.name !== mist.vendor_name) lines.push(` vendor ${chalk.dim(mist.vendor_name)}`);
407
+ lines.push(` state ${colorState(mist.state)}`);
408
+ if (mist.kind) lines.push(` kind ${mist.kind}`);
409
+ if (mist.public_url) lines.push(` url ${chalk.cyan(mist.public_url)}`);
410
+ if (mist.hostable_type) lines.push(` attached ${mist.hostable_type}`);
411
+ if (mist.droplet) lines.push(` droplet ${mist.droplet.name} (#${mist.droplet.id})`);
412
+ if (mist.provisioned_at) lines.push(` live ${relativeDate(mist.provisioned_at)}`);
413
+ lines.push(` updated ${relativeDate(mist.updated_at)}`);
414
+ if (mist.scheduled_destroy_at) lines.push(chalk.magenta(` destroy scheduled ${formatAbsolute(mist.scheduled_destroy_at)} (run \`fluid mist restore ${mist.slug}\` to undo)`));
415
+ console.log(lines.join("\n"));
416
+ }));
417
+ //#endregion
418
+ //#region src/commands/create.ts
419
+ /**
420
+ * `fluid mist create` — POST /mists. Synchronous; 30–60s.
421
+ *
422
+ * Updated for `fluid` PR #18678: the API no longer creates Droplets inline.
423
+ * Pass an existing hostable via `--hostable <type>:<id>` (shorthand) or the
424
+ * pair `--hostable-type` / `--hostable-id`. Optional `--name <label>` sets
425
+ * the display label; if blank, the API falls back to the hostable's name.
426
+ */
427
+ const createCommand = new Command("create").description(`Provision a new mist (optionally attached to an existing hostable: ${SHORTHAND_LIST})`).option("--hostable <ref>", "Attach to an existing hostable. Shorthand: <type>:<id> (e.g. droplet:42)").option("--hostable-type <type>", `Hostable type when not using shorthand (${SHORTHAND_LIST})`).option("--hostable-id <id>", "Hostable id when not using shorthand").option("--name <label>", "Optional display label (defaults to hostable name)").option("--kind <kind>", "Mist kind (default: next_app)").option("--json", "Emit raw API JSON").action((opts) => runAction(async () => {
428
+ const mist = {};
429
+ if (opts.kind) mist.kind = opts.kind;
430
+ if (opts.name?.trim()) mist.name = opts.name.trim();
431
+ const hasShorthand = !!opts.hostable;
432
+ const hasPair = !!opts.hostableType || !!opts.hostableId;
433
+ if (hasShorthand && hasPair) throw new Error("Use either `--hostable <type>:<id>` OR `--hostable-type` + `--hostable-id`, not both.");
434
+ if (hasShorthand) {
435
+ const pair = parseHostableRef(opts.hostable);
436
+ mist.hostable_type = pair.hostable_type;
437
+ mist.hostable_id = pair.hostable_id;
438
+ } else if (hasPair) {
439
+ if (!opts.hostableType || !opts.hostableId) throw new Error("`--hostable-type` and `--hostable-id` must be passed together.");
440
+ const id = Number(opts.hostableId);
441
+ if (!Number.isInteger(id) || id <= 0) throw new Error(`Invalid --hostable-id "${opts.hostableId}". Must be a positive integer.`);
442
+ mist.hostable_type = canonicalizeHostableType(opts.hostableType);
443
+ mist.hostable_id = id;
444
+ }
445
+ const client = createMistClient();
446
+ const spinner = ora("Setting up your Mist Cloud environment…").start();
447
+ try {
448
+ const response = await createMist(client, { mist });
449
+ spinner.succeed("Provisioned");
450
+ if (opts.json) return printJson(response);
451
+ const m = response.mist;
452
+ console.log();
453
+ const label = m.name ?? m.droplet?.name ?? m.vendor_name;
454
+ console.log(`${chalk.bold(label)} ${chalk.dim(m.slug)} ${colorState(m.state)}`);
455
+ if (m.public_url) console.log(` ${chalk.cyan(m.public_url)}`);
456
+ if (m.hostable_type && m.hostable_id != null) console.log(chalk.dim(` attached: ${m.hostable_type} #${m.hostable_id}`));
457
+ console.log();
458
+ console.log(chalk.dim(`Clone with: fluid mist clone ${m.slug}`));
459
+ } catch (err) {
460
+ spinner.fail("Provisioning failed");
461
+ throw err;
462
+ }
463
+ }));
464
+ //#endregion
465
+ //#region src/commands/update.ts
466
+ /**
467
+ * `fluid mist update` — PATCH /mists/:id. Covers three operations on one
468
+ * endpoint:
469
+ *
470
+ * - Rename: --name <label>
471
+ * - Attach: --hostable <type>:<id>
472
+ * - Detach: --detach (sends explicit nulls for both keys)
473
+ *
474
+ * `--hostable` and `--detach` are mutually exclusive at the CLI surface so
475
+ * the user can't accidentally express contradictory intent. The wire-level
476
+ * rule (both keys must be paired or both null) is enforced server-side.
477
+ */
478
+ const updateCommand = new Command("update").description("Rename a mist, attach/replace a hostable, or detach one").argument("[slug]", "The mist slug (defaults to .mist file in CWD)").option("--name <label>", "Set the display label").option("--hostable <ref>", `Attach or replace the hostable. Shorthand: <type>:<id> (${SHORTHAND_LIST})`).option("--detach", "Detach the hostable (sends explicit nulls)").option("--json", "Emit raw API JSON").action((slug, opts) => runAction(async () => {
479
+ if (opts.hostable && opts.detach) throw new Error("Pass either `--hostable` OR `--detach`, not both — they're mutually exclusive.");
480
+ if (!opts.name && !opts.hostable && !opts.detach) throw new Error("Nothing to do — pass at least one of `--name`, `--hostable`, or `--detach`.");
481
+ const body = { mist: {} };
482
+ if (opts.name?.trim()) body.mist.name = opts.name.trim();
483
+ if (opts.hostable) {
484
+ const pair = parseHostableRef(opts.hostable);
485
+ body.mist.hostable_type = pair.hostable_type;
486
+ body.mist.hostable_id = pair.hostable_id;
487
+ }
488
+ if (opts.detach) {
489
+ body.mist.hostable_type = null;
490
+ body.mist.hostable_id = null;
491
+ }
492
+ const resolved = resolveSlug(slug);
493
+ const response = await updateMist(createMistClient(), resolved, body);
494
+ if (opts.json) return printJson(response);
495
+ const m = response.mist;
496
+ const label = m.name ?? m.droplet?.name ?? m.vendor_name;
497
+ console.log(`${chalk.bold(label)} ${chalk.dim(m.slug)} ${colorState(m.state)}`);
498
+ if (m.hostable_type && m.hostable_id != null) console.log(chalk.dim(` attached: ${m.hostable_type} #${m.hostable_id}`));
499
+ else console.log(chalk.dim(` standalone (no hostable)`));
500
+ }));
501
+ //#endregion
502
+ //#region src/commands/delete.ts
503
+ /**
504
+ * `fluid mist delete` — scheduled teardown 15 days out (spec §3 "15-day
505
+ * grace period"). DO NOT poll for completion.
506
+ */
507
+ 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 () => {
508
+ const resolved = resolveSlug(slug);
509
+ const client = createMistClient();
510
+ if (!opts.yes) {
511
+ const { mist } = await showMist(client, resolved);
512
+ const fifteenDaysOut = new Date(Date.now() + 15 * 864e5).toISOString().slice(0, 10);
513
+ console.log();
514
+ console.log(`About to schedule ${chalk.bold(mist.vendor_name)} (${chalk.dim(mist.slug)}) for destruction on ${chalk.yellow(fifteenDaysOut)}.`);
515
+ console.log(chalk.dim(`Vendor resources stay alive until then; run \`fluid mist restore ${mist.slug}\` to undo.`));
516
+ const { confirmed } = await prompts({
517
+ type: "confirm",
518
+ name: "confirmed",
519
+ message: "Continue?",
520
+ initial: false
521
+ }, { onCancel: () => process.exit(130) });
522
+ if (!confirmed) {
523
+ console.log(chalk.dim("Aborted."));
524
+ process.exit(0);
525
+ }
526
+ }
527
+ const { mist } = await deleteMist(client, resolved);
528
+ if (opts.json) return printJson({ mist });
529
+ console.log(chalk.magenta(`Scheduled for destruction on ${mist.scheduled_destroy_at ? formatAbsolute(mist.scheduled_destroy_at) : "the 15-day mark"}.`));
530
+ console.log(chalk.dim(`Vendor resources stay alive until then; run \`fluid mist restore ${mist.slug}\` to undo.`));
531
+ }));
532
+ //#endregion
533
+ //#region src/commands/restore.ts
534
+ /** `fluid mist restore` — un-delete within the 15-day window. Spec §3. */
535
+ 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 () => {
536
+ const resolved = resolveSlug(slug);
537
+ const { mist } = await restoreMist(createMistClient(), resolved);
538
+ if (opts.json) return printJson({ mist });
539
+ console.log(`Restored: ${mist.public_url ? chalk.cyan(mist.public_url) : mist.vendor_name} (${colorState(mist.state)})`);
540
+ }));
541
+ //#endregion
542
+ //#region src/api/git_credential.ts
543
+ async function createGitCredential(client, slug) {
544
+ const id = await resolveMistId(client, slug);
545
+ return client.post(`/api/v202604/mists/${id}/git_credential`);
546
+ }
547
+ //#endregion
548
+ //#region src/lib/git.ts
549
+ /**
550
+ * Git wrapper — shells out to system `git` via execa. Spec §11.1: we
551
+ * deliberately do NOT bundle git or use a pure-JS impl; users are expected
552
+ * to have it installed.
553
+ *
554
+ * Security: token-bearing `remote_url` from POST /git_credential must never
555
+ * be logged, persisted, *or* exposed in the process table. The token is
556
+ * stripped from the URL and handed to git through a GIT_ASKPASS script via
557
+ * env vars (MIST_GIT_USER / MIST_GIT_PASS) — so it never appears in argv
558
+ * (visible to `ps aux` on macOS, `/proc/<pid>/cmdline` on Linux) and is
559
+ * never written to `.git/config`.
560
+ */
561
+ var GitError = class extends Error {
562
+ exitCode;
563
+ constructor(message, exitCode = 1) {
564
+ super(message);
565
+ this.name = "GitError";
566
+ this.exitCode = exitCode;
567
+ }
568
+ };
569
+ /** execa throws ENOENT when the git binary itself is missing — the
570
+ * single most common failure on non-developer machines. Translate it
571
+ * into an actionable message instead of "git <verb> failed (exit 1)".
572
+ * Returns null for every other failure so call sites keep their
573
+ * specific messages. */
574
+ function gitMissingError(err) {
575
+ if (err?.code !== "ENOENT") return null;
576
+ 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);
577
+ }
578
+ /**
579
+ * Split a token-bearing remote URL into the userless URL + creds. Used
580
+ * to keep the token off git's argv: we pass `cleanUrl` to git and feed
581
+ * `username` / `password` back through GIT_ASKPASS env vars.
582
+ *
583
+ * If the URL has no embedded credentials, returns empty strings — fine
584
+ * for public repos where git won't even invoke askpass.
585
+ */
586
+ function splitRemoteCredentials(remoteUrl) {
587
+ const u = new URL(remoteUrl);
588
+ const username = decodeURIComponent(u.username);
589
+ const password = decodeURIComponent(u.password);
590
+ u.username = "";
591
+ u.password = "";
592
+ return {
593
+ cleanUrl: u.toString(),
594
+ username,
595
+ password
596
+ };
597
+ }
598
+ /**
599
+ * Write a stub GIT_ASKPASS script (shell on POSIX, .cmd on Windows) to
600
+ * a per-process tmpdir and return its path. The script itself contains
601
+ * no secret — it just echoes back whichever env var (MIST_GIT_USER /
602
+ * MIST_GIT_PASS) matches git's prompt ("Username for …" / "Password
603
+ * for …"). The credentials only live in the spawned git process's env
604
+ * for the duration of the call.
605
+ *
606
+ * Idempotent: subsequent calls reuse the same script path.
607
+ */
608
+ let askpassPath = null;
609
+ function ensureAskpassScript() {
610
+ if (askpassPath && existsSync(askpassPath)) return askpassPath;
611
+ const dir = path.join(os.tmpdir(), `mist-cli-askpass-${process.pid}`);
612
+ if (!existsSync(dir)) mkdirSync(dir, {
613
+ recursive: true,
614
+ mode: 448
615
+ });
616
+ if (process.platform === "win32") {
617
+ const p = path.join(dir, "askpass.cmd");
618
+ writeFileSync(p, `@echo off\r\nset "p=%~1"\r\nif /i "%p:~0,1%"=="U" (<nul set /p=%MIST_GIT_USER%) else (<nul set /p=%MIST_GIT_PASS%)\r\n`);
619
+ askpassPath = p;
620
+ } else {
621
+ const p = path.join(dir, "askpass.sh");
622
+ writeFileSync(p, `#!/bin/sh\ncase "$1" in\n Username*) printf '%s' "$MIST_GIT_USER" ;;\n *) printf '%s' "$MIST_GIT_PASS" ;;\nesac\n`, { mode: 448 });
623
+ chmodSync(p, 448);
624
+ askpassPath = p;
625
+ }
626
+ return askpassPath;
627
+ }
628
+ /**
629
+ * Build the spawn env + cleaned URL for a credentialed git invocation.
630
+ * Returns:
631
+ * - `cleanUrl`: URL with userinfo stripped — safe to pass as argv.
632
+ * - `env`: parent env plus GIT_ASKPASS + MIST_GIT_USER / MIST_GIT_PASS.
633
+ * GIT_TERMINAL_PROMPT=0 prevents the TTY-prompt fallback if
634
+ * the credentials are empty (we want auth to fail fast, not
635
+ * hang the desktop waiting for interactive input).
636
+ */
637
+ function gitAuthSpawn(remoteUrl) {
638
+ const { cleanUrl, username, password } = splitRemoteCredentials(remoteUrl);
639
+ return {
640
+ cleanUrl,
641
+ env: {
642
+ ...process.env,
643
+ GIT_ASKPASS: ensureAskpassScript(),
644
+ GIT_TERMINAL_PROMPT: "0",
645
+ MIST_GIT_USER: username,
646
+ MIST_GIT_PASS: password
647
+ }
648
+ };
649
+ }
650
+ /** Confirm git is installed. Cheap call once at startup. */
651
+ async function ensureGitAvailable() {
652
+ try {
653
+ await execa("git", ["--version"]);
654
+ } catch {
655
+ 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`.");
656
+ }
657
+ }
658
+ /**
659
+ * `git clone <cleanUrl> <targetDir>`. The original remoteUrl carries a
660
+ * token; gitAuthSpawn strips it from the URL and re-injects it via
661
+ * GIT_ASKPASS so it never appears in argv. `credential.helper=` stops
662
+ * git from caching what askpass returned to any system credential
663
+ * store.
664
+ */
665
+ async function gitClone(remoteUrl, targetDir) {
666
+ const { cleanUrl, env } = gitAuthSpawn(remoteUrl);
667
+ try {
668
+ await execa("git", [
669
+ "-c",
670
+ "credential.helper=",
671
+ "clone",
672
+ "--quiet",
673
+ cleanUrl,
674
+ targetDir
675
+ ], {
676
+ env,
677
+ stdio: [
678
+ "ignore",
679
+ "inherit",
680
+ "inherit"
681
+ ]
682
+ });
683
+ } catch (err) {
684
+ const missing = gitMissingError(err);
685
+ if (missing) throw missing;
686
+ const e = err;
687
+ throw new GitError(`git clone failed${e.exitCode != null ? ` (exit ${e.exitCode})` : ""}`, e.exitCode ?? 1);
688
+ }
689
+ }
690
+ /** `git push origin <branch>` — credentials supplied via GIT_ASKPASS,
691
+ * never in argv. The `.extraheader=` override clears any cached
692
+ * Authorization header that a CI-style git config might inject. */
693
+ async function gitPush(cwd, remoteUrl, branch = "main") {
694
+ const { cleanUrl, env } = gitAuthSpawn(remoteUrl);
695
+ try {
696
+ await execa("git", [
697
+ "-c",
698
+ "credential.helper=",
699
+ "-c",
700
+ `http.https://github.com/.extraheader=`,
701
+ "push",
702
+ cleanUrl,
703
+ `HEAD:${branch}`
704
+ ], {
705
+ cwd,
706
+ env,
707
+ stdio: [
708
+ "ignore",
709
+ "inherit",
710
+ "inherit"
711
+ ]
712
+ });
713
+ } catch (err) {
714
+ const missing = gitMissingError(err);
715
+ if (missing) throw missing;
716
+ const e = err;
717
+ throw new GitError(`git push failed${e.exitCode != null ? ` (exit ${e.exitCode})` : ""}`, e.exitCode ?? 1);
718
+ }
719
+ }
720
+ /** `git pull origin <branch>`. Token supplied via GIT_ASKPASS, same as push. */
721
+ async function gitPull(cwd, remoteUrl, branch = "main") {
722
+ const { cleanUrl, env } = gitAuthSpawn(remoteUrl);
723
+ try {
724
+ await execa("git", [
725
+ "-c",
726
+ "credential.helper=",
727
+ "pull",
728
+ "--ff-only",
729
+ cleanUrl,
730
+ branch
731
+ ], {
732
+ cwd,
733
+ env,
734
+ stdio: [
735
+ "ignore",
736
+ "inherit",
737
+ "inherit"
738
+ ]
739
+ });
740
+ } catch (err) {
741
+ const missing = gitMissingError(err);
742
+ if (missing) throw missing;
743
+ const e = err;
744
+ throw new GitError(`git pull failed${e.exitCode != null ? ` (exit ${e.exitCode})` : ""}`, e.exitCode ?? 1);
745
+ }
746
+ }
747
+ /**
748
+ * Are there uncommitted changes (working tree or index) in this repo?
749
+ * `git status --porcelain` is empty when everything is clean, non-empty
750
+ * otherwise — so the count is just "is the output non-trivial".
751
+ */
752
+ async function hasUncommittedChanges(cwd) {
753
+ try {
754
+ const { stdout } = await execa("git", ["status", "--porcelain"], { cwd });
755
+ return stdout.trim().length > 0;
756
+ } catch {
757
+ return false;
758
+ }
759
+ }
760
+ /**
761
+ * Fetch the given branch from the token-bearing remote URL, writing
762
+ * `FETCH_HEAD` so `git rev-list HEAD...FETCH_HEAD` is meaningful.
763
+ *
764
+ * Same security stance as gitPush — installation token comes through
765
+ * GIT_ASKPASS, never argv or `.git/config`.
766
+ */
767
+ async function gitFetch(cwd, remoteUrl, branch = "main") {
768
+ const { cleanUrl, env } = gitAuthSpawn(remoteUrl);
769
+ try {
770
+ await execa("git", [
771
+ "-c",
772
+ "credential.helper=",
773
+ "fetch",
774
+ "--quiet",
775
+ cleanUrl,
776
+ branch
777
+ ], {
778
+ cwd,
779
+ env
780
+ });
781
+ } catch (err) {
782
+ const missing = gitMissingError(err);
783
+ if (missing) throw missing;
784
+ const e = err;
785
+ throw new GitError(`git fetch failed${e.exitCode != null ? ` (exit ${e.exitCode})` : ""}`, e.exitCode ?? 1);
786
+ }
787
+ }
788
+ /**
789
+ * Count commits in local HEAD vs `FETCH_HEAD` (after `gitFetch`). Returns
790
+ * `{ahead, behind}`: how many commits HEAD has that the remote doesn't,
791
+ * and vice versa. A `0/0` result means "in sync".
792
+ */
793
+ async function gitAheadBehindVsFetchHead(cwd) {
794
+ try {
795
+ const { stdout } = await execa("git", [
796
+ "rev-list",
797
+ "--left-right",
798
+ "--count",
799
+ "HEAD...FETCH_HEAD"
800
+ ], { cwd });
801
+ const [a, b] = stdout.trim().split(/\s+/);
802
+ const ahead = Number.parseInt(a ?? "0", 10);
803
+ const behind = Number.parseInt(b ?? "0", 10);
804
+ return {
805
+ ahead: Number.isFinite(ahead) ? ahead : 0,
806
+ behind: Number.isFinite(behind) ? behind : 0
807
+ };
808
+ } catch {
809
+ return {
810
+ ahead: 0,
811
+ behind: 0
812
+ };
813
+ }
814
+ }
815
+ /** Fast-forward `HEAD` to `FETCH_HEAD`. Fails (non-zero exit) if not
816
+ * fast-forwardable — caller must check ahead-count first. */
817
+ async function gitMergeFastForward(cwd) {
818
+ try {
819
+ await execa("git", [
820
+ "merge",
821
+ "--ff-only",
822
+ "FETCH_HEAD"
823
+ ], { cwd });
824
+ } catch (err) {
825
+ const missing = gitMissingError(err);
826
+ if (missing) throw missing;
827
+ const e = err;
828
+ throw new GitError(`git fast-forward merge failed${e.exitCode != null ? ` (exit ${e.exitCode})` : ""}`, e.exitCode ?? 1);
829
+ }
830
+ }
831
+ /**
832
+ * Stash working-tree + untracked changes with a Mist-tagged message.
833
+ * Returns the stash ref (`stash@{0}`) we created. Callers should pair
834
+ * this with `gitStashPop` after the dangerous step is done.
835
+ */
836
+ async function gitStash(cwd, message) {
837
+ try {
838
+ await execa("git", [
839
+ "stash",
840
+ "push",
841
+ "--include-untracked",
842
+ "--message",
843
+ message
844
+ ], { cwd });
845
+ } catch (err) {
846
+ const missing = gitMissingError(err);
847
+ if (missing) throw missing;
848
+ throw new GitError(`git stash push failed`, err.exitCode ?? 1);
849
+ }
850
+ }
851
+ /**
852
+ * Pop the most recent stash. Throws on conflict so callers can surface
853
+ * the situation (the working tree will have conflict markers; the user
854
+ * needs to resolve manually).
855
+ */
856
+ async function gitStashPop(cwd) {
857
+ try {
858
+ await execa("git", ["stash", "pop"], { cwd });
859
+ } catch (err) {
860
+ const missing = gitMissingError(err);
861
+ if (missing) throw missing;
862
+ 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);
863
+ }
864
+ }
865
+ /**
866
+ * `git add -A && git commit -m <message>` in `cwd`.
867
+ *
868
+ * Mist Desktop's "Publish" UX target is non-technical: the user expects
869
+ * "ship my changes" to ship the changes they made, not to know about
870
+ * staging or commit messages. This helper bundles working-tree edits
871
+ * into one commit per publish so push has something to send.
872
+ *
873
+ * The commit author falls back to the repo's configured user; if there
874
+ * isn't one we pass `-c user.email=… -c user.name=…` flags pointing at a
875
+ * synthetic bot identity so the commit isn't rejected by git.
876
+ */
877
+ async function gitAutoCommit(cwd, message) {
878
+ try {
879
+ await execa("git", ["add", "-A"], { cwd });
880
+ } catch (err) {
881
+ const missing = gitMissingError(err);
882
+ if (missing) throw missing;
883
+ throw new GitError(`git add failed`, err.exitCode ?? 1);
884
+ }
885
+ let hasName = false;
886
+ let hasEmail = false;
887
+ try {
888
+ hasName = (await execa("git", ["config", "user.name"], { cwd })).stdout.trim().length > 0;
889
+ } catch {}
890
+ try {
891
+ hasEmail = (await execa("git", ["config", "user.email"], { cwd })).stdout.trim().length > 0;
892
+ } catch {}
893
+ const identityArgs = [...hasName ? [] : ["-c", "user.name=Mist Desktop"], ...hasEmail ? [] : ["-c", "user.email=mist@fluid.app"]];
894
+ try {
895
+ await execa("git", [
896
+ ...identityArgs,
897
+ "commit",
898
+ "-m",
899
+ message
900
+ ], {
901
+ cwd,
902
+ stdio: [
903
+ "ignore",
904
+ "inherit",
905
+ "inherit"
906
+ ]
907
+ });
908
+ } catch (err) {
909
+ const missing = gitMissingError(err);
910
+ if (missing) throw missing;
911
+ const e = err;
912
+ if (typeof e.stderr === "string" && /nothing to commit/i.test(e.stderr)) return;
913
+ throw new GitError(`git commit failed${e.exitCode != null ? ` (exit ${e.exitCode})` : ""}`, e.exitCode ?? 1);
914
+ }
915
+ }
916
+ //#endregion
917
+ //#region src/commands/clone.ts
918
+ /**
919
+ * `fluid mist clone <slug>` — mints a fresh git credential and clones
920
+ * into ./<vendor-name>/, writes a `.mist` marker file. Spec §3 + §11.
921
+ */
922
+ const cloneCommand = new Command("clone").description("Clone the mist's GitHub repo (mints a fresh token per call)").argument("[slug]", "The mist slug (defaults to .mist file in CWD)").option("--dir <dir>", "Target directory (defaults to ./<vendor-name>/)").action((slug, opts) => runAction(async () => {
923
+ await ensureGitAvailable();
924
+ const resolvedSlug = resolveSlug(slug);
925
+ const client = createMistClient();
926
+ const spinner = ora("Resolving mist…").start();
927
+ const { mist } = await showMist(client, resolvedSlug);
928
+ spinner.text = "Minting git credential…";
929
+ const { git_credential: cred } = await createGitCredential(client, mist.slug);
930
+ const targetDir = resolve(opts.dir ?? mist.vendor_name);
931
+ if (existsSync(targetDir)) {
932
+ spinner.fail(`Directory ${targetDir} already exists. Pass --dir or remove it first.`);
933
+ process.exit(1);
934
+ }
935
+ spinner.text = `Cloning into ${mist.vendor_name}/…`;
936
+ try {
937
+ await gitClone(cred.remote_url, targetDir);
938
+ } catch (err) {
939
+ spinner.fail("git clone failed");
940
+ throw err;
941
+ }
942
+ writeMistFile(targetDir, mist);
943
+ spinner.succeed(`Cloned into ${chalk.cyan(targetDir)}`);
944
+ console.log(chalk.dim(` cd ${join(".", mist.vendor_name)}`));
945
+ if (mist.public_url) console.log(chalk.dim(` ${chalk.cyan(mist.public_url)}`));
946
+ }));
947
+ //#endregion
948
+ //#region src/api/deployments.ts
949
+ async function listDeployments(client, slug, limit) {
950
+ const id = await resolveMistId(client, slug);
951
+ return client.get(`/api/v202604/mists/${id}/deployments`, limit != null ? { limit } : void 0);
952
+ }
953
+ async function showDeployment(client, slug, deploymentId) {
954
+ const id = await resolveMistId(client, slug);
955
+ return client.get(`/api/v202604/mists/${id}/deployments/${deploymentId}`);
956
+ }
957
+ //#endregion
958
+ //#region src/api/me.ts
959
+ async function fetchCurrentUser(client) {
960
+ const raw = await client.get("/api/me");
961
+ const composed = [raw.first_name, raw.last_name].filter(Boolean).join(" ").trim();
962
+ const fullName = raw.full_name ?? (composed.length > 0 ? composed : null);
963
+ return {
964
+ id: raw.id,
965
+ public_id: raw.public_id ?? String(raw.id),
966
+ full_name: fullName,
967
+ first_name: raw.first_name ?? null,
968
+ last_name: raw.last_name ?? null,
969
+ email: raw.email ?? null
970
+ };
971
+ }
972
+ //#endregion
973
+ //#region src/commands/push.ts
974
+ /**
975
+ * `fluid mist push [--watch]` — mints a credential, pushes to origin, and
976
+ * (with --watch) polls /deployments until READY/ERROR. Spec §3 + §4.
977
+ */
978
+ const POLL_MS$1 = 2e3;
979
+ const LOCATE_TIMEOUT_MS = 2 * 6e4;
980
+ const READY_TIMEOUT_MS = 5 * 6e4;
981
+ function sleep$1(ms) {
982
+ return new Promise((r) => setTimeout(r, ms));
983
+ }
984
+ const pushCommand = new Command("push").description("Commit working-tree changes and push to the Mist-managed remote (triggers a Vercel deploy)").argument("[slug]", "The mist slug (defaults to .mist file in CWD)").option("--watch", "Poll /deployments until ready or error").option("--branch <name>", "Branch to push (default: main)").option("--no-commit", "Skip the auto-commit step (push existing commits only)").option("-m, --message <message>", "Commit message for the auto-commit (default: timestamped Publish)").action((slug, opts) => runAction(async () => {
985
+ await ensureGitAvailable();
986
+ const resolved = resolveSlug(slug);
987
+ const client = createMistClient();
988
+ const branch = opts.branch ?? "main";
989
+ const cwd = process.cwd();
990
+ const driftSpinner = ora("Checking for remote changes…").start();
991
+ try {
992
+ const { git_credential: fetchCred } = await createGitCredential(client, resolved);
993
+ await gitFetch(cwd, fetchCred.remote_url, branch);
994
+ const { ahead, behind } = await gitAheadBehindVsFetchHead(cwd);
995
+ if (behind > 0 && ahead === 0) {
996
+ const wasDirty = await hasUncommittedChanges(cwd);
997
+ driftSpinner.text = `Pulling ${behind} commit${behind === 1 ? "" : "s"} from origin/main…`;
998
+ if (wasDirty) await gitStash(cwd, "mist-push: pre-pull stash");
999
+ try {
1000
+ await gitMergeFastForward(cwd);
1001
+ } finally {
1002
+ if (wasDirty) await gitStashPop(cwd);
1003
+ }
1004
+ driftSpinner.succeed(`Pulled ${behind} commit${behind === 1 ? "" : "s"} from origin/main`);
1005
+ } else if (behind > 0 && ahead > 0) {
1006
+ driftSpinner.fail(`Your branch and origin/main have diverged (you're ${ahead} ahead, ${behind} behind).`);
1007
+ console.error(chalk.yellow(" Resolve in a terminal: `git pull --rebase`, fix any conflicts, then re-run Publish."));
1008
+ process.exit(1);
1009
+ } else driftSpinner.succeed("Up to date with origin/main");
1010
+ } catch (err) {
1011
+ if (err instanceof Error && err.message.includes("Resolve in a terminal")) throw err;
1012
+ driftSpinner.fail("Couldn't sync with origin/main");
1013
+ throw err;
1014
+ }
1015
+ if (opts.commit !== false) {
1016
+ if (await hasUncommittedChanges(cwd)) {
1017
+ const baseMessage = opts.message ?? `Publish from Mist Desktop · ${(/* @__PURE__ */ new Date()).toISOString().slice(0, 19).replace("T", " ")}`;
1018
+ let namePrefix = "";
1019
+ let idSuffix = "";
1020
+ try {
1021
+ const me = await Promise.race([fetchCurrentUser(client), new Promise((_, reject) => setTimeout(() => reject(/* @__PURE__ */ new Error("timed out after 5s")), 5e3))]);
1022
+ const name = me.full_name && me.full_name.trim() || me.email || (Number.isFinite(me.id) ? `user-${me.id}` : null);
1023
+ const id = me.public_id && me.public_id.trim();
1024
+ if (name) namePrefix = `${name}: `;
1025
+ if (id) idSuffix = ` (${id})`;
1026
+ } catch (err) {
1027
+ const reason = err instanceof Error ? err.message : String(err);
1028
+ console.warn(chalk.yellow(` (skipping user-stamp on commit — couldn't fetch /api/me: ${reason})`));
1029
+ }
1030
+ const message = `${namePrefix}${baseMessage}${idSuffix}`;
1031
+ const commitSpinner = ora(`Committing changes (${message.length > 60 ? message.slice(0, 57) + "…" : message})…`).start();
1032
+ try {
1033
+ await gitAutoCommit(cwd, message);
1034
+ commitSpinner.succeed("Committed");
1035
+ } catch (err) {
1036
+ commitSpinner.fail("Commit failed");
1037
+ throw err;
1038
+ }
1039
+ }
1040
+ }
1041
+ const spinner = ora("Minting git credential…").start();
1042
+ const { git_credential: cred } = await createGitCredential(client, resolved);
1043
+ spinner.text = `Pushing ${branch} to origin…`;
1044
+ try {
1045
+ await gitPush(cwd, cred.remote_url, branch);
1046
+ } catch (err) {
1047
+ spinner.fail("git push failed");
1048
+ throw err;
1049
+ }
1050
+ spinner.succeed("Push complete");
1051
+ if (!opts.watch) return;
1052
+ let pushedSha = null;
1053
+ try {
1054
+ const { execa } = await import("execa");
1055
+ pushedSha = (await execa("git", ["rev-parse", "HEAD"], { cwd })).stdout.trim();
1056
+ } catch {}
1057
+ const locateStart = Date.now();
1058
+ let deploymentId = null;
1059
+ let attempt = 0;
1060
+ while (Date.now() - locateStart < LOCATE_TIMEOUT_MS) {
1061
+ attempt++;
1062
+ const { deployments } = await listDeployments(client, resolved, 5);
1063
+ const match = pushedSha ? deployments.find((d) => d.commit_sha === pushedSha) : deployments[0];
1064
+ if (match) {
1065
+ deploymentId = match.id;
1066
+ console.log(`Located deployment ${match.id} (${match.state})`);
1067
+ break;
1068
+ }
1069
+ if (attempt === 1) console.log(`Locating deployment for commit ${pushedSha?.slice(0, 7) ?? "(unknown)"}…`);
1070
+ else if (attempt % 5 === 0) console.log(` …still waiting (${attempt * 2}s elapsed)`);
1071
+ await sleep$1(POLL_MS$1);
1072
+ }
1073
+ if (!deploymentId) {
1074
+ console.error(`No deployment matching ${pushedSha?.slice(0, 7) ?? "the push"} appeared within ${LOCATE_TIMEOUT_MS / 1e3}s.`);
1075
+ console.error("Vercel may still be queueing — run `fluid mist deployments` to check.");
1076
+ process.exit(3);
1077
+ }
1078
+ const readyStart = Date.now();
1079
+ let lastState = null;
1080
+ while (Date.now() - readyStart < READY_TIMEOUT_MS) {
1081
+ const { deployment } = await showDeployment(client, resolved, deploymentId);
1082
+ if (deployment.state !== lastState) {
1083
+ console.log(` ${deployment.state}…`);
1084
+ lastState = deployment.state;
1085
+ }
1086
+ if (deployment.state === "ready") {
1087
+ console.log(`✔ Ready: ${chalk.cyan(deployment.url ?? "(no url)")}`);
1088
+ return;
1089
+ }
1090
+ if (deployment.state === "error" || deployment.state === "canceled") {
1091
+ console.error(`✘ Deployment ${deployment.state}`);
1092
+ process.exit(3);
1093
+ }
1094
+ await sleep$1(POLL_MS$1);
1095
+ }
1096
+ console.error(`Watch timed out after ${READY_TIMEOUT_MS / 1e3}s. Last state: ${lastState}.`);
1097
+ console.error("Run `fluid mist deployments` to check the latest state.");
1098
+ process.exit(3);
1099
+ }));
1100
+ //#endregion
1101
+ //#region src/commands/pull.ts
1102
+ /** `fluid mist pull` — git pull origin main. Spec §3. */
1103
+ const pullCommand = new Command("pull").description("git pull from the Mist-managed remote").argument("[slug]", "The mist slug (defaults to .mist file in CWD)").option("--branch <name>", "Branch to pull (default: main)").action((slug, opts) => runAction(async () => {
1104
+ await ensureGitAvailable();
1105
+ const resolved = resolveSlug(slug);
1106
+ const client = createMistClient();
1107
+ const branch = opts.branch ?? "main";
1108
+ const spinner = ora("Minting git credential…").start();
1109
+ const { git_credential: cred } = await createGitCredential(client, resolved);
1110
+ spinner.text = `Pulling ${branch}…`;
1111
+ try {
1112
+ await gitPull(process.cwd(), cred.remote_url, branch);
1113
+ } catch (err) {
1114
+ spinner.fail("git pull failed");
1115
+ throw err;
1116
+ }
1117
+ spinner.succeed("Pull complete");
1118
+ }));
1119
+ //#endregion
1120
+ //#region src/commands/open.ts
1121
+ /** `fluid mist open` — opens public_url in the default browser. Spec §3. */
1122
+ const openCommand = new Command("open").description("Open the mist's public URL in your browser").argument("[slug]", "The mist slug (defaults to .mist file in CWD)").action((slug) => runAction(async () => {
1123
+ const resolved = resolveSlug(slug);
1124
+ const { mist } = await showMist(createMistClient(), resolved);
1125
+ if (!mist.public_url) {
1126
+ console.error(chalk.yellow(`Mist ${mist.slug} has no public URL yet (state: ${mist.state}).`));
1127
+ process.exit(1);
1128
+ }
1129
+ console.log(chalk.cyan(mist.public_url));
1130
+ await open(mist.public_url);
1131
+ }));
1132
+ //#endregion
1133
+ //#region src/commands/deployments.ts
1134
+ /** `fluid mist deployments` — list recent deployments. Spec §3. */
1135
+ const STATE_COLOR = {
1136
+ ready: chalk.green,
1137
+ building: chalk.yellow,
1138
+ queued: chalk.dim,
1139
+ error: chalk.red,
1140
+ canceled: chalk.red
1141
+ };
1142
+ const deploymentsCommand = new Command("deployments").description("List recent deployments for a mist").argument("[slug]", "The mist slug (defaults to .mist file in CWD)").option("--limit <n>", "How many to fetch (default 10)").option("--json", "Emit raw API JSON").action((slug, opts) => runAction(async () => {
1143
+ const resolved = resolveSlug(slug);
1144
+ const response = await listDeployments(createMistClient(), resolved, opts.limit ? parseInt(opts.limit, 10) : 10);
1145
+ if (opts.json) return printJson(response);
1146
+ const deployments = response.deployments ?? [];
1147
+ if (deployments.length === 0) {
1148
+ console.log(chalk.dim("No deployments yet."));
1149
+ return;
1150
+ }
1151
+ printTable([[
1152
+ chalk.bold("ID"),
1153
+ chalk.bold("STATE"),
1154
+ chalk.bold("COMMIT"),
1155
+ chalk.bold("MESSAGE"),
1156
+ chalk.bold("AGE")
1157
+ ], ...deployments.map((d) => [
1158
+ d.id,
1159
+ (STATE_COLOR[d.state] ?? chalk.white)(d.state),
1160
+ d.commit_sha ? d.commit_sha.slice(0, 7) : chalk.dim("—"),
1161
+ d.commit_message ? d.commit_message.slice(0, 48) : chalk.dim("—"),
1162
+ relativeDate(d.created_at)
1163
+ ])]);
1164
+ }));
1165
+ //#endregion
1166
+ //#region src/api/logs.ts
1167
+ async function fetchLogs(client, slug, params = {}) {
1168
+ const id = await resolveMistId(client, slug);
1169
+ return client.get(`/api/v202604/mists/${id}/logs`, params);
1170
+ }
1171
+ //#endregion
1172
+ //#region src/commands/logs.ts
1173
+ /** `fluid mist logs [--tail]` — poll /logs?since=<last> every 2s. Spec §4. */
1174
+ const POLL_MS = 2e3;
1175
+ function sleep(ms) {
1176
+ return new Promise((r) => setTimeout(r, ms));
1177
+ }
1178
+ function levelColor(level) {
1179
+ return level === "stderr" ? chalk.red : chalk.dim;
1180
+ }
1181
+ function printLine(entry) {
1182
+ const ts = entry.timestamp.slice(11, 19);
1183
+ process.stdout.write(`${chalk.dim(ts)} ${levelColor(entry.level)(entry.level.padEnd(6))} ${entry.message}\n`);
1184
+ }
1185
+ const logsCommand = new Command("logs").description("Fetch deployment logs (use --tail to stream)").argument("[slug]", "The mist slug (defaults to .mist file in CWD)").option("--tail", "Stream new logs every 2s").option("--limit <n>", "Initial fetch size (default 200)").option("--deployment <id>", "Logs from a specific deployment ID").option("--json", "Emit raw API JSON (one batch; ignored when --tail)").action((slug, opts) => runAction(async () => {
1186
+ const resolved = resolveSlug(slug);
1187
+ const client = createMistClient();
1188
+ const limit = opts.limit ? parseInt(opts.limit, 10) : 200;
1189
+ const initial = await fetchLogs(client, resolved, {
1190
+ ...opts.deployment ? { deployment_id: opts.deployment } : {},
1191
+ limit
1192
+ });
1193
+ if (opts.json && !opts.tail) return printJson(initial);
1194
+ for (const entry of initial.logs) printLine(entry);
1195
+ if (!opts.tail) return;
1196
+ let since = initial.logs.length > 0 ? initial.logs[initial.logs.length - 1].timestamp : (/* @__PURE__ */ new Date()).toISOString();
1197
+ while (true) {
1198
+ await sleep(POLL_MS);
1199
+ const batch = await fetchLogs(client, resolved, {
1200
+ ...opts.deployment ? { deployment_id: opts.deployment } : {},
1201
+ since,
1202
+ limit
1203
+ });
1204
+ for (const entry of batch.logs) {
1205
+ printLine(entry);
1206
+ since = entry.timestamp;
1207
+ }
1208
+ }
1209
+ }));
1210
+ //#endregion
1211
+ //#region src/api/env_vars.ts
1212
+ function path$1(id, key) {
1213
+ return key ? `/api/v202604/mists/${id}/env_vars/${encodeURIComponent(key)}` : `/api/v202604/mists/${id}/env_vars`;
1214
+ }
1215
+ async function listEnvVars(client, slug) {
1216
+ const id = await resolveMistId(client, slug);
1217
+ return client.get(path$1(id));
1218
+ }
1219
+ /** Create a new env var (the spec routes `set` to POST for new keys). */
1220
+ async function createEnvVar(client, slug, key, value, targets) {
1221
+ const id = await resolveMistId(client, slug);
1222
+ return client.post(path$1(id), { env_var: {
1223
+ key,
1224
+ value,
1225
+ targets
1226
+ } });
1227
+ }
1228
+ /** Update an existing env var (the spec routes `set` to PATCH for existing keys). */
1229
+ async function updateEnvVar(client, slug, key, value, targets) {
1230
+ const id = await resolveMistId(client, slug);
1231
+ return client.patch(path$1(id, key), { env_var: {
1232
+ value,
1233
+ targets
1234
+ } });
1235
+ }
1236
+ async function deleteEnvVar(client, slug, key) {
1237
+ const id = await resolveMistId(client, slug);
1238
+ return client.delete(path$1(id, key));
1239
+ }
1240
+ //#endregion
1241
+ //#region src/commands/env/list.ts
1242
+ /** `fluid mist env list` — list env vars; mark managed_by entries. Spec §4. */
1243
+ function maskValue(value) {
1244
+ if (value.length <= 8) return "•".repeat(value.length);
1245
+ return `${value.slice(0, 4)}${"•".repeat(8)}${value.slice(-2)}`;
1246
+ }
1247
+ const envListCommand = new Command("list").description("List a mist's env vars").argument("[slug]", "The mist slug (defaults to .mist file in CWD)").option("--show-values", "Print full values instead of masking them").option("--json", "Emit raw API JSON").action((slug, opts) => runAction(async () => {
1248
+ const resolved = resolveSlug(slug);
1249
+ const response = await listEnvVars(createMistClient(), resolved);
1250
+ if (opts.json) return printJson(response);
1251
+ const vars = response.env_vars ?? [];
1252
+ if (vars.length === 0) {
1253
+ console.log(chalk.dim("No env vars."));
1254
+ return;
1255
+ }
1256
+ printTable([[
1257
+ chalk.bold("KEY"),
1258
+ chalk.bold("VALUE"),
1259
+ chalk.bold("TARGETS"),
1260
+ chalk.bold("MANAGED")
1261
+ ], ...vars.map((v) => [
1262
+ v.key,
1263
+ opts.showValues ? v.value : maskValue(v.value),
1264
+ v.targets.join(","),
1265
+ v.managed_by === "mist" ? chalk.magenta("mist") : chalk.dim("user")
1266
+ ])]);
1267
+ }));
1268
+ //#endregion
1269
+ //#region src/commands/env/set.ts
1270
+ /**
1271
+ * `fluid mist env set <slug> KEY=value [KEY=value ...]` — POST for new
1272
+ * keys, PATCH for existing. Spec §3 + §4.
1273
+ */
1274
+ function parsePair(pair) {
1275
+ const eq = pair.indexOf("=");
1276
+ if (eq <= 0) throw new Error(`Invalid pair "${pair}". Use KEY=value (no leading spaces).`);
1277
+ return {
1278
+ key: pair.slice(0, eq),
1279
+ value: pair.slice(eq + 1)
1280
+ };
1281
+ }
1282
+ const envSetCommand = new Command("set").description("Set one or more env vars (KEY=value KEY=value …)").argument("<slug-or-pair>", "The mist slug, or the first KEY=value pair").argument("[pairs...]", "Additional KEY=value pairs").action(async (slugOrPair, pairs) => {
1283
+ await runAction(async () => {
1284
+ let slug;
1285
+ let rawPairs;
1286
+ if (slugOrPair.includes("=")) {
1287
+ slug = resolveSlug();
1288
+ rawPairs = [slugOrPair, ...pairs];
1289
+ } else {
1290
+ slug = resolveSlug(slugOrPair);
1291
+ rawPairs = pairs;
1292
+ }
1293
+ if (rawPairs.length === 0) {
1294
+ console.error(chalk.red("Pass at least one KEY=value pair."));
1295
+ process.exit(1);
1296
+ }
1297
+ const client = createMistClient();
1298
+ const existing = await listEnvVars(client, slug);
1299
+ const existingKeys = new Set((existing.env_vars ?? []).map((v) => v.key));
1300
+ for (const raw of rawPairs) {
1301
+ const { key, value } = parsePair(raw);
1302
+ if (existingKeys.has(key)) {
1303
+ await updateEnvVar(client, slug, key, value);
1304
+ console.log(`${chalk.green("updated")} ${key}`);
1305
+ } else {
1306
+ await createEnvVar(client, slug, key, value);
1307
+ console.log(`${chalk.cyan("created")} ${key}`);
1308
+ }
1309
+ }
1310
+ });
1311
+ });
1312
+ //#endregion
1313
+ //#region src/commands/env/unset.ts
1314
+ /** `fluid mist env unset <slug> KEY [KEY ...]` — DELETE per key. Spec §3. */
1315
+ const envUnsetCommand = new Command("unset").description("Delete one or more env vars").argument("<slug-or-key>", "The mist slug, or the first KEY (if .mist file is present)").argument("[keys...]", "Additional keys").action(async (slugOrKey, keys) => {
1316
+ await runAction(async () => {
1317
+ let slug;
1318
+ let toDelete;
1319
+ if (/^[A-Z_][A-Z0-9_]*$/.test(slugOrKey)) {
1320
+ slug = resolveSlug();
1321
+ toDelete = [slugOrKey, ...keys];
1322
+ } else {
1323
+ slug = resolveSlug(slugOrKey);
1324
+ toDelete = keys;
1325
+ }
1326
+ if (toDelete.length === 0) {
1327
+ console.error(chalk.red("Pass at least one KEY."));
1328
+ process.exit(1);
1329
+ }
1330
+ const client = createMistClient();
1331
+ for (const key of toDelete) {
1332
+ await deleteEnvVar(client, slug, key);
1333
+ console.log(`${chalk.red("deleted")} ${key}`);
1334
+ }
1335
+ });
1336
+ });
1337
+ //#endregion
1338
+ //#region src/commands/env/pull.ts
1339
+ /**
1340
+ * `fluid mist env pull` — writes `.env.local` with user-managed vars only.
1341
+ * Skips `managed_by: "mist"` entries (DATABASE_URL, FLUID_*) since local
1342
+ * dev uses PGlite, not the production database. Spec §4 + §5.
1343
+ */
1344
+ function escapeValue(v) {
1345
+ if (/[\s#"']/.test(v)) return `"${v.replace(/(["\\$`])/g, "\\$1")}"`;
1346
+ return v;
1347
+ }
1348
+ const envPullCommand = new Command("pull").description("Write user-managed env vars to .env.local in the CWD").argument("[slug]", "The mist slug (defaults to .mist file in CWD)").option("--out <path>", "Output path (default: .env.local)").action((slug, opts) => runAction(async () => {
1349
+ const resolved = resolveSlug(slug);
1350
+ const { env_vars } = await listEnvVars(createMistClient(), resolved);
1351
+ const user = env_vars.filter((v) => v.managed_by === "user");
1352
+ const skipped = env_vars.filter((v) => v.managed_by === "mist");
1353
+ const target = join(process.cwd(), opts.out ?? ".env.local");
1354
+ const body = user.map((v) => `${v.key}=${escapeValue(v.value)}`).join("\n");
1355
+ writeFileSync(target, body + (body.length ? "\n" : ""), "utf8");
1356
+ console.log(chalk.green(`Wrote ${user.length} var(s) to ${target.replace(process.cwd() + "/", "")}.`));
1357
+ if (skipped.length > 0) console.log(chalk.dim(`Skipped ${skipped.length} Mist-managed var(s): ${skipped.map((v) => v.key).join(", ")} (local dev uses PGlite)`));
1358
+ }));
1359
+ //#endregion
1360
+ //#region src/commands/env/index.ts
1361
+ /** `fluid mist env` — sub-command aggregator. */
1362
+ const envCommand = new Command("env").description("Manage a mist's environment variables").addCommand(envListCommand).addCommand(envSetCommand).addCommand(envUnsetCommand).addCommand(envPullCommand);
1363
+ //#endregion
1364
+ //#region src/index.ts
1365
+ /**
1366
+ * @fluid-app/fluid-cli-mist
1367
+ *
1368
+ * Fluid CLI plugin for managing Mist hosted extensions. Auto-discovered
1369
+ * by @fluid-app/fluid-cli via the `fluid-cli-*` naming convention.
1370
+ *
1371
+ * Source of truth for command surface + behavior:
1372
+ * fluid (sibling repo) features/mist/cli-spec.md
1373
+ */
1374
+ const plugin = {
1375
+ name: "fluid-cli-mist",
1376
+ version: "0.1.0",
1377
+ async register(ctx) {
1378
+ const mist = new Command("mist").description("Create, manage, and deploy Mist hosted extensions");
1379
+ mist.addCommand(listCommand);
1380
+ mist.addCommand(showCommand);
1381
+ mist.addCommand(createCommand);
1382
+ mist.addCommand(updateCommand);
1383
+ mist.addCommand(deleteCommand);
1384
+ mist.addCommand(restoreCommand);
1385
+ mist.addCommand(cloneCommand);
1386
+ mist.addCommand(pushCommand);
1387
+ mist.addCommand(pullCommand);
1388
+ mist.addCommand(openCommand);
1389
+ mist.addCommand(deploymentsCommand);
1390
+ mist.addCommand(logsCommand);
1391
+ mist.addCommand(envCommand);
1392
+ ctx.program.addCommand(mist);
1393
+ }
1394
+ };
1395
+ //#endregion
1396
+ export { plugin as default };
1397
+
1398
+ //# sourceMappingURL=index.mjs.map