@fluid-app/fluid-cli-mysite 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.
@@ -0,0 +1,7 @@
1
+ import { FluidPlugin } from "@fluid-app/fluid-cli";
2
+
3
+ //#region src/index.d.ts
4
+ declare const plugin: FluidPlugin;
5
+ //#endregion
6
+ export { plugin as default };
7
+ //# sourceMappingURL=index.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../src/index.ts"],"mappings":";;;cAGM,MAAA,EAAQ,WAAA"}
package/dist/index.mjs ADDED
@@ -0,0 +1,749 @@
1
+ import { Command } from "commander";
2
+ import chalk from "chalk";
3
+ import { getAuthToken } from "@fluid-app/fluid-cli";
4
+ import path from "node:path";
5
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
6
+ import { existsSync } from "node:fs";
7
+ import http from "node:http";
8
+ import { hostname } from "node:os";
9
+ import { randomBytes } from "node:crypto";
10
+ import chokidar from "chokidar";
11
+ import https from "node:https";
12
+ import prompts from "prompts";
13
+ //#region ../../platform/api-client-core/src/fetch-client.ts
14
+ /**
15
+ * API Error class compatible with fluid-admin's ApiError
16
+ */
17
+ var ApiError = class ApiError extends Error {
18
+ status;
19
+ data;
20
+ requestId;
21
+ constructor(message, status, data, requestId) {
22
+ super(message);
23
+ this.name = "ApiError";
24
+ this.status = status;
25
+ this.data = data;
26
+ this.requestId = requestId;
27
+ if ("captureStackTrace" in Error) Error.captureStackTrace(this, ApiError);
28
+ }
29
+ toJSON() {
30
+ return {
31
+ name: this.name,
32
+ message: this.message,
33
+ status: this.status,
34
+ data: this.data,
35
+ requestId: this.requestId
36
+ };
37
+ }
38
+ };
39
+ function getStringRequestId(value) {
40
+ if (typeof value !== "string") return;
41
+ const trimmed = value.trim();
42
+ return trimmed.length > 0 ? trimmed : void 0;
43
+ }
44
+ function getRequestIdFromHeaders(headers) {
45
+ return getStringRequestId(headers.get("x-request-id")) ?? getStringRequestId(headers.get("request-id")) ?? getStringRequestId(headers.get("X-Request-ID"));
46
+ }
47
+ function getRequestIdFromJsonBody(body) {
48
+ if (!body || typeof body !== "object" || Array.isArray(body)) return;
49
+ const record = body;
50
+ const meta = record.meta;
51
+ return getStringRequestId(record.request_id) ?? getStringRequestId(record.requestId) ?? (meta && typeof meta === "object" && !Array.isArray(meta) ? getStringRequestId(meta.request_id) ?? getStringRequestId(meta.requestId) : void 0);
52
+ }
53
+ /**
54
+ * Creates a configured fetch client instance
55
+ */
56
+ function createFetchClient(config) {
57
+ const { baseUrl, getAuthToken, onAuthError, defaultHeaders = {}, credentials, cache, networkRetry, throwOnInvalidJson = false } = config;
58
+ const maxNetworkRetries = Math.max(0, networkRetry?.maxRetries ?? 0);
59
+ const baseNetworkRetryDelayMs = Math.max(0, networkRetry?.baseDelayMs ?? 0);
60
+ /**
61
+ * Build headers for a request
62
+ */
63
+ async function buildHeaders(customHeaders) {
64
+ const headers = {
65
+ Accept: "application/json",
66
+ "Content-Type": "application/json",
67
+ ...defaultHeaders,
68
+ ...customHeaders
69
+ };
70
+ if (getAuthToken) {
71
+ const token = await getAuthToken();
72
+ if (token) headers.Authorization = `Bearer ${token}`;
73
+ }
74
+ return headers;
75
+ }
76
+ /**
77
+ * Join baseUrl + endpoint via string concatenation (matches fetchApi).
78
+ * Using `new URL(endpoint, baseUrl)` would strip any path prefix from
79
+ * baseUrl (e.g. "/api") when the endpoint starts with "/".
80
+ */
81
+ function joinUrl(endpoint) {
82
+ return `${baseUrl}${endpoint}`;
83
+ }
84
+ /**
85
+ * Build URL with query parameters for GET requests
86
+ * Compatible with fluid-admin's query param handling
87
+ */
88
+ function buildUrl(endpoint, params) {
89
+ const fullUrl = joinUrl(endpoint);
90
+ if (!params || Object.keys(params).length === 0) return fullUrl;
91
+ const queryString = new URLSearchParams();
92
+ Object.entries(params).forEach(([key, value]) => {
93
+ if (value === void 0 || value === null) return;
94
+ if (Array.isArray(value)) value.forEach((item) => queryString.append(`${key}[]`, String(item)));
95
+ else if (typeof value === "object") Object.entries(value).forEach(([subKey, subValue]) => {
96
+ if (subValue === void 0 || subValue === null) return;
97
+ if (Array.isArray(subValue)) subValue.forEach((item) => queryString.append(`${key}[${subKey}][]`, String(item)));
98
+ else queryString.append(`${key}[${subKey}]`, String(subValue));
99
+ });
100
+ else queryString.append(key, String(value));
101
+ });
102
+ const qs = queryString.toString();
103
+ return qs ? `${fullUrl}?${qs}` : fullUrl;
104
+ }
105
+ /**
106
+ * Shared response handler for both JSON and FormData requests.
107
+ * Handles auth errors, non-OK responses, 204 No Content, and JSON parsing.
108
+ */
109
+ async function handleResponse(response, method, _url) {
110
+ const headerRequestId = getRequestIdFromHeaders(response.headers);
111
+ if (response.status === 401 && onAuthError) onAuthError();
112
+ if (!response.ok) {
113
+ const errorText = await response.text().catch(() => "");
114
+ if (response.headers.get("content-type")?.includes("application/json")) {
115
+ let data;
116
+ try {
117
+ data = JSON.parse(errorText);
118
+ } catch {
119
+ throw new ApiError(errorText.slice(0, 200) || `${method} request failed with status ${response.status}`, response.status, null, headerRequestId);
120
+ }
121
+ const nestedError = typeof data.error === "object" && data.error !== null ? data.error.message : void 0;
122
+ throw new ApiError(data.message || data.error_message || (typeof nestedError === "string" ? nestedError : void 0) || `${method} request failed`, response.status, data.errors || data, headerRequestId ?? getRequestIdFromJsonBody(data));
123
+ } else throw new ApiError(`${method} request failed with status ${response.status}`, response.status, null, headerRequestId);
124
+ }
125
+ if (response.status === 204 || response.headers.get("content-length") === "0") return null;
126
+ if (response.headers.get("content-type")?.includes("application/json")) {
127
+ const responseText = await response.text();
128
+ try {
129
+ return JSON.parse(responseText);
130
+ } catch {
131
+ if (throwOnInvalidJson) throw new ApiError("Failed to parse response as JSON", response.status, null, headerRequestId);
132
+ return responseText ? responseText : null;
133
+ }
134
+ }
135
+ return null;
136
+ }
137
+ function getNetworkRetryDelayMs(retryAttempt) {
138
+ return baseNetworkRetryDelayMs * 2 ** (retryAttempt - 1);
139
+ }
140
+ async function waitForNetworkRetry(retryAttempt) {
141
+ const delayMs = getNetworkRetryDelayMs(retryAttempt);
142
+ if (delayMs <= 0) return;
143
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
144
+ }
145
+ async function fetchWithNetworkRetry(url, fetchOptions, signal) {
146
+ let retryCount = 0;
147
+ while (true) try {
148
+ return await fetch(url, fetchOptions);
149
+ } catch (networkError) {
150
+ if (signal?.aborted || retryCount >= maxNetworkRetries) throw networkError;
151
+ retryCount += 1;
152
+ await waitForNetworkRetry(retryCount);
153
+ if (signal?.aborted) throw networkError;
154
+ }
155
+ }
156
+ /**
157
+ * Main request function
158
+ */
159
+ async function request(endpoint, options = {}) {
160
+ const { method = "GET", headers: customHeaders, params, body, signal, priority } = options;
161
+ const url = params ? buildUrl(endpoint, params) : joinUrl(endpoint);
162
+ const headers = await buildHeaders(customHeaders);
163
+ let response;
164
+ try {
165
+ const fetchOptions = {
166
+ method,
167
+ headers
168
+ };
169
+ if (credentials) fetchOptions.credentials = credentials;
170
+ if (cache) fetchOptions.cache = cache;
171
+ if (priority) fetchOptions.priority = priority;
172
+ const serializedBody = body && method !== "GET" ? JSON.stringify(body) : null;
173
+ if (serializedBody) fetchOptions.body = serializedBody;
174
+ if (signal) fetchOptions.signal = signal;
175
+ response = await fetchWithNetworkRetry(url, fetchOptions, signal);
176
+ } catch (networkError) {
177
+ throw new ApiError(`Network error: ${networkError instanceof Error ? networkError.message : "Unknown network error"}`, 0, null);
178
+ }
179
+ return handleResponse(response, method, url);
180
+ }
181
+ /**
182
+ * Request with FormData (for file uploads)
183
+ */
184
+ async function requestWithFormData(endpoint, formData, options = {}) {
185
+ const { method = "POST", headers: customHeaders, signal, priority } = options;
186
+ const url = joinUrl(endpoint);
187
+ const headers = await buildHeaders(customHeaders);
188
+ delete headers["Content-Type"];
189
+ let response;
190
+ try {
191
+ const fetchOptions = {
192
+ method,
193
+ headers,
194
+ body: formData
195
+ };
196
+ if (credentials) fetchOptions.credentials = credentials;
197
+ if (cache) fetchOptions.cache = cache;
198
+ if (priority) fetchOptions.priority = priority;
199
+ if (signal) fetchOptions.signal = signal;
200
+ response = await fetchWithNetworkRetry(url, fetchOptions, signal);
201
+ } catch (networkError) {
202
+ throw new ApiError(`Network error: ${networkError instanceof Error ? networkError.message : "Unknown network error"}`, 0, null);
203
+ }
204
+ return handleResponse(response, method, url);
205
+ }
206
+ return {
207
+ request,
208
+ requestWithFormData,
209
+ get: (endpoint, params, options) => request(endpoint, {
210
+ ...options,
211
+ method: "GET",
212
+ ...params && { params }
213
+ }),
214
+ post: (endpoint, body, options) => request(endpoint, {
215
+ ...options,
216
+ method: "POST",
217
+ body
218
+ }),
219
+ put: (endpoint, body, options) => request(endpoint, {
220
+ ...options,
221
+ method: "PUT",
222
+ body
223
+ }),
224
+ patch: (endpoint, body, options) => request(endpoint, {
225
+ ...options,
226
+ method: "PATCH",
227
+ body
228
+ }),
229
+ delete: (endpoint, options) => request(endpoint, {
230
+ ...options,
231
+ method: "DELETE"
232
+ })
233
+ };
234
+ }
235
+ //#endregion
236
+ //#region src/api.ts
237
+ /** Base URL for all API calls. Set FLUID_API_BASE to point at staging. */
238
+ function getApiBase() {
239
+ return process.env["FLUID_API_BASE"] ?? "https://api.fluid.app";
240
+ }
241
+ function createApiClient(tokenOverride) {
242
+ return createFetchClient({
243
+ baseUrl: getApiBase(),
244
+ getAuthToken: () => tokenOverride ?? getAuthToken() ?? null
245
+ });
246
+ }
247
+ function requireToken() {
248
+ const token = getAuthToken();
249
+ if (!token) {
250
+ console.error("Not logged in. Run `fluid login` first.");
251
+ process.exit(1);
252
+ }
253
+ return token;
254
+ }
255
+ async function listMysiteThemes(api) {
256
+ return (await api.get("/api/legacy_themes")).legacy_themes ?? [];
257
+ }
258
+ async function getMysiteTheme(api, id) {
259
+ return (await api.get(`/api/legacy_themes/${id}`)).legacy_theme;
260
+ }
261
+ async function createMysiteTheme(api, args) {
262
+ return (await api.post("/api/legacy_themes", { legacy_theme: args })).legacy_theme;
263
+ }
264
+ /** PATCH the theme's template blobs. WARNING: this publishes
265
+ * immediately — the server has no draft state for MySite themes. */
266
+ async function updateMysiteTemplate(api, id, template) {
267
+ return (await api.patch(`/api/legacy_themes/${id}`, { legacy_theme: { template } })).legacy_theme;
268
+ }
269
+ /** `GET /api/me` exposes `mysite_url` = `{company base_url}my/{username}`
270
+ * for the active company — exactly the page the dev server should
271
+ * preview. Returns null when the user has no username/membership in
272
+ * the company (no MySite to preview). */
273
+ async function fetchMysiteUrl(api) {
274
+ const me = await api.get("/api/me");
275
+ if (!me.mysite_url) return null;
276
+ try {
277
+ return new URL(me.mysite_url);
278
+ } catch {
279
+ return null;
280
+ }
281
+ }
282
+ //#endregion
283
+ //#region src/commands/list.ts
284
+ function createListCommand() {
285
+ return new Command("list").description("List the company's MySite themes").action(async () => {
286
+ requireToken();
287
+ const themes = await listMysiteThemes(createApiClient());
288
+ if (themes.length === 0) {
289
+ console.log("No MySite themes found for this company.");
290
+ return;
291
+ }
292
+ for (const t of themes) console.log(`${chalk.cyan(String(t.id).padStart(8))} ${t.name}${t.public ? chalk.dim(" (public)") : ""}`);
293
+ console.log(chalk.dim(`\nClone one locally: fluid mysite pull --theme <id> --root <dir>`));
294
+ });
295
+ }
296
+ //#endregion
297
+ //#region src/theme-files.ts
298
+ const MANIFEST_FILE = ".fluid-mysite.json";
299
+ const THEME_FILES = {
300
+ content: "content.liquid",
301
+ head: "head.liquid",
302
+ stylesheet: "stylesheet.css"
303
+ };
304
+ function manifestPath(root) {
305
+ return path.join(root, MANIFEST_FILE);
306
+ }
307
+ async function readManifest(root) {
308
+ const p = manifestPath(root);
309
+ if (!existsSync(p)) return null;
310
+ try {
311
+ const parsed = JSON.parse(await readFile(p, "utf8"));
312
+ return typeof parsed.themeId === "number" ? parsed : null;
313
+ } catch {
314
+ return null;
315
+ }
316
+ }
317
+ async function writeManifest(root, manifest) {
318
+ await mkdir(root, { recursive: true });
319
+ await writeFile(manifestPath(root), JSON.stringify(manifest, null, 2) + "\n", "utf8");
320
+ }
321
+ async function writeThemeFiles(root, template) {
322
+ await mkdir(root, { recursive: true });
323
+ const written = [];
324
+ const blobs = {
325
+ content: template?.content ?? "",
326
+ head: template?.head ?? "",
327
+ stylesheet: template?.stylesheet ?? ""
328
+ };
329
+ for (const key of Object.keys(THEME_FILES)) {
330
+ const file = THEME_FILES[key];
331
+ await writeFile(path.join(root, file), blobs[key], "utf8");
332
+ written.push(file);
333
+ }
334
+ return written;
335
+ }
336
+ async function readThemeFiles(root) {
337
+ const read = async (file) => {
338
+ const p = path.join(root, file);
339
+ if (!existsSync(p)) return "";
340
+ return readFile(p, "utf8");
341
+ };
342
+ return {
343
+ content: await read(THEME_FILES.content),
344
+ head: await read(THEME_FILES.head),
345
+ stylesheet: await read(THEME_FILES.stylesheet)
346
+ };
347
+ }
348
+ function themeFilePaths(root) {
349
+ return Object.values(THEME_FILES).map((f) => path.join(root, f));
350
+ }
351
+ //#endregion
352
+ //#region src/commands/pull.ts
353
+ function createPullCommand() {
354
+ return new Command("pull").description("Download a MySite theme's Liquid + CSS into a local directory").requiredOption("-t, --theme <id>", "MySite theme id (see `mysite list`)").option("--root <dir>", "Target directory", ".").option("--yes", "Overwrite existing local files without asking", false).action(async (opts) => {
355
+ requireToken();
356
+ const themeId = Number(opts.theme);
357
+ if (!Number.isInteger(themeId)) {
358
+ console.error(`Invalid theme id: ${opts.theme}`);
359
+ process.exit(1);
360
+ }
361
+ const root = path.resolve(opts.root);
362
+ const existing = await readManifest(root);
363
+ if (existing && existing.themeId !== themeId && !opts.yes) {
364
+ console.error(`${root} already tracks MySite theme #${existing.themeId} (${existing.name}).\nRe-run with --yes to overwrite it with theme #${themeId}, or pick a different --root.`);
365
+ process.exit(1);
366
+ }
367
+ const theme = await getMysiteTheme(createApiClient(), themeId);
368
+ const written = await writeThemeFiles(root, theme.template);
369
+ await writeManifest(root, {
370
+ themeId: theme.id,
371
+ name: theme.name,
372
+ ...existing && existing.themeId === theme.id && existing.devThemeId ? { devThemeId: existing.devThemeId } : {}
373
+ });
374
+ console.log(`${chalk.green("✔")} Pulled ${chalk.bold(theme.name)} (#${theme.id}) into ${root}`);
375
+ for (const f of written) console.log(` ${chalk.dim("wrote")} ${f}`);
376
+ console.log(` ${chalk.dim("wrote")} ${MANIFEST_FILE}`);
377
+ console.log(`\nNext: ${chalk.cyan("fluid mysite dev")} to preview edits live, ${chalk.cyan("fluid mysite publish")} to push them to the live theme.`);
378
+ });
379
+ }
380
+ //#endregion
381
+ //#region src/dev-server/hot-reload.ts
382
+ function buildHotReloadScript(mode) {
383
+ return `
384
+ <script>
385
+ (() => {
386
+ window.__FLUID_CLI_ENV__ = ${JSON.stringify({ mode })};
387
+
388
+ class HotReload {
389
+ static reloadMode() { return window.__FLUID_CLI_ENV__.mode; }
390
+ static isActive() { return HotReload.reloadMode() !== "off"; }
391
+ static refresh() {
392
+ console.log("[HotReload] Refreshing page");
393
+ window.location.reload();
394
+ }
395
+ }
396
+
397
+ class SSEClient {
398
+ constructor(url, handler) {
399
+ if (typeof EventSource === "undefined") {
400
+ console.error("[HotReload] EventSource not supported in this browser.");
401
+ return;
402
+ }
403
+ console.log("[HotReload] Initializing…");
404
+ this.url = url;
405
+ this.handler = handler;
406
+ }
407
+ connect() {
408
+ const es = new EventSource(this.url);
409
+ es.onopen = () => console.log("[HotReload] SSE connected.");
410
+ es.onerror = () => {
411
+ console.log("[HotReload] SSE closed. Reconnecting in 5s…");
412
+ es.close();
413
+ setTimeout(() => this.connect(), 5000);
414
+ };
415
+ es.onmessage = (msg) => {
416
+ let data;
417
+ try {
418
+ data = JSON.parse(msg.data);
419
+ } catch {
420
+ return; // non-JSON frame (keep-alive etc.) — ignore
421
+ }
422
+ this.handler(data);
423
+ };
424
+ }
425
+ }
426
+
427
+ if (HotReload.isActive()) {
428
+ new SSEClient("/hot-reload", (data) => {
429
+ if (data.reload_page) HotReload.refresh();
430
+ }).connect();
431
+ }
432
+ })();
433
+ <\/script>`;
434
+ }
435
+ function injectHotReload(html, mode) {
436
+ const script = buildHotReloadScript(mode);
437
+ if (html.includes("</body>")) return html.replace("</body>", `${script}\n</body>`);
438
+ return html + script;
439
+ }
440
+ //#endregion
441
+ //#region src/dev-server/proxy.ts
442
+ const HOP_BY_HOP = new Set([
443
+ "connection",
444
+ "keep-alive",
445
+ "proxy-authenticate",
446
+ "proxy-authorization",
447
+ "te",
448
+ "trailer",
449
+ "transfer-encoding",
450
+ "upgrade",
451
+ "content-security-policy"
452
+ ]);
453
+ async function proxyRequest(req, res, opts) {
454
+ const headers = {};
455
+ for (const [k, v] of Object.entries(req.headers)) if (!HOP_BY_HOP.has(k.toLowerCase()) && typeof v === "string") headers[k] = v;
456
+ headers["host"] = opts.host;
457
+ headers["user-agent"] = "Fluid CLI";
458
+ headers["accept-encoding"] = "identity";
459
+ const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
460
+ url.searchParams.set("preview_theme_id", String(opts.previewThemeId));
461
+ const isGet = req.method === "GET" || req.method === "HEAD";
462
+ let body;
463
+ if (!isGet) {
464
+ body = await readBody(req);
465
+ if (body.length > 0) headers["content-length"] = String(body.length);
466
+ }
467
+ return new Promise((resolve, reject) => {
468
+ const options = {
469
+ hostname: opts.host,
470
+ port: 443,
471
+ path: url.pathname + (url.search || ""),
472
+ method: req.method ?? "GET",
473
+ headers
474
+ };
475
+ const [hostname, portStr] = opts.host.split(":");
476
+ const port = portStr ? Number(portStr) : 443;
477
+ options.hostname = hostname;
478
+ options.port = port;
479
+ const proxyReq = https.request(options, (proxyRes) => {
480
+ proxyRes.on("error", reject);
481
+ const isHtml = (proxyRes.headers["content-type"] ?? "").includes("text/html");
482
+ const responseHeaders = {};
483
+ for (const [k, v] of Object.entries(proxyRes.headers)) if (!HOP_BY_HOP.has(k.toLowerCase()) && v !== void 0) responseHeaders[k] = v;
484
+ if (isHtml) {
485
+ const chunks = [];
486
+ proxyRes.on("data", (chunk) => chunks.push(chunk));
487
+ proxyRes.on("end", () => {
488
+ let html = Buffer.concat(chunks).toString("utf-8");
489
+ html = injectHotReload(html, opts.reloadMode);
490
+ responseHeaders["content-length"] = String(Buffer.byteLength(html));
491
+ res.writeHead(proxyRes.statusCode ?? 200, responseHeaders);
492
+ res.end(html);
493
+ resolve();
494
+ });
495
+ } else {
496
+ res.writeHead(proxyRes.statusCode ?? 200, responseHeaders);
497
+ proxyRes.pipe(res);
498
+ proxyRes.on("end", resolve);
499
+ }
500
+ });
501
+ proxyReq.on("error", (err) => {
502
+ reject(err);
503
+ });
504
+ if (body) proxyReq.write(body);
505
+ proxyReq.end();
506
+ });
507
+ }
508
+ function readBody(req) {
509
+ return new Promise((resolve, reject) => {
510
+ const chunks = [];
511
+ req.on("data", (chunk) => chunks.push(chunk));
512
+ req.on("end", () => resolve(Buffer.concat(chunks)));
513
+ req.on("error", reject);
514
+ });
515
+ }
516
+ //#endregion
517
+ //#region src/dev-server/sse.ts
518
+ var SSEStream = class {
519
+ responses = /* @__PURE__ */ new Set();
520
+ add(res) {
521
+ res.writeHead(200, {
522
+ "Content-Type": "text/event-stream",
523
+ "Cache-Control": "no-cache",
524
+ Connection: "keep-alive",
525
+ "Access-Control-Allow-Origin": "*"
526
+ });
527
+ res.write(":\n\n");
528
+ this.responses.add(res);
529
+ res.on("close", () => this.responses.delete(res));
530
+ }
531
+ broadcast(data) {
532
+ const payload = `data: ${data}\n\n`;
533
+ for (const res of this.responses) try {
534
+ res.write(payload);
535
+ } catch {
536
+ this.responses.delete(res);
537
+ }
538
+ }
539
+ close() {
540
+ for (const res of this.responses) try {
541
+ res.end();
542
+ } catch {}
543
+ this.responses.clear();
544
+ }
545
+ get size() {
546
+ return this.responses.size;
547
+ }
548
+ };
549
+ //#endregion
550
+ //#region src/commands/dev.ts
551
+ const DEFAULT_PORT = 9293;
552
+ function createDevCommand() {
553
+ return new Command("dev").description("Preview local MySite edits live without touching the published theme").option("--root <dir>", "Theme directory (from `mysite pull`)", ".").option("--port <port>", "Local preview port", String(DEFAULT_PORT)).option("--live-reload <mode>", "Reload browser on save: full-page | off", "full-page").action(async (opts) => {
554
+ requireToken();
555
+ const root = path.resolve(opts.root);
556
+ const port = Number(opts.port) || DEFAULT_PORT;
557
+ const reloadMode = opts.liveReload === "off" ? "off" : "full-page";
558
+ const manifest = await readManifest(root);
559
+ if (!manifest) {
560
+ console.error(`No ${chalk.bold(".fluid-mysite.json")} in ${root}. Run ${chalk.cyan("fluid mysite pull --theme <id>")} first.`);
561
+ process.exit(1);
562
+ }
563
+ const api = createApiClient();
564
+ const mysiteUrl = await fetchMysiteUrl(api);
565
+ if (!mysiteUrl) {
566
+ console.error("Couldn't determine your MySite URL — your user needs a username in this company to have a /my page to preview against.");
567
+ process.exit(1);
568
+ }
569
+ const devThemeId = await ensureDevCopy(api, root, manifest);
570
+ await syncToDevCopy(api, root, devThemeId, "initial sync");
571
+ const sse = new SSEStream();
572
+ const server = http.createServer((req, res) => {
573
+ if (req.url === "/hot-reload") {
574
+ sse.add(res);
575
+ return;
576
+ }
577
+ if ((req.url ?? "/").split("?")[0] === "/") {
578
+ res.writeHead(302, { location: mysiteUrl.pathname });
579
+ res.end();
580
+ return;
581
+ }
582
+ proxyRequest(req, res, {
583
+ host: mysiteUrl.host,
584
+ previewThemeId: devThemeId,
585
+ reloadMode
586
+ }).catch((err) => {
587
+ if (!res.headersSent) res.writeHead(502);
588
+ res.end(`Proxy error: ${err instanceof Error ? err.message : err}`);
589
+ });
590
+ });
591
+ const stopWatcher = watchThemeFiles(root, async () => {
592
+ if (await syncToDevCopy(api, root, devThemeId, "sync")) sse.broadcast(JSON.stringify({ reload_page: true }));
593
+ });
594
+ const boundPort = await listenOnAvailablePort(server, port);
595
+ if (boundPort !== port) console.log(chalk.dim(`Port ${port} is in use — using ${boundPort} instead.`));
596
+ {
597
+ const localUrl = `http://127.0.0.1:${boundPort}${mysiteUrl.pathname}`;
598
+ const prodPreview = `${mysiteUrl.href}?preview_theme_id=${devThemeId}`;
599
+ console.log(`\n${chalk.green("✔")} Dev copy ${chalk.bold(`#${devThemeId}`)} ready — the live theme (#${manifest.themeId}) stays untouched.\n`);
600
+ console.log(` ${chalk.bold("Local preview:")} ${chalk.cyan(localUrl)}`);
601
+ console.log(` ${chalk.bold("Production preview:")} ${chalk.cyan(prodPreview)}`);
602
+ console.log(` ${chalk.bold("Live site:")} ${chalk.dim(mysiteUrl.href)}\n`);
603
+ console.log(chalk.dim("Edits to content.liquid / head.liquid / stylesheet.css sync on save. Run `fluid mysite publish` to push them live. Ctrl+C to stop."));
604
+ }
605
+ const shutdown = async () => {
606
+ sse.close();
607
+ server.close();
608
+ await stopWatcher();
609
+ process.exit(0);
610
+ };
611
+ process.on("SIGINT", () => void shutdown());
612
+ process.on("SIGTERM", () => void shutdown());
613
+ });
614
+ }
615
+ /** Bind to `startPort`, walking forward on EADDRINUSE (up to
616
+ * `attempts` ports) so parallel previews coexist. Resolves with the
617
+ * port actually bound; rejects on non-port errors. */
618
+ function listenOnAvailablePort(server, startPort, attempts = 20) {
619
+ return new Promise((resolve, reject) => {
620
+ let port = startPort;
621
+ const tryListen = () => {
622
+ const onError = (err) => {
623
+ server.removeListener("listening", onListening);
624
+ if (err.code === "EADDRINUSE" && port - startPort < attempts) {
625
+ port += 1;
626
+ tryListen();
627
+ } else reject(err);
628
+ };
629
+ const onListening = () => {
630
+ server.removeListener("error", onError);
631
+ resolve(port);
632
+ };
633
+ server.once("error", onError);
634
+ server.once("listening", onListening);
635
+ server.listen(port, "127.0.0.1");
636
+ };
637
+ tryListen();
638
+ });
639
+ }
640
+ /** Find or create the scratch theme this directory syncs against.
641
+ *
642
+ * The name MUST be unique within the company: the Rails update action
643
+ * resolves templates by (name, themeable_type, company), so a scratch
644
+ * copy sharing the real theme's name would silently write to the REAL
645
+ * template. A random suffix keeps them apart. */
646
+ async function ensureDevCopy(api, root, manifest) {
647
+ if (manifest.devThemeId) try {
648
+ return (await getMysiteTheme(api, manifest.devThemeId)).id;
649
+ } catch {
650
+ console.log(chalk.dim(`Stored dev copy #${manifest.devThemeId} is gone — creating a fresh one.`));
651
+ }
652
+ const files = await readThemeFiles(root);
653
+ const suffix = `${hostname().split(".")[0] ?? "dev"}-${randomBytes(2).toString("hex")}`;
654
+ const created = await createMysiteTheme(api, {
655
+ name: `${manifest.name} (dev ${suffix})`.slice(0, 50),
656
+ description: "Fluid CLI development copy — auto-created by `fluid mysite dev`. Safe to delete.",
657
+ template: files,
658
+ public: false
659
+ });
660
+ await writeManifest(root, {
661
+ ...manifest,
662
+ devThemeId: created.id
663
+ });
664
+ return created.id;
665
+ }
666
+ async function syncToDevCopy(api, root, devThemeId, label) {
667
+ const started = Date.now();
668
+ try {
669
+ await updateMysiteTemplate(api, devThemeId, await readThemeFiles(root));
670
+ console.log(`${chalk.green("✔")} ${label} ${chalk.dim(`(${Date.now() - started}ms)`)}`);
671
+ return true;
672
+ } catch (err) {
673
+ console.error(`${chalk.red("✖")} ${label} failed: ${err instanceof Error ? err.message : err}`);
674
+ return false;
675
+ }
676
+ }
677
+ /** Watch the three theme files, coalescing rapid saves into one sync. */
678
+ function watchThemeFiles(root, onChange) {
679
+ const watcher = chokidar.watch(themeFilePaths(root), {
680
+ ignoreInitial: true,
681
+ awaitWriteFinish: {
682
+ stabilityThreshold: 50,
683
+ pollInterval: 10
684
+ }
685
+ });
686
+ let pending = Promise.resolve();
687
+ const enqueue = () => {
688
+ pending = pending.then(onChange).catch(() => {});
689
+ };
690
+ watcher.on("change", enqueue);
691
+ watcher.on("add", enqueue);
692
+ return () => watcher.close();
693
+ }
694
+ //#endregion
695
+ //#region src/commands/publish.ts
696
+ function createPublishCommand() {
697
+ return new Command("publish").description("Push local files to the LIVE MySite theme (immediate — no draft state)").option("--root <dir>", "Theme directory (from `mysite pull`)", ".").option("--yes", "Skip the confirmation prompt", false).action(async (opts) => {
698
+ requireToken();
699
+ const root = path.resolve(opts.root);
700
+ const manifest = await readManifest(root);
701
+ if (!manifest) {
702
+ console.error(`No ${chalk.bold(".fluid-mysite.json")} in ${root}. Run ${chalk.cyan("fluid mysite pull --theme <id>")} first.`);
703
+ process.exit(1);
704
+ }
705
+ if (!opts.yes) {
706
+ const { confirmed } = await prompts({
707
+ type: "confirm",
708
+ name: "confirmed",
709
+ message: `Publish local files to "${manifest.name}" (#${manifest.themeId})? MySite pages using this theme update immediately.`,
710
+ initial: false
711
+ });
712
+ if (!confirmed) {
713
+ console.log("Aborted.");
714
+ return;
715
+ }
716
+ }
717
+ const api = createApiClient();
718
+ const files = await readThemeFiles(root);
719
+ await updateMysiteTemplate(api, manifest.themeId, files);
720
+ console.log(`${chalk.green("✔")} Published ${chalk.bold(manifest.name)} (#${manifest.themeId})`);
721
+ try {
722
+ const url = await fetchMysiteUrl(api);
723
+ if (url) console.log(` ${chalk.dim("Live at")} ${chalk.cyan(url.href)}`);
724
+ } catch {}
725
+ });
726
+ }
727
+ //#endregion
728
+ //#region src/commands/mysite.ts
729
+ function registerMysiteCommand(ctx) {
730
+ const cmd = new Command("mysite").description("MySite theme workflow — list, pull, dev preview server, publish");
731
+ cmd.addCommand(createListCommand());
732
+ cmd.addCommand(createPullCommand());
733
+ cmd.addCommand(createDevCommand());
734
+ cmd.addCommand(createPublishCommand());
735
+ ctx.program.addCommand(cmd);
736
+ }
737
+ //#endregion
738
+ //#region src/index.ts
739
+ const plugin = {
740
+ name: "@fluid-app/fluid-cli-mysite",
741
+ version: "0.1.0",
742
+ register(ctx) {
743
+ registerMysiteCommand(ctx);
744
+ }
745
+ };
746
+ //#endregion
747
+ export { plugin as default };
748
+
749
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../../../platform/api-client-core/src/fetch-client.ts","../src/api.ts","../src/commands/list.ts","../src/theme-files.ts","../src/commands/pull.ts","../src/dev-server/hot-reload.ts","../src/dev-server/proxy.ts","../src/dev-server/sse.ts","../src/commands/dev.ts","../src/commands/publish.ts","../src/commands/mysite.ts","../src/index.ts"],"sourcesContent":["/**\n * Minimal, framework-agnostic fetch client for Fluid APIs\n * Compatible with fluid-admin patterns but usable standalone\n */\n\nexport interface FetchClientConfig {\n /**\n * Base URL for all requests (e.g., \"https://api.fluid.app/api\")\n */\n baseUrl: string;\n\n /**\n * Optional function to get auth token\n * Return null/undefined if no token available\n */\n getAuthToken?: () => string | null | Promise<string | null>;\n\n /**\n * Optional callback when 401 auth error occurs\n */\n onAuthError?: () => void;\n\n /**\n * Default headers to include in all requests\n * Example: { \"x-fluid-client\": \"admin\" }\n */\n defaultHeaders?: Record<string, string>;\n\n /**\n * Credentials mode for fetch requests.\n * Set to `\"include\"` for cookie-based (same-origin BFF) authentication.\n * @default undefined (browser default: \"same-origin\")\n */\n credentials?: RequestCredentials;\n\n /**\n * Request cache mode for fetch requests.\n * @default undefined (browser default)\n */\n cache?: RequestCache;\n\n /**\n * Retry configuration for thrown network errors from fetch.\n * Does not retry HTTP error responses or aborted requests.\n * @default undefined (no retries)\n */\n networkRetry?: {\n maxRetries?: number;\n baseDelayMs?: number;\n };\n\n /**\n * Throw ApiError when a successful response declares JSON but cannot be parsed.\n * Defaults to false to preserve the legacy generated-client behavior.\n * @default false\n */\n throwOnInvalidJson?: boolean;\n}\n\nexport interface RequestOptions {\n method?: \"GET\" | \"POST\" | \"PUT\" | \"DELETE\" | \"PATCH\";\n headers?: Record<string, string>;\n params?: Record<string, unknown>;\n body?: unknown;\n signal?: AbortSignal;\n priority?: RequestInit[\"priority\"];\n}\n\n/**\n * API Error class compatible with fluid-admin's ApiError\n */\nexport class ApiError extends Error {\n public readonly status: number;\n public readonly data: unknown;\n public readonly requestId?: string;\n\n constructor(\n message: string,\n status: number,\n data?: unknown,\n requestId?: string,\n ) {\n super(message);\n this.name = \"ApiError\";\n this.status = status;\n this.data = data;\n this.requestId = requestId;\n\n if (\"captureStackTrace\" in Error) {\n (\n Error as {\n captureStackTrace: (\n target: Error,\n constructor: NewableFunction,\n ) => void;\n }\n ).captureStackTrace(this, ApiError);\n }\n }\n\n toJSON(): {\n name: string;\n message: string;\n status: number;\n data: unknown;\n requestId?: string;\n } {\n return {\n name: this.name,\n message: this.message,\n status: this.status,\n data: this.data,\n requestId: this.requestId,\n };\n }\n}\n\nfunction getStringRequestId(value: unknown): string | undefined {\n if (typeof value !== \"string\") {\n return undefined;\n }\n\n const trimmed = value.trim();\n return trimmed.length > 0 ? trimmed : undefined;\n}\n\nfunction getRequestIdFromHeaders(headers: Headers): string | undefined {\n return (\n getStringRequestId(headers.get(\"x-request-id\")) ??\n getStringRequestId(headers.get(\"request-id\")) ??\n getStringRequestId(headers.get(\"X-Request-ID\"))\n );\n}\n\nfunction getRequestIdFromJsonBody(body: unknown): string | undefined {\n if (!body || typeof body !== \"object\" || Array.isArray(body)) {\n return undefined;\n }\n\n const record = body as Record<string, unknown>;\n const meta = record.meta;\n\n return (\n getStringRequestId(record.request_id) ??\n getStringRequestId(record.requestId) ??\n (meta && typeof meta === \"object\" && !Array.isArray(meta)\n ? (getStringRequestId((meta as Record<string, unknown>).request_id) ??\n getStringRequestId((meta as Record<string, unknown>).requestId))\n : undefined)\n );\n}\n\n/**\n * Type guard for ApiError\n */\nexport function isApiError(error: unknown): error is ApiError {\n return error instanceof ApiError;\n}\n\nexport interface FetchClientInstance {\n request: <TResponse = unknown>(\n endpoint: string,\n options?: RequestOptions,\n ) => Promise<TResponse>;\n requestWithFormData: <TResponse = unknown>(\n endpoint: string,\n formData: FormData,\n options?: Omit<RequestOptions, \"body\" | \"params\"> & {\n method?: \"POST\" | \"PUT\" | \"PATCH\";\n },\n ) => Promise<TResponse>;\n get: <TResponse = unknown>(\n endpoint: string,\n params?: Record<string, unknown>,\n options?: Omit<RequestOptions, \"method\" | \"params\">,\n ) => Promise<TResponse>;\n post: <TResponse = unknown>(\n endpoint: string,\n body?: unknown,\n options?: Omit<RequestOptions, \"method\" | \"body\">,\n ) => Promise<TResponse>;\n put: <TResponse = unknown>(\n endpoint: string,\n body?: unknown,\n options?: Omit<RequestOptions, \"method\" | \"body\">,\n ) => Promise<TResponse>;\n patch: <TResponse = unknown>(\n endpoint: string,\n body?: unknown,\n options?: Omit<RequestOptions, \"method\" | \"body\">,\n ) => Promise<TResponse>;\n delete: <TResponse = unknown>(\n endpoint: string,\n options?: Omit<RequestOptions, \"method\">,\n ) => Promise<TResponse>;\n}\n\n/**\n * Creates a configured fetch client instance\n */\nexport function createFetchClient(\n config: FetchClientConfig,\n): FetchClientInstance {\n const {\n baseUrl,\n getAuthToken,\n onAuthError,\n defaultHeaders = {},\n credentials,\n cache,\n networkRetry,\n throwOnInvalidJson = false,\n } = config;\n const maxNetworkRetries = Math.max(0, networkRetry?.maxRetries ?? 0);\n const baseNetworkRetryDelayMs = Math.max(0, networkRetry?.baseDelayMs ?? 0);\n\n /**\n * Build headers for a request\n */\n async function buildHeaders(\n customHeaders?: Record<string, string>,\n ): Promise<Record<string, string>> {\n const headers: Record<string, string> = {\n Accept: \"application/json\",\n \"Content-Type\": \"application/json\",\n ...defaultHeaders,\n ...customHeaders,\n };\n\n // Add auth token if available\n if (getAuthToken) {\n const token = await getAuthToken();\n if (token) {\n headers.Authorization = `Bearer ${token}`;\n }\n }\n\n return headers;\n }\n\n /**\n * Join baseUrl + endpoint via string concatenation (matches fetchApi).\n * Using `new URL(endpoint, baseUrl)` would strip any path prefix from\n * baseUrl (e.g. \"/api\") when the endpoint starts with \"/\".\n */\n function joinUrl(endpoint: string): string {\n return `${baseUrl}${endpoint}`;\n }\n\n /**\n * Build URL with query parameters for GET requests\n * Compatible with fluid-admin's query param handling\n */\n function buildUrl(\n endpoint: string,\n params?: Record<string, unknown>,\n ): string {\n const fullUrl = joinUrl(endpoint);\n\n if (!params || Object.keys(params).length === 0) {\n return fullUrl;\n }\n\n const queryString = new URLSearchParams();\n\n Object.entries(params).forEach(([key, value]) => {\n if (value === undefined || value === null) {\n return; // Skip undefined/null values\n }\n\n if (Array.isArray(value)) {\n // Handle arrays like Rails expects: key[]\n value.forEach((item) => queryString.append(`${key}[]`, String(item)));\n } else if (typeof value === \"object\") {\n // Handle nested objects: key[subkey]\n Object.entries(value).forEach(([subKey, subValue]) => {\n if (subValue === undefined || subValue === null) {\n return;\n }\n\n if (Array.isArray(subValue)) {\n subValue.forEach((item) =>\n queryString.append(`${key}[${subKey}][]`, String(item)),\n );\n } else {\n queryString.append(`${key}[${subKey}]`, String(subValue));\n }\n });\n } else {\n queryString.append(key, String(value));\n }\n });\n\n const qs = queryString.toString();\n return qs ? `${fullUrl}?${qs}` : fullUrl;\n }\n\n /**\n * Shared response handler for both JSON and FormData requests.\n * Handles auth errors, non-OK responses, 204 No Content, and JSON parsing.\n */\n async function handleResponse<TResponse>(\n response: Response,\n method: string,\n _url: string,\n ): Promise<TResponse> {\n const headerRequestId = getRequestIdFromHeaders(response.headers);\n\n if (response.status === 401 && onAuthError) {\n onAuthError();\n }\n\n if (!response.ok) {\n // Read body as text first to avoid SyntaxError from response.json()\n // when server returns non-JSON bodies with application/json content-type.\n const errorText = await response.text().catch(() => \"\");\n const contentType = response.headers.get(\"content-type\");\n\n if (contentType?.includes(\"application/json\")) {\n let data: Record<string, unknown>;\n try {\n data = JSON.parse(errorText);\n } catch {\n throw new ApiError(\n errorText.slice(0, 200) ||\n `${method} request failed with status ${response.status}`,\n response.status,\n null,\n headerRequestId,\n );\n }\n // Some Rails BFF endpoints return `{ error: { message, details } }`\n // instead of `{ message }` or `{ error_message }`. Fall through to\n // that shape last so callers always surface the real reason.\n const nestedError =\n typeof data.error === \"object\" && data.error !== null\n ? (data.error as { message?: unknown }).message\n : undefined;\n const msg =\n (data.message as string | undefined) ||\n (data.error_message as string | undefined) ||\n (typeof nestedError === \"string\" ? nestedError : undefined);\n throw new ApiError(\n msg || `${method} request failed`,\n response.status,\n data.errors || data,\n headerRequestId ?? getRequestIdFromJsonBody(data),\n );\n } else {\n throw new ApiError(\n `${method} request failed with status ${response.status}`,\n response.status,\n null,\n headerRequestId,\n );\n }\n }\n\n if (\n response.status === 204 ||\n response.headers.get(\"content-length\") === \"0\"\n ) {\n return null as TResponse;\n }\n\n const contentType = response.headers.get(\"content-type\");\n\n if (contentType?.includes(\"application/json\")) {\n const responseText = await response.text();\n\n try {\n const data = JSON.parse(responseText);\n return data as TResponse;\n } catch {\n if (throwOnInvalidJson) {\n throw new ApiError(\n \"Failed to parse response as JSON\",\n response.status,\n null,\n headerRequestId,\n );\n }\n\n // API declared JSON content-type but body isn't valid JSON.\n // Return the raw payload to preserve the legacy non-strict path.\n return responseText ? (responseText as TResponse) : (null as TResponse);\n }\n }\n\n // Non-JSON response (text/plain, text/html, etc.)\n return null as TResponse;\n }\n\n function getNetworkRetryDelayMs(retryAttempt: number): number {\n return baseNetworkRetryDelayMs * 2 ** (retryAttempt - 1);\n }\n\n async function waitForNetworkRetry(retryAttempt: number): Promise<void> {\n const delayMs = getNetworkRetryDelayMs(retryAttempt);\n if (delayMs <= 0) {\n return;\n }\n\n await new Promise((resolve) => setTimeout(resolve, delayMs));\n }\n\n async function fetchWithNetworkRetry(\n url: string,\n fetchOptions: RequestInit,\n signal?: AbortSignal,\n ): Promise<Response> {\n let retryCount = 0;\n\n while (true) {\n try {\n return await fetch(url, fetchOptions);\n } catch (networkError) {\n if (signal?.aborted || retryCount >= maxNetworkRetries) {\n throw networkError;\n }\n\n retryCount += 1;\n await waitForNetworkRetry(retryCount);\n\n if (signal?.aborted) {\n throw networkError;\n }\n }\n }\n }\n\n /**\n * Main request function\n */\n async function request<TResponse = unknown>(\n endpoint: string,\n options: RequestOptions = {},\n ): Promise<TResponse> {\n const {\n method = \"GET\",\n headers: customHeaders,\n params,\n body,\n signal,\n priority,\n } = options;\n\n const url = params ? buildUrl(endpoint, params) : joinUrl(endpoint);\n\n const headers = await buildHeaders(customHeaders);\n\n let response: Response;\n\n try {\n const fetchOptions: RequestInit = { method, headers };\n if (credentials) fetchOptions.credentials = credentials;\n if (cache) fetchOptions.cache = cache;\n if (priority) fetchOptions.priority = priority;\n const serializedBody =\n body && method !== \"GET\" ? JSON.stringify(body) : null;\n if (serializedBody) fetchOptions.body = serializedBody;\n if (signal) fetchOptions.signal = signal;\n response = await fetchWithNetworkRetry(url, fetchOptions, signal);\n } catch (networkError) {\n throw new ApiError(\n `Network error: ${networkError instanceof Error ? networkError.message : \"Unknown network error\"}`,\n 0,\n null,\n );\n }\n\n return handleResponse<TResponse>(response, method, url);\n }\n\n /**\n * Request with FormData (for file uploads)\n */\n async function requestWithFormData<TResponse = unknown>(\n endpoint: string,\n formData: FormData,\n options: Omit<RequestOptions, \"body\" | \"params\"> & {\n method?: \"POST\" | \"PUT\" | \"PATCH\";\n } = {},\n ): Promise<TResponse> {\n const {\n method = \"POST\",\n headers: customHeaders,\n signal,\n priority,\n } = options;\n\n const url = joinUrl(endpoint);\n const headers = await buildHeaders(customHeaders);\n\n // Remove Content-Type to let browser set it with boundary\n delete headers[\"Content-Type\"];\n\n let response: Response;\n\n try {\n const fetchOptions: RequestInit = { method, headers, body: formData };\n if (credentials) fetchOptions.credentials = credentials;\n if (cache) fetchOptions.cache = cache;\n if (priority) fetchOptions.priority = priority;\n if (signal) fetchOptions.signal = signal;\n response = await fetchWithNetworkRetry(url, fetchOptions, signal);\n } catch (networkError) {\n throw new ApiError(\n `Network error: ${networkError instanceof Error ? networkError.message : \"Unknown network error\"}`,\n 0,\n null,\n );\n }\n\n return handleResponse<TResponse>(response, method, url);\n }\n\n // Return client with convenience methods\n return {\n request: request,\n requestWithFormData: requestWithFormData,\n\n // Convenience methods for common HTTP verbs\n get: <TResponse = unknown>(\n endpoint: string,\n params?: Record<string, unknown>,\n options?: Omit<RequestOptions, \"method\" | \"params\">,\n ): Promise<TResponse> =>\n request<TResponse>(endpoint, {\n ...options,\n method: \"GET\" as const,\n ...(params && { params }),\n }),\n\n post: <TResponse = unknown>(\n endpoint: string,\n body?: unknown,\n options?: Omit<RequestOptions, \"method\" | \"body\">,\n ): Promise<TResponse> =>\n request<TResponse>(endpoint, {\n ...options,\n method: \"POST\",\n body,\n }),\n\n put: <TResponse = unknown>(\n endpoint: string,\n body?: unknown,\n options?: Omit<RequestOptions, \"method\" | \"body\">,\n ): Promise<TResponse> =>\n request<TResponse>(endpoint, {\n ...options,\n method: \"PUT\",\n body,\n }),\n\n patch: <TResponse = unknown>(\n endpoint: string,\n body?: unknown,\n options?: Omit<RequestOptions, \"method\" | \"body\">,\n ): Promise<TResponse> =>\n request<TResponse>(endpoint, {\n ...options,\n method: \"PATCH\",\n body,\n }),\n\n delete: <TResponse = unknown>(\n endpoint: string,\n options?: Omit<RequestOptions, \"method\">,\n ): Promise<TResponse> =>\n request<TResponse>(endpoint, {\n ...options,\n method: \"DELETE\",\n }),\n };\n}\n\nexport type FetchClient = FetchClientInstance;\n","import {\n createFetchClient,\n type FetchClientInstance,\n} from \"@fluid-app/api-client-core\";\nimport { getAuthToken } from \"@fluid-app/fluid-cli\";\n\nexport type ApiClient = FetchClientInstance;\n\n/** Base URL for all API calls. Set FLUID_API_BASE to point at staging. */\nfunction getApiBase(): string {\n return process.env[\"FLUID_API_BASE\"] ?? \"https://api.fluid.app\";\n}\n\nexport function createApiClient(tokenOverride?: string): ApiClient {\n return createFetchClient({\n baseUrl: getApiBase(),\n getAuthToken: () => tokenOverride ?? getAuthToken() ?? null,\n });\n}\n\nexport function requireToken(): string {\n const token = getAuthToken();\n if (!token) {\n console.error(\"Not logged in. Run `fluid login` first.\");\n process.exit(1);\n }\n return token;\n}\n\n// ── Legacy (MySite) theme API ────────────────────────────────────────\n//\n// A MySite theme is a Themes::LegacyTheme with exactly ONE template\n// carrying three Liquid/CSS blobs: content, stylesheet, head. There is\n// no draft state through this API — PATCHing a template publishes it\n// immediately (Rails calls template.publish on every save). That is\n// why `mysite dev` syncs to a scratch copy and `mysite publish` is the\n// only command that touches the real theme.\n\nexport interface MysiteTemplate {\n id?: number;\n name?: string | null;\n content: string | null;\n stylesheet: string | null;\n head: string | null;\n status?: string | null;\n}\n\nexport interface MysiteTheme {\n id: number;\n name: string;\n description?: string | null;\n image_url?: string | null;\n public?: boolean;\n company_id?: number;\n template?: MysiteTemplate | null;\n}\n\nexport interface MysiteFiles {\n content: string;\n stylesheet: string;\n head: string;\n}\n\nexport async function listMysiteThemes(api: ApiClient): Promise<MysiteTheme[]> {\n const res = await api.get<{ legacy_themes?: MysiteTheme[] }>(\n \"/api/legacy_themes\",\n );\n return res.legacy_themes ?? [];\n}\n\nexport async function getMysiteTheme(\n api: ApiClient,\n id: number,\n): Promise<MysiteTheme> {\n const res = await api.get<{ legacy_theme: MysiteTheme }>(\n `/api/legacy_themes/${id}`,\n );\n return res.legacy_theme;\n}\n\nexport async function createMysiteTheme(\n api: ApiClient,\n args: {\n name: string;\n description?: string;\n template: MysiteFiles;\n /** Dev copies send false so they stay out of the member-facing\n * theme picker. Requires the Rails create action to accept the\n * param (fluid PR pairs with this one); older servers ignore it\n * and force public — visible but otherwise harmless. */\n public?: boolean;\n },\n): Promise<MysiteTheme> {\n const res = await api.post<{ legacy_theme: MysiteTheme }>(\n \"/api/legacy_themes\",\n { legacy_theme: args },\n );\n return res.legacy_theme;\n}\n\n/** PATCH the theme's template blobs. WARNING: this publishes\n * immediately — the server has no draft state for MySite themes. */\nexport async function updateMysiteTemplate(\n api: ApiClient,\n id: number,\n template: MysiteFiles,\n): Promise<MysiteTheme> {\n const res = await api.patch<{ legacy_theme: MysiteTheme }>(\n `/api/legacy_themes/${id}`,\n { legacy_theme: { template } },\n );\n return res.legacy_theme;\n}\n\n// ── Current user ─────────────────────────────────────────────────────\n\n/** `GET /api/me` exposes `mysite_url` = `{company base_url}my/{username}`\n * for the active company — exactly the page the dev server should\n * preview. Returns null when the user has no username/membership in\n * the company (no MySite to preview). */\nexport async function fetchMysiteUrl(api: ApiClient): Promise<URL | null> {\n const me = await api.get<{ mysite_url?: string | null }>(\"/api/me\");\n if (!me.mysite_url) return null;\n try {\n return new URL(me.mysite_url);\n } catch {\n return null;\n }\n}\n","import { Command } from \"commander\";\nimport chalk from \"chalk\";\nimport { createApiClient, listMysiteThemes, requireToken } from \"../api.js\";\n\nexport function createListCommand(): Command {\n return new Command(\"list\")\n .description(\"List the company's MySite themes\")\n .action(async () => {\n requireToken();\n const api = createApiClient();\n const themes = await listMysiteThemes(api);\n if (themes.length === 0) {\n console.log(\"No MySite themes found for this company.\");\n return;\n }\n for (const t of themes) {\n console.log(\n `${chalk.cyan(String(t.id).padStart(8))} ${t.name}${\n t.public ? chalk.dim(\" (public)\") : \"\"\n }`,\n );\n }\n console.log(\n chalk.dim(\n `\\nClone one locally: fluid mysite pull --theme <id> --root <dir>`,\n ),\n );\n });\n}\n","import { readFile, writeFile, mkdir } from \"node:fs/promises\";\nimport { existsSync } from \"node:fs\";\nimport path from \"node:path\";\nimport type { MysiteFiles, MysiteTemplate } from \"./api.js\";\n\n// Local layout for a pulled MySite theme. A MySite theme is a single\n// template with three blobs, so the whole \"project\" is three files\n// plus a manifest:\n//\n// content.liquid — the page body (Liquid)\n// head.liquid — extra <head> markup (Liquid)\n// stylesheet.css — theme CSS\n// .fluid-mysite.json — theme id/name + the dev-copy id `mysite dev`\n// syncs against\n\nexport const MANIFEST_FILE = \".fluid-mysite.json\";\n\nexport const THEME_FILES = {\n content: \"content.liquid\",\n head: \"head.liquid\",\n stylesheet: \"stylesheet.css\",\n} as const;\n\nexport interface MysiteManifest {\n themeId: number;\n name: string;\n /** Scratch copy `mysite dev` syncs to (created lazily on first dev\n * run). Kept out of the way of the real theme because template\n * updates publish immediately server-side. */\n devThemeId?: number;\n}\n\nexport function manifestPath(root: string): string {\n return path.join(root, MANIFEST_FILE);\n}\n\nexport async function readManifest(\n root: string,\n): Promise<MysiteManifest | null> {\n const p = manifestPath(root);\n if (!existsSync(p)) return null;\n try {\n const parsed = JSON.parse(await readFile(p, \"utf8\")) as MysiteManifest;\n return typeof parsed.themeId === \"number\" ? parsed : null;\n } catch {\n return null;\n }\n}\n\nexport async function writeManifest(\n root: string,\n manifest: MysiteManifest,\n): Promise<void> {\n await mkdir(root, { recursive: true });\n await writeFile(\n manifestPath(root),\n JSON.stringify(manifest, null, 2) + \"\\n\",\n \"utf8\",\n );\n}\n\nexport async function writeThemeFiles(\n root: string,\n template: MysiteTemplate | null | undefined,\n): Promise<string[]> {\n await mkdir(root, { recursive: true });\n const written: string[] = [];\n const blobs: MysiteFiles = {\n content: template?.content ?? \"\",\n head: template?.head ?? \"\",\n stylesheet: template?.stylesheet ?? \"\",\n };\n for (const key of Object.keys(THEME_FILES) as Array<keyof MysiteFiles>) {\n const file = THEME_FILES[key];\n await writeFile(path.join(root, file), blobs[key], \"utf8\");\n written.push(file);\n }\n return written;\n}\n\nexport async function readThemeFiles(root: string): Promise<MysiteFiles> {\n const read = async (file: string): Promise<string> => {\n const p = path.join(root, file);\n if (!existsSync(p)) return \"\";\n return readFile(p, \"utf8\");\n };\n return {\n content: await read(THEME_FILES.content),\n head: await read(THEME_FILES.head),\n stylesheet: await read(THEME_FILES.stylesheet),\n };\n}\n\nexport function themeFilePaths(root: string): string[] {\n return Object.values(THEME_FILES).map((f) => path.join(root, f));\n}\n","import path from \"node:path\";\nimport { Command } from \"commander\";\nimport chalk from \"chalk\";\nimport { createApiClient, getMysiteTheme, requireToken } from \"../api.js\";\nimport {\n readManifest,\n writeManifest,\n writeThemeFiles,\n MANIFEST_FILE,\n} from \"../theme-files.js\";\n\nexport function createPullCommand(): Command {\n return new Command(\"pull\")\n .description(\n \"Download a MySite theme's Liquid + CSS into a local directory\",\n )\n .requiredOption(\"-t, --theme <id>\", \"MySite theme id (see `mysite list`)\")\n .option(\"--root <dir>\", \"Target directory\", \".\")\n .option(\"--yes\", \"Overwrite existing local files without asking\", false)\n .action(async (opts: { theme: string; root: string; yes: boolean }) => {\n requireToken();\n const themeId = Number(opts.theme);\n if (!Number.isInteger(themeId)) {\n console.error(`Invalid theme id: ${opts.theme}`);\n process.exit(1);\n }\n const root = path.resolve(opts.root);\n\n const existing = await readManifest(root);\n if (existing && existing.themeId !== themeId && !opts.yes) {\n console.error(\n `${root} already tracks MySite theme #${existing.themeId} (${existing.name}).\\n` +\n `Re-run with --yes to overwrite it with theme #${themeId}, or pick a different --root.`,\n );\n process.exit(1);\n }\n\n const api = createApiClient();\n const theme = await getMysiteTheme(api, themeId);\n const written = await writeThemeFiles(root, theme.template);\n await writeManifest(root, {\n themeId: theme.id,\n name: theme.name,\n // Preserve an existing dev copy when re-pulling the same theme\n // so `mysite dev` keeps reusing it instead of littering the\n // company with scratch themes.\n ...(existing && existing.themeId === theme.id && existing.devThemeId\n ? { devThemeId: existing.devThemeId }\n : {}),\n });\n\n console.log(\n `${chalk.green(\"✔\")} Pulled ${chalk.bold(theme.name)} (#${theme.id}) into ${root}`,\n );\n for (const f of written) console.log(` ${chalk.dim(\"wrote\")} ${f}`);\n console.log(` ${chalk.dim(\"wrote\")} ${MANIFEST_FILE}`);\n console.log(\n `\\nNext: ${chalk.cyan(\"fluid mysite dev\")} to preview edits live, ` +\n `${chalk.cyan(\"fluid mysite publish\")} to push them to the live theme.`,\n );\n });\n}\n","export type ReloadMode = \"full-page\" | \"off\";\n\nexport function buildHotReloadScript(mode: ReloadMode): string {\n return `\n<script>\n(() => {\n window.__FLUID_CLI_ENV__ = ${JSON.stringify({ mode })};\n\n class HotReload {\n static reloadMode() { return window.__FLUID_CLI_ENV__.mode; }\n static isActive() { return HotReload.reloadMode() !== \"off\"; }\n static refresh() {\n console.log(\"[HotReload] Refreshing page\");\n window.location.reload();\n }\n }\n\n class SSEClient {\n constructor(url, handler) {\n if (typeof EventSource === \"undefined\") {\n console.error(\"[HotReload] EventSource not supported in this browser.\");\n return;\n }\n console.log(\"[HotReload] Initializing…\");\n this.url = url;\n this.handler = handler;\n }\n connect() {\n const es = new EventSource(this.url);\n es.onopen = () => console.log(\"[HotReload] SSE connected.\");\n es.onerror = () => {\n console.log(\"[HotReload] SSE closed. Reconnecting in 5s…\");\n es.close();\n setTimeout(() => this.connect(), 5000);\n };\n es.onmessage = (msg) => {\n let data;\n try {\n data = JSON.parse(msg.data);\n } catch {\n return; // non-JSON frame (keep-alive etc.) — ignore\n }\n this.handler(data);\n };\n }\n }\n\n if (HotReload.isActive()) {\n new SSEClient(\"/hot-reload\", (data) => {\n if (data.reload_page) HotReload.refresh();\n }).connect();\n }\n})();\n</script>`;\n}\n\nexport function injectHotReload(html: string, mode: ReloadMode): string {\n const script = buildHotReloadScript(mode);\n if (html.includes(\"</body>\")) {\n return html.replace(\"</body>\", `${script}\\n</body>`);\n }\n return html + script;\n}\n","import https from \"node:https\";\nimport type { IncomingMessage, ServerResponse } from \"node:http\";\nimport { injectHotReload, type ReloadMode } from \"./hot-reload.js\";\n\n// Localhost → production proxy for MySite previews. MySite pages are\n// rendered server-side (Liquid via PageBuilder), and the ONLY draft\n// mechanism is the `preview_theme_id` query param on the public\n// visitor route (`/my/:username`). A raw preview URL loses that param\n// on every internal link click — the proxy pins it on every request\n// instead, and injects a live-reload SSE client into HTML responses\n// so saves refresh the page.\n//\n// Adapted from theme-dev's proxy; MySite needs neither the\n// `x-fluid-theme` header nor the `replace_templates` trick (drafts\n// live on a synced scratch theme, not in pending local files), so\n// this version is a straight pass-through plus one query param.\n\nconst HOP_BY_HOP = new Set([\n \"connection\",\n \"keep-alive\",\n \"proxy-authenticate\",\n \"proxy-authorization\",\n \"te\",\n \"trailer\",\n \"transfer-encoding\",\n \"upgrade\",\n \"content-security-policy\",\n]);\n\nexport interface ProxyOptions {\n /** Production host serving the MySite (from /api/me's mysite_url). */\n host: string;\n /** Scratch theme id to pin via preview_theme_id. */\n previewThemeId: number;\n reloadMode: ReloadMode;\n}\n\nexport async function proxyRequest(\n req: IncomingMessage,\n res: ServerResponse,\n opts: ProxyOptions,\n): Promise<void> {\n const headers: Record<string, string> = {};\n for (const [k, v] of Object.entries(req.headers)) {\n if (!HOP_BY_HOP.has(k.toLowerCase()) && typeof v === \"string\") {\n headers[k] = v;\n }\n }\n headers[\"host\"] = opts.host;\n headers[\"user-agent\"] = \"Fluid CLI\";\n headers[\"accept-encoding\"] = \"identity\";\n\n const url = new URL(req.url ?? \"/\", `http://${req.headers.host}`);\n // Harmless on asset/API requests; the mysites controller reads it\n // on page requests to render the scratch theme.\n url.searchParams.set(\"preview_theme_id\", String(opts.previewThemeId));\n\n const isGet = req.method === \"GET\" || req.method === \"HEAD\";\n let body: Buffer | undefined;\n if (!isGet) {\n body = await readBody(req);\n if (body.length > 0) {\n headers[\"content-length\"] = String(body.length);\n }\n }\n\n return new Promise((resolve, reject) => {\n const options: https.RequestOptions = {\n hostname: opts.host,\n port: 443,\n path: url.pathname + (url.search || \"\"),\n method: req.method ?? \"GET\",\n headers,\n };\n\n const [hostname, portStr] = opts.host.split(\":\");\n const port = portStr ? Number(portStr) : 443;\n options.hostname = hostname;\n options.port = port;\n\n const proxyReq = https.request(options, (proxyRes) => {\n // Upstream can drop mid-stream (network blip, server restart) —\n // without this the \"error\" event is unhandled, which crashes the\n // process and leaves the promise hung.\n proxyRes.on(\"error\", reject);\n const contentType = proxyRes.headers[\"content-type\"] ?? \"\";\n const isHtml = contentType.includes(\"text/html\");\n\n const responseHeaders: Record<string, string | string[]> = {};\n for (const [k, v] of Object.entries(proxyRes.headers)) {\n if (!HOP_BY_HOP.has(k.toLowerCase()) && v !== undefined) {\n responseHeaders[k] = v as string | string[];\n }\n }\n\n if (isHtml) {\n const chunks: Buffer[] = [];\n proxyRes.on(\"data\", (chunk: Buffer) => chunks.push(chunk));\n proxyRes.on(\"end\", () => {\n let html = Buffer.concat(chunks).toString(\"utf-8\");\n html = injectHotReload(html, opts.reloadMode);\n responseHeaders[\"content-length\"] = String(Buffer.byteLength(html));\n res.writeHead(proxyRes.statusCode ?? 200, responseHeaders);\n res.end(html);\n resolve();\n });\n } else {\n res.writeHead(proxyRes.statusCode ?? 200, responseHeaders);\n proxyRes.pipe(res);\n proxyRes.on(\"end\", resolve);\n }\n });\n\n proxyReq.on(\"error\", (err) => {\n reject(err);\n });\n\n if (body) proxyReq.write(body);\n proxyReq.end();\n });\n}\n\nfunction readBody(req: IncomingMessage): Promise<Buffer> {\n return new Promise((resolve, reject) => {\n const chunks: Buffer[] = [];\n req.on(\"data\", (chunk: Buffer) => chunks.push(chunk));\n req.on(\"end\", () => resolve(Buffer.concat(chunks)));\n req.on(\"error\", reject);\n });\n}\n","import type { ServerResponse } from \"node:http\";\n\nexport class SSEStream {\n private responses = new Set<ServerResponse>();\n\n add(res: ServerResponse): void {\n res.writeHead(200, {\n \"Content-Type\": \"text/event-stream\",\n \"Cache-Control\": \"no-cache\",\n Connection: \"keep-alive\",\n \"Access-Control-Allow-Origin\": \"*\",\n });\n res.write(\":\\n\\n\");\n this.responses.add(res);\n res.on(\"close\", () => this.responses.delete(res));\n }\n\n broadcast(data: string): void {\n const payload = `data: ${data}\\n\\n`;\n for (const res of this.responses) {\n try {\n res.write(payload);\n } catch {\n this.responses.delete(res);\n }\n }\n }\n\n close(): void {\n for (const res of this.responses) {\n try {\n res.end();\n } catch {\n // ignore\n }\n }\n this.responses.clear();\n }\n\n get size(): number {\n return this.responses.size;\n }\n}\n","import http from \"node:http\";\nimport path from \"node:path\";\nimport { hostname } from \"node:os\";\nimport { randomBytes } from \"node:crypto\";\nimport { Command } from \"commander\";\nimport chalk from \"chalk\";\nimport chokidar from \"chokidar\";\nimport {\n createApiClient,\n createMysiteTheme,\n fetchMysiteUrl,\n getMysiteTheme,\n requireToken,\n updateMysiteTemplate,\n type ApiClient,\n} from \"../api.js\";\nimport {\n readManifest,\n readThemeFiles,\n themeFilePaths,\n writeManifest,\n type MysiteManifest,\n} from \"../theme-files.js\";\nimport { proxyRequest } from \"../dev-server/proxy.js\";\nimport { SSEStream } from \"../dev-server/sse.js\";\nimport type { ReloadMode } from \"../dev-server/hot-reload.js\";\n\nconst DEFAULT_PORT = 9293;\n\n// Server-side rendering is the only way to see a MySite theme (Liquid\n// + drops like fluid_affiliate resolve on the Rails side), and template\n// PATCHes publish immediately — so \"test before committing\" means:\n//\n// 1. keep a scratch copy of the theme (created once, reused, and\n// never assigned to anyone — only reachable via preview_theme_id)\n// 2. PATCH the scratch copy on every local save\n// 3. proxy localhost → the user's real /my/:username page with\n// preview_theme_id pinned, live-reloading on sync\n//\n// The real theme is only touched by `fluid mysite publish`.\n\nexport function createDevCommand(): Command {\n return new Command(\"dev\")\n .description(\n \"Preview local MySite edits live without touching the published theme\",\n )\n .option(\"--root <dir>\", \"Theme directory (from `mysite pull`)\", \".\")\n .option(\"--port <port>\", \"Local preview port\", String(DEFAULT_PORT))\n .option(\n \"--live-reload <mode>\",\n \"Reload browser on save: full-page | off\",\n \"full-page\",\n )\n .action(\n async (opts: { root: string; port: string; liveReload: string }) => {\n requireToken();\n const root = path.resolve(opts.root);\n const port = Number(opts.port) || DEFAULT_PORT;\n const reloadMode: ReloadMode =\n opts.liveReload === \"off\" ? \"off\" : \"full-page\";\n\n const manifest = await readManifest(root);\n if (!manifest) {\n console.error(\n `No ${chalk.bold(\".fluid-mysite.json\")} in ${root}. ` +\n `Run ${chalk.cyan(\"fluid mysite pull --theme <id>\")} first.`,\n );\n process.exit(1);\n }\n\n const api = createApiClient();\n\n const mysiteUrl = await fetchMysiteUrl(api);\n if (!mysiteUrl) {\n console.error(\n \"Couldn't determine your MySite URL — your user needs a username \" +\n \"in this company to have a /my page to preview against.\",\n );\n process.exit(1);\n }\n\n const devThemeId = await ensureDevCopy(api, root, manifest);\n\n // Initial sync so the scratch copy reflects local files even if\n // they were edited while dev wasn't running.\n await syncToDevCopy(api, root, devThemeId, \"initial sync\");\n\n const sse = new SSEStream();\n const server = http.createServer((req, res) => {\n if (req.url === \"/hot-reload\") {\n sse.add(res);\n return;\n }\n // Send the bare origin to the user's MySite page so consumers\n // that only know the port (Mist's preview webview, a human\n // typing localhost:9293) land on the right path without\n // knowing the username.\n if ((req.url ?? \"/\").split(\"?\")[0] === \"/\") {\n res.writeHead(302, { location: mysiteUrl.pathname });\n res.end();\n return;\n }\n void proxyRequest(req, res, {\n host: mysiteUrl.host,\n previewThemeId: devThemeId,\n reloadMode,\n }).catch((err) => {\n if (!res.headersSent) res.writeHead(502);\n res.end(`Proxy error: ${err instanceof Error ? err.message : err}`);\n });\n });\n\n const stopWatcher = watchThemeFiles(root, async () => {\n const ok = await syncToDevCopy(api, root, devThemeId, \"sync\");\n if (ok) sse.broadcast(JSON.stringify({ reload_page: true }));\n });\n\n // Walk forward from the requested port if it's taken — the\n // team runs multiple previews side by side (and Mist will\n // spawn these programmatically), so EADDRINUSE shouldn't be\n // a crash. An explicitly passed --port that's busy still\n // gets a clear message about where we actually bound.\n const boundPort = await listenOnAvailablePort(server, port);\n if (boundPort !== port) {\n console.log(\n chalk.dim(`Port ${port} is in use — using ${boundPort} instead.`),\n );\n }\n {\n const localUrl = `http://127.0.0.1:${boundPort}${mysiteUrl.pathname}`;\n const prodPreview = `${mysiteUrl.href}?preview_theme_id=${devThemeId}`;\n console.log(\n `\\n${chalk.green(\"✔\")} Dev copy ${chalk.bold(`#${devThemeId}`)} ready — the live theme (#${manifest.themeId}) stays untouched.\\n`,\n );\n console.log(\n ` ${chalk.bold(\"Local preview:\")} ${chalk.cyan(localUrl)}`,\n );\n console.log(\n ` ${chalk.bold(\"Production preview:\")} ${chalk.cyan(prodPreview)}`,\n );\n console.log(\n ` ${chalk.bold(\"Live site:\")} ${chalk.dim(mysiteUrl.href)}\\n`,\n );\n console.log(\n chalk.dim(\n \"Edits to content.liquid / head.liquid / stylesheet.css sync on save. \" +\n \"Run `fluid mysite publish` to push them live. Ctrl+C to stop.\",\n ),\n );\n }\n\n const shutdown = async () => {\n sse.close();\n server.close();\n await stopWatcher();\n process.exit(0);\n };\n process.on(\"SIGINT\", () => void shutdown());\n process.on(\"SIGTERM\", () => void shutdown());\n },\n );\n}\n\n/** Bind to `startPort`, walking forward on EADDRINUSE (up to\n * `attempts` ports) so parallel previews coexist. Resolves with the\n * port actually bound; rejects on non-port errors. */\nfunction listenOnAvailablePort(\n server: http.Server,\n startPort: number,\n attempts = 20,\n): Promise<number> {\n return new Promise((resolve, reject) => {\n let port = startPort;\n const tryListen = () => {\n const onError = (err: NodeJS.ErrnoException) => {\n server.removeListener(\"listening\", onListening);\n if (err.code === \"EADDRINUSE\" && port - startPort < attempts) {\n port += 1;\n tryListen();\n } else {\n reject(err);\n }\n };\n const onListening = () => {\n server.removeListener(\"error\", onError);\n resolve(port);\n };\n server.once(\"error\", onError);\n server.once(\"listening\", onListening);\n server.listen(port, \"127.0.0.1\");\n };\n tryListen();\n });\n}\n\n/** Find or create the scratch theme this directory syncs against.\n *\n * The name MUST be unique within the company: the Rails update action\n * resolves templates by (name, themeable_type, company), so a scratch\n * copy sharing the real theme's name would silently write to the REAL\n * template. A random suffix keeps them apart. */\nasync function ensureDevCopy(\n api: ApiClient,\n root: string,\n manifest: MysiteManifest,\n): Promise<number> {\n if (manifest.devThemeId) {\n try {\n const existing = await getMysiteTheme(api, manifest.devThemeId);\n return existing.id;\n } catch {\n console.log(\n chalk.dim(\n `Stored dev copy #${manifest.devThemeId} is gone — creating a fresh one.`,\n ),\n );\n }\n }\n\n const files = await readThemeFiles(root);\n const suffix = `${hostname().split(\".\")[0] ?? \"dev\"}-${randomBytes(2).toString(\"hex\")}`;\n const name = `${manifest.name} (dev ${suffix})`.slice(0, 50);\n const created = await createMysiteTheme(api, {\n name,\n description:\n \"Fluid CLI development copy — auto-created by `fluid mysite dev`. Safe to delete.\",\n template: files,\n public: false,\n });\n await writeManifest(root, { ...manifest, devThemeId: created.id });\n return created.id;\n}\n\nasync function syncToDevCopy(\n api: ApiClient,\n root: string,\n devThemeId: number,\n label: string,\n): Promise<boolean> {\n const started = Date.now();\n try {\n const files = await readThemeFiles(root);\n await updateMysiteTemplate(api, devThemeId, files);\n console.log(\n `${chalk.green(\"✔\")} ${label} ${chalk.dim(`(${Date.now() - started}ms)`)}`,\n );\n return true;\n } catch (err) {\n console.error(\n `${chalk.red(\"✖\")} ${label} failed: ${err instanceof Error ? err.message : err}`,\n );\n return false;\n }\n}\n\n/** Watch the three theme files, coalescing rapid saves into one sync. */\nfunction watchThemeFiles(\n root: string,\n onChange: () => Promise<void>,\n): () => Promise<void> {\n const watcher = chokidar.watch(themeFilePaths(root), {\n ignoreInitial: true,\n awaitWriteFinish: { stabilityThreshold: 50, pollInterval: 10 },\n });\n let pending = Promise.resolve();\n const enqueue = () => {\n pending = pending.then(onChange).catch(() => {});\n };\n watcher.on(\"change\", enqueue);\n watcher.on(\"add\", enqueue);\n return () => watcher.close();\n}\n","import path from \"node:path\";\nimport { Command } from \"commander\";\nimport chalk from \"chalk\";\nimport prompts from \"prompts\";\nimport {\n createApiClient,\n fetchMysiteUrl,\n requireToken,\n updateMysiteTemplate,\n} from \"../api.js\";\nimport { readManifest, readThemeFiles } from \"../theme-files.js\";\n\nexport function createPublishCommand(): Command {\n return new Command(\"publish\")\n .description(\n \"Push local files to the LIVE MySite theme (immediate — no draft state)\",\n )\n .option(\"--root <dir>\", \"Theme directory (from `mysite pull`)\", \".\")\n .option(\"--yes\", \"Skip the confirmation prompt\", false)\n .action(async (opts: { root: string; yes: boolean }) => {\n requireToken();\n const root = path.resolve(opts.root);\n const manifest = await readManifest(root);\n if (!manifest) {\n console.error(\n `No ${chalk.bold(\".fluid-mysite.json\")} in ${root}. ` +\n `Run ${chalk.cyan(\"fluid mysite pull --theme <id>\")} first.`,\n );\n process.exit(1);\n }\n\n if (!opts.yes) {\n const { confirmed } = await prompts({\n type: \"confirm\",\n name: \"confirmed\",\n message:\n `Publish local files to \"${manifest.name}\" (#${manifest.themeId})? ` +\n \"MySite pages using this theme update immediately.\",\n initial: false,\n });\n if (!confirmed) {\n console.log(\"Aborted.\");\n return;\n }\n }\n\n const api = createApiClient();\n const files = await readThemeFiles(root);\n await updateMysiteTemplate(api, manifest.themeId, files);\n console.log(\n `${chalk.green(\"✔\")} Published ${chalk.bold(manifest.name)} (#${manifest.themeId})`,\n );\n\n // Best-effort convenience link; publish already succeeded.\n try {\n const url = await fetchMysiteUrl(api);\n if (url)\n console.log(` ${chalk.dim(\"Live at\")} ${chalk.cyan(url.href)}`);\n } catch {\n // ignore\n }\n });\n}\n","import { Command } from \"commander\";\nimport type { PluginContext } from \"@fluid-app/fluid-cli\";\nimport { createListCommand } from \"./list.js\";\nimport { createPullCommand } from \"./pull.js\";\nimport { createDevCommand } from \"./dev.js\";\nimport { createPublishCommand } from \"./publish.js\";\n\nexport function registerMysiteCommand(ctx: PluginContext): void {\n const cmd = new Command(\"mysite\").description(\n \"MySite theme workflow — list, pull, dev preview server, publish\",\n );\n\n cmd.addCommand(createListCommand());\n cmd.addCommand(createPullCommand());\n cmd.addCommand(createDevCommand());\n cmd.addCommand(createPublishCommand());\n\n ctx.program.addCommand(cmd);\n}\n","import type { FluidPlugin, PluginContext } from \"@fluid-app/fluid-cli\";\nimport { registerMysiteCommand } from \"./commands/mysite.js\";\n\nconst plugin: FluidPlugin = {\n name: \"@fluid-app/fluid-cli-mysite\",\n version: \"0.1.0\",\n register(ctx: PluginContext) {\n registerMysiteCommand(ctx);\n },\n};\n\nexport default plugin;\n"],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@fluid-app/fluid-cli-mysite",
3
+ "version": "0.1.0",
4
+ "description": "Fluid CLI plugin for MySite theme workflows — pull, dev preview server, publish",
5
+ "files": [
6
+ "dist",
7
+ "README.md"
8
+ ],
9
+ "type": "module",
10
+ "main": "./dist/index.mjs",
11
+ "types": "./dist/index.d.mts",
12
+ "exports": {
13
+ ".": {
14
+ "import": {
15
+ "types": "./dist/index.d.mts",
16
+ "default": "./dist/index.mjs"
17
+ }
18
+ }
19
+ },
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "dependencies": {
24
+ "chalk": "^5.6.2",
25
+ "chokidar": "^4.0.0",
26
+ "commander": "^12.0.0",
27
+ "prompts": "^2.4.2",
28
+ "@fluid-app/fluid-cli": "0.1.12"
29
+ },
30
+ "devDependencies": {
31
+ "@types/node": "24.10.12",
32
+ "@types/prompts": "^2.4.9",
33
+ "tsdown": "^0.21.0",
34
+ "typescript": "^5",
35
+ "@fluid-app/api-client-core": "0.1.0",
36
+ "@fluid-app/typescript-config": "0.0.0"
37
+ },
38
+ "engines": {
39
+ "node": ">=18.0.0"
40
+ },
41
+ "scripts": {
42
+ "build": "tsdown",
43
+ "dev": "tsdown --watch",
44
+ "typecheck": "tsgo --noEmit",
45
+ "lint": "oxlint"
46
+ }
47
+ }