@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.d.mts +158 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +1398 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +51 -0
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
|