@fluid-app/fluid-cli-theme-dev 0.1.21 → 0.1.23
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/README.md +14 -0
- package/dist/index.mjs +155 -2
- package/dist/index.mjs.map +1 -1
- package/package.json +8 -4
- package/.turbo/turbo-build.log +0 -16
- package/.turbo/turbo-typecheck.log +0 -4
- package/jest.config.cjs +0 -21
- package/jest.mocks/fluid-cli.ts +0 -33
- package/src/__tests__/plugin-state.test.ts +0 -186
- package/src/api.ts +0 -28
- package/src/commands/dev.ts +0 -186
- package/src/commands/init.ts +0 -51
- package/src/commands/lint.ts +0 -186
- package/src/commands/navigate.ts +0 -259
- package/src/commands/pull.ts +0 -242
- package/src/commands/push.ts +0 -220
- package/src/commands/theme.ts +0 -23
- package/src/index.ts +0 -12
- package/src/plugin-state.ts +0 -171
- package/src/theme/dev-server/hot-reload.ts +0 -65
- package/src/theme/dev-server/index.ts +0 -145
- package/src/theme/dev-server/proxy.ts +0 -125
- package/src/theme/dev-server/sse.ts +0 -43
- package/src/theme/dev-server/watcher.ts +0 -54
- package/src/theme/file.ts +0 -104
- package/src/theme/fluid-ignore.ts +0 -64
- package/src/theme/mime-type.ts +0 -45
- package/src/theme/root.ts +0 -54
- package/src/theme/syncer.ts +0 -338
- package/src/theme-config.ts +0 -34
- package/src/theme-picker.ts +0 -164
- package/src/workspace.ts +0 -71
- package/tsconfig.json +0 -10
- package/tsdown.config.ts +0 -19
- /package/{skills → dist/skills}/themes-review/SKILL.md +0 -0
- /package/{skills → dist/skills}/themes-review/references/blocks-vs-sections.md +0 -0
- /package/{skills → dist/skills}/themes-review/references/css-js-hygiene.md +0 -0
- /package/{skills → dist/skills}/themes-review/references/dead-code.md +0 -0
- /package/{skills → dist/skills}/themes-review/references/dynamism.md +0 -0
- /package/{skills → dist/skills}/themes-review/references/editor-attributes.md +0 -0
- /package/{skills → dist/skills}/themes-review/references/examples.md +0 -0
- /package/{skills → dist/skills}/themes-review/references/fairshare-attributes.md +0 -0
- /package/{skills → dist/skills}/themes-review/references/global-settings.md +0 -0
- /package/{skills → dist/skills}/themes-review/references/liquid-correctness.md +0 -0
- /package/{skills → dist/skills}/themes-review/references/navigation.md +0 -0
- /package/{skills → dist/skills}/themes-review/references/performance.md +0 -0
- /package/{skills → dist/skills}/themes-review/references/security-accessibility.md +0 -0
- /package/{skills → dist/skills}/themes-review/references/setting-types.md +0 -0
package/src/theme/syncer.ts
DELETED
|
@@ -1,338 +0,0 @@
|
|
|
1
|
-
import { sep } from "node:path";
|
|
2
|
-
import type { ApiClient } from "../api.js";
|
|
3
|
-
import type { ThemeFile } from "./file.js";
|
|
4
|
-
import type { ThemeRoot } from "./root.js";
|
|
5
|
-
import { themes, type components } from "@fluid-app/themes-api-client";
|
|
6
|
-
|
|
7
|
-
type RemoteResource = components["schemas"]["ApplicationThemeResource"];
|
|
8
|
-
|
|
9
|
-
export interface SyncResult {
|
|
10
|
-
uploaded: number;
|
|
11
|
-
downloaded: number;
|
|
12
|
-
deleted: number;
|
|
13
|
-
errors: string[];
|
|
14
|
-
validationFailed: boolean;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export class Syncer {
|
|
18
|
-
private checksums = new Map<string, string>();
|
|
19
|
-
|
|
20
|
-
constructor(
|
|
21
|
-
private api: ApiClient,
|
|
22
|
-
private themeId: number,
|
|
23
|
-
private themeRoot: ThemeRoot,
|
|
24
|
-
) {}
|
|
25
|
-
|
|
26
|
-
// ─── Checksum Management ──────────────────────────────────────────────────
|
|
27
|
-
|
|
28
|
-
async fetchChecksums(): Promise<void> {
|
|
29
|
-
const body = await themes.listThemeResources(this.api, this.themeId);
|
|
30
|
-
this.updateChecksums(body.application_theme_resources ?? []);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
private updateChecksums(resources: RemoteResource[]): void {
|
|
34
|
-
for (const r of resources) {
|
|
35
|
-
if (r.key && r.checksum) this.checksums.set(r.key, r.checksum);
|
|
36
|
-
}
|
|
37
|
-
for (const key of this.checksums.keys()) {
|
|
38
|
-
if (this.checksums.has(`${key}.liquid`)) this.checksums.delete(key);
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
hasChanged(file: ThemeFile): boolean {
|
|
43
|
-
return file.checksum() !== this.checksums.get(file.relativePath);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
remoteKeys(): string[] {
|
|
47
|
-
return [...this.checksums.keys()];
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/** Snapshot of remote checksums (key → sha256). Available after fetchChecksums() or downloadAll(). */
|
|
51
|
-
remoteChecksums(): Record<string, string> {
|
|
52
|
-
return Object.fromEntries(this.checksums);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
// ─── Upload ───────────────────────────────────────────────────────────────
|
|
56
|
-
|
|
57
|
-
async uploadFile(file: ThemeFile): Promise<void> {
|
|
58
|
-
if (file.isText) {
|
|
59
|
-
await themes.updateThemeResource(this.api, this.themeId, {
|
|
60
|
-
application_theme_resource: {
|
|
61
|
-
key: file.relativePath,
|
|
62
|
-
content: file.read(),
|
|
63
|
-
},
|
|
64
|
-
});
|
|
65
|
-
} else {
|
|
66
|
-
await this.uploadBinaryFile(file);
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
private async uploadBinaryFile(file: ThemeFile): Promise<void> {
|
|
71
|
-
// Step 1: Create DAM placeholder
|
|
72
|
-
const placeholderBody = await this.api.post<{
|
|
73
|
-
asset: { id: number; canonical_path: string };
|
|
74
|
-
}>("/api/dam/assets", {
|
|
75
|
-
placeholder_asset: {
|
|
76
|
-
description: `Uploaded via Fluid CLI: ${file.name}`,
|
|
77
|
-
mime_type: file.mime.name,
|
|
78
|
-
name: file.name,
|
|
79
|
-
},
|
|
80
|
-
});
|
|
81
|
-
const asset = placeholderBody.asset;
|
|
82
|
-
|
|
83
|
-
// Step 2: Get ImageKit auth token
|
|
84
|
-
const authBody = await this.api.post<{
|
|
85
|
-
token: string;
|
|
86
|
-
signature: string;
|
|
87
|
-
expire: number;
|
|
88
|
-
}>("/api/dam/assets/imagekit_auth", {});
|
|
89
|
-
|
|
90
|
-
// Step 3: Upload to ImageKit via multipart
|
|
91
|
-
const folder = this.canonicalPathToImageKitFolder(asset.canonical_path);
|
|
92
|
-
const formData = new FormData();
|
|
93
|
-
const blob = new Blob([file.readBinary() as unknown as ArrayBuffer], {
|
|
94
|
-
type: file.mime.name,
|
|
95
|
-
});
|
|
96
|
-
formData.append("file", blob, file.name);
|
|
97
|
-
formData.append("token", authBody.token);
|
|
98
|
-
formData.append("signature", authBody.signature);
|
|
99
|
-
formData.append("expire", String(authBody.expire));
|
|
100
|
-
formData.append("folder", folder);
|
|
101
|
-
formData.append("fileName", file.name);
|
|
102
|
-
formData.append("publicKey", "public_j7s4Ih9ETh/OCp41mVQH7tlXBdU=");
|
|
103
|
-
|
|
104
|
-
const ikResp = await fetch(
|
|
105
|
-
"https://upload.imagekit.io/api/v1/files/upload",
|
|
106
|
-
{
|
|
107
|
-
method: "POST",
|
|
108
|
-
body: formData,
|
|
109
|
-
},
|
|
110
|
-
);
|
|
111
|
-
if (!ikResp.ok) throw new Error(`ImageKit upload failed: ${ikResp.status}`);
|
|
112
|
-
const ikBody = (await ikResp.json()) as {
|
|
113
|
-
fileId: string;
|
|
114
|
-
url: string;
|
|
115
|
-
thumbnailUrl: string;
|
|
116
|
-
size: number;
|
|
117
|
-
height?: number;
|
|
118
|
-
width?: number;
|
|
119
|
-
};
|
|
120
|
-
|
|
121
|
-
// Step 4: Backfill DAM asset
|
|
122
|
-
const backfillPayload: Record<string, unknown> = {
|
|
123
|
-
asset: {
|
|
124
|
-
id: asset.id,
|
|
125
|
-
imagekit_file_id: ikBody.fileId,
|
|
126
|
-
imagekit_url: ikBody.url,
|
|
127
|
-
mime_type: file.mime.name,
|
|
128
|
-
name: file.name,
|
|
129
|
-
file_size: ikBody.size,
|
|
130
|
-
expected_path: asset.canonical_path,
|
|
131
|
-
},
|
|
132
|
-
};
|
|
133
|
-
if (ikBody.height)
|
|
134
|
-
(backfillPayload["asset"] as Record<string, unknown>)["height"] =
|
|
135
|
-
ikBody.height;
|
|
136
|
-
if (ikBody.width)
|
|
137
|
-
(backfillPayload["asset"] as Record<string, unknown>)["width"] =
|
|
138
|
-
ikBody.width;
|
|
139
|
-
|
|
140
|
-
const backfillBody = await this.api.post<{
|
|
141
|
-
asset: { code: string; default_variant_url: string };
|
|
142
|
-
}>("/api/dam/assets/backfill_imagekit", backfillPayload);
|
|
143
|
-
|
|
144
|
-
// Step 5: Associate with theme resource
|
|
145
|
-
await themes.updateThemeResource(this.api, this.themeId, {
|
|
146
|
-
application_theme_resource: {
|
|
147
|
-
key: file.relativePath,
|
|
148
|
-
dam_asset: {
|
|
149
|
-
dam_asset_code: backfillBody.asset.code,
|
|
150
|
-
content_type: file.mime.name,
|
|
151
|
-
content_size: ikBody.size,
|
|
152
|
-
filename: file.name,
|
|
153
|
-
handle: backfillBody.asset.code,
|
|
154
|
-
url: backfillBody.asset.default_variant_url,
|
|
155
|
-
preview_image_url: ikBody.thumbnailUrl,
|
|
156
|
-
},
|
|
157
|
-
},
|
|
158
|
-
});
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
private canonicalPathToImageKitFolder(canonicalPath: string): string {
|
|
162
|
-
const parts = canonicalPath.split(".");
|
|
163
|
-
const companyId = parts[0] ?? "unknown";
|
|
164
|
-
const category = parts[1] ?? "files";
|
|
165
|
-
const assetCode = parts[2] ?? "unknown";
|
|
166
|
-
const folderMap: Record<string, string> = {
|
|
167
|
-
images: "images",
|
|
168
|
-
videos: "videos",
|
|
169
|
-
audio: "audio",
|
|
170
|
-
documents: "documents",
|
|
171
|
-
files: "files",
|
|
172
|
-
};
|
|
173
|
-
return `${companyId}/${folderMap[category] ?? "files"}/${assetCode}`;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
// ─── Delete ───────────────────────────────────────────────────────────────
|
|
177
|
-
|
|
178
|
-
async deleteRemoteFile(relativePath: string): Promise<void> {
|
|
179
|
-
await themes.deleteThemeResource(this.api, this.themeId, {
|
|
180
|
-
application_theme_resource: { key: relativePath },
|
|
181
|
-
});
|
|
182
|
-
this.checksums.delete(relativePath);
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
// ─── Download ─────────────────────────────────────────────────────────────
|
|
186
|
-
|
|
187
|
-
async downloadAll(): Promise<RemoteResource[]> {
|
|
188
|
-
const body = await themes.listThemeResources(this.api, this.themeId);
|
|
189
|
-
const resources = body.application_theme_resources ?? [];
|
|
190
|
-
this.updateChecksums(resources);
|
|
191
|
-
return resources;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
async downloadBinaryAsset(url: string): Promise<Buffer> {
|
|
195
|
-
const resp = await fetch(url);
|
|
196
|
-
if (!resp.ok) throw new Error(`Failed to download asset: ${resp.status}`);
|
|
197
|
-
return Buffer.from(await resp.arrayBuffer());
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
// ─── Full Upload ──────────────────────────────────────────────────────────
|
|
201
|
-
|
|
202
|
-
async uploadTheme(
|
|
203
|
-
opts: {
|
|
204
|
-
delete?: boolean;
|
|
205
|
-
validate?: boolean;
|
|
206
|
-
onProgress?: (done: number, total: number) => void;
|
|
207
|
-
} = {},
|
|
208
|
-
): Promise<SyncResult> {
|
|
209
|
-
await this.fetchChecksums();
|
|
210
|
-
|
|
211
|
-
const localFiles = this.themeRoot.files();
|
|
212
|
-
const result: SyncResult = {
|
|
213
|
-
uploaded: 0,
|
|
214
|
-
deleted: 0,
|
|
215
|
-
downloaded: 0,
|
|
216
|
-
errors: [],
|
|
217
|
-
validationFailed: false,
|
|
218
|
-
};
|
|
219
|
-
|
|
220
|
-
// Schema validation pass
|
|
221
|
-
if (opts.validate) {
|
|
222
|
-
for (const file of localFiles) {
|
|
223
|
-
if (!file.isLiquid) continue;
|
|
224
|
-
const diagnostics = file.validateSchema();
|
|
225
|
-
const errors = diagnostics.filter((d) => d.severity === "error");
|
|
226
|
-
for (const d of errors) {
|
|
227
|
-
result.errors.push(`${file.relativePath}: ${d.message}`);
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
if (result.errors.length > 0) {
|
|
231
|
-
result.validationFailed = true;
|
|
232
|
-
return result;
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
const toUpload = localFiles.filter((f) => f.exists && this.hasChanged(f));
|
|
237
|
-
let done = 0;
|
|
238
|
-
for (const file of toUpload) {
|
|
239
|
-
try {
|
|
240
|
-
await this.uploadFile(file);
|
|
241
|
-
result.uploaded++;
|
|
242
|
-
} catch (e) {
|
|
243
|
-
result.errors.push(`Upload ${file.relativePath}: ${e}`);
|
|
244
|
-
}
|
|
245
|
-
opts.onProgress?.(++done, toUpload.length);
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
if (opts.delete) {
|
|
249
|
-
const localPaths = new Set(localFiles.map((f) => f.relativePath));
|
|
250
|
-
const toDelete = this.remoteKeys().filter((k) => !localPaths.has(k));
|
|
251
|
-
for (const key of toDelete) {
|
|
252
|
-
try {
|
|
253
|
-
await this.deleteRemoteFile(key);
|
|
254
|
-
result.deleted++;
|
|
255
|
-
} catch (e) {
|
|
256
|
-
result.errors.push(`Delete ${key}: ${e}`);
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
return result;
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
// ─── Full Download ────────────────────────────────────────────────────────
|
|
265
|
-
|
|
266
|
-
async downloadTheme(
|
|
267
|
-
opts: {
|
|
268
|
-
delete?: boolean;
|
|
269
|
-
skip?: Set<string>;
|
|
270
|
-
onProgress?: (done: number, total: number) => void;
|
|
271
|
-
} = {},
|
|
272
|
-
): Promise<SyncResult & { skipped: number }> {
|
|
273
|
-
const resources = await this.downloadAll();
|
|
274
|
-
const result: SyncResult & { skipped: number } = {
|
|
275
|
-
uploaded: 0,
|
|
276
|
-
deleted: 0,
|
|
277
|
-
downloaded: 0,
|
|
278
|
-
skipped: 0,
|
|
279
|
-
errors: [],
|
|
280
|
-
validationFailed: false,
|
|
281
|
-
};
|
|
282
|
-
|
|
283
|
-
let done = 0;
|
|
284
|
-
for (const resource of resources) {
|
|
285
|
-
if (opts.skip?.has(resource.key)) {
|
|
286
|
-
result.skipped++;
|
|
287
|
-
opts.onProgress?.(++done, resources.length);
|
|
288
|
-
continue;
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
const file = this.themeRoot.file(resource.key);
|
|
292
|
-
|
|
293
|
-
// Guard against path traversal from malicious API responses
|
|
294
|
-
if (!file.absolutePath.startsWith(this.themeRoot.root + sep)) {
|
|
295
|
-
result.errors.push(`Download ${resource.key}: path traversal detected`);
|
|
296
|
-
opts.onProgress?.(++done, resources.length);
|
|
297
|
-
continue;
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
try {
|
|
301
|
-
if (resource.resource_type === "FileResource" && resource.url) {
|
|
302
|
-
const buf = await this.downloadBinaryAsset(resource.url);
|
|
303
|
-
file.write(buf);
|
|
304
|
-
} else if (
|
|
305
|
-
resource.content !== undefined &&
|
|
306
|
-
resource.content !== null
|
|
307
|
-
) {
|
|
308
|
-
const content =
|
|
309
|
-
typeof resource.content === "string"
|
|
310
|
-
? resource.content
|
|
311
|
-
: JSON.stringify(resource.content);
|
|
312
|
-
file.write(content);
|
|
313
|
-
}
|
|
314
|
-
result.downloaded++;
|
|
315
|
-
} catch (e) {
|
|
316
|
-
result.errors.push(`Download ${resource.key}: ${e}`);
|
|
317
|
-
}
|
|
318
|
-
opts.onProgress?.(++done, resources.length);
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
if (opts.delete) {
|
|
322
|
-
const remoteKeys = new Set(resources.map((r) => r.key));
|
|
323
|
-
for (const file of this.themeRoot.files()) {
|
|
324
|
-
if (!remoteKeys.has(file.relativePath)) {
|
|
325
|
-
try {
|
|
326
|
-
const { unlinkSync } = await import("node:fs");
|
|
327
|
-
unlinkSync(file.absolutePath);
|
|
328
|
-
result.deleted++;
|
|
329
|
-
} catch {
|
|
330
|
-
// ignore
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
return result;
|
|
337
|
-
}
|
|
338
|
-
}
|
package/src/theme-config.ts
DELETED
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
|
|
4
|
-
export interface ThemeConfig {
|
|
5
|
-
themeId: number;
|
|
6
|
-
themeName: string;
|
|
7
|
-
company: string;
|
|
8
|
-
lastPulledAt: string | null;
|
|
9
|
-
checksums: Record<string, string>;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
const CONFIG_FILE = ".fluid-theme.json";
|
|
13
|
-
|
|
14
|
-
function configPath(themeRoot: string): string {
|
|
15
|
-
return join(themeRoot, CONFIG_FILE);
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/** Read `.fluid-theme.json` from a theme directory, or null if it doesn't exist. */
|
|
19
|
-
export function readThemeConfig(themeRoot: string): ThemeConfig | null {
|
|
20
|
-
const path = configPath(themeRoot);
|
|
21
|
-
if (!existsSync(path)) return null;
|
|
22
|
-
try {
|
|
23
|
-
const raw = readFileSync(path, "utf-8");
|
|
24
|
-
return JSON.parse(raw) as ThemeConfig;
|
|
25
|
-
} catch {
|
|
26
|
-
return null;
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/** Write `.fluid-theme.json` to a theme directory. */
|
|
31
|
-
export function writeThemeConfig(themeRoot: string, config: ThemeConfig): void {
|
|
32
|
-
const path = configPath(themeRoot);
|
|
33
|
-
writeFileSync(path, JSON.stringify(config, null, 2) + "\n", "utf-8");
|
|
34
|
-
}
|
package/src/theme-picker.ts
DELETED
|
@@ -1,164 +0,0 @@
|
|
|
1
|
-
import chalk from "chalk";
|
|
2
|
-
import prompts from "prompts";
|
|
3
|
-
import type { createApiClient } from "./api.js";
|
|
4
|
-
import { themes, type components } from "@fluid-app/themes-api-client";
|
|
5
|
-
|
|
6
|
-
export type ApplicationTheme = components["schemas"]["ApplicationTheme"];
|
|
7
|
-
|
|
8
|
-
const PAGE_SIZE = 50;
|
|
9
|
-
const LOAD_MORE_VALUE = -1;
|
|
10
|
-
|
|
11
|
-
function themeLabel(t: ApplicationTheme): string {
|
|
12
|
-
const active = t.status === "active" ? ` ${chalk.green("[active]")}` : "";
|
|
13
|
-
return `${t.name} (#${t.id})${active}`;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
function themeChoices(
|
|
17
|
-
themeList: ApplicationTheme[],
|
|
18
|
-
hasMore: boolean,
|
|
19
|
-
): prompts.Choice[] {
|
|
20
|
-
const choices: prompts.Choice[] = themeList.map((t) => ({
|
|
21
|
-
title: themeLabel(t),
|
|
22
|
-
value: t.id,
|
|
23
|
-
}));
|
|
24
|
-
if (hasMore) {
|
|
25
|
-
choices.push({
|
|
26
|
-
title: chalk.dim(`── Load more themes ──`),
|
|
27
|
-
value: LOAD_MORE_VALUE,
|
|
28
|
-
});
|
|
29
|
-
}
|
|
30
|
-
return choices;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
async function fetchThemesPage(
|
|
34
|
-
api: ReturnType<typeof createApiClient>,
|
|
35
|
-
page: number,
|
|
36
|
-
searchQuery?: string,
|
|
37
|
-
): Promise<{
|
|
38
|
-
themes: ApplicationTheme[];
|
|
39
|
-
hasMore: boolean;
|
|
40
|
-
}> {
|
|
41
|
-
const body = await themes.listApplicationThemes(api, {
|
|
42
|
-
per_page: PAGE_SIZE,
|
|
43
|
-
page,
|
|
44
|
-
...(searchQuery ? { search_query: searchQuery } : {}),
|
|
45
|
-
});
|
|
46
|
-
const list = body.application_themes ?? [];
|
|
47
|
-
const totalPages = body.meta?.total_pages ?? 1;
|
|
48
|
-
return { themes: list, hasMore: page < totalPages };
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export async function selectTheme(
|
|
52
|
-
api: ReturnType<typeof createApiClient>,
|
|
53
|
-
message: string,
|
|
54
|
-
): Promise<ApplicationTheme> {
|
|
55
|
-
const allThemes: ApplicationTheme[] = [];
|
|
56
|
-
let page = 1;
|
|
57
|
-
let hasMore = true;
|
|
58
|
-
let initialIndex = 0;
|
|
59
|
-
|
|
60
|
-
// Search cache — persists across suggest calls
|
|
61
|
-
let searchQuery = "";
|
|
62
|
-
let searchResults: ApplicationTheme[] = [];
|
|
63
|
-
|
|
64
|
-
while (true) {
|
|
65
|
-
if (hasMore && allThemes.length < page * PAGE_SIZE) {
|
|
66
|
-
const result = await fetchThemesPage(api, page);
|
|
67
|
-
allThemes.push(...result.themes);
|
|
68
|
-
hasMore = result.hasMore;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
if (!allThemes.length) {
|
|
72
|
-
console.error("No themes found.");
|
|
73
|
-
process.exit(1);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
const choices = themeChoices(allThemes, hasMore);
|
|
77
|
-
|
|
78
|
-
const { id } = await prompts(
|
|
79
|
-
{
|
|
80
|
-
type: "autocomplete",
|
|
81
|
-
name: "id",
|
|
82
|
-
message,
|
|
83
|
-
initial: initialIndex,
|
|
84
|
-
choices,
|
|
85
|
-
suggest: async (input: string, choices: prompts.Choice[]) => {
|
|
86
|
-
if (!input) {
|
|
87
|
-
searchQuery = "";
|
|
88
|
-
searchResults = [];
|
|
89
|
-
return choices;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
if (input !== searchQuery) {
|
|
93
|
-
searchQuery = input;
|
|
94
|
-
try {
|
|
95
|
-
const result = await fetchThemesPage(api, 1, input);
|
|
96
|
-
searchResults = result.themes;
|
|
97
|
-
} catch {
|
|
98
|
-
searchResults = [];
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
return searchResults.map((t) => ({
|
|
103
|
-
title: themeLabel(t),
|
|
104
|
-
value: t.id,
|
|
105
|
-
}));
|
|
106
|
-
},
|
|
107
|
-
},
|
|
108
|
-
{ onCancel: () => process.exit(130) },
|
|
109
|
-
);
|
|
110
|
-
|
|
111
|
-
if (id === LOAD_MORE_VALUE) {
|
|
112
|
-
initialIndex = allThemes.length;
|
|
113
|
-
page++;
|
|
114
|
-
continue;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
if (!id) {
|
|
118
|
-
console.error("No theme selected.");
|
|
119
|
-
process.exit(1);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// Check loaded themes first, then search results
|
|
123
|
-
const found =
|
|
124
|
-
allThemes.find((t) => t.id === id) ??
|
|
125
|
-
searchResults.find((t) => t.id === id);
|
|
126
|
-
if (found) return found;
|
|
127
|
-
|
|
128
|
-
// Fetch directly by ID as fallback
|
|
129
|
-
const body = await themes.getApplicationTheme(api, id);
|
|
130
|
-
return body.application_theme;
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
export async function findTheme(
|
|
135
|
-
api: ReturnType<typeof createApiClient>,
|
|
136
|
-
identifier: string,
|
|
137
|
-
): Promise<ApplicationTheme> {
|
|
138
|
-
// Try ID lookup first
|
|
139
|
-
const idNum = Number(identifier);
|
|
140
|
-
if (Number.isInteger(idNum) && idNum > 0) {
|
|
141
|
-
try {
|
|
142
|
-
const body = await themes.getApplicationTheme(api, idNum);
|
|
143
|
-
if (body.application_theme) return body.application_theme;
|
|
144
|
-
} catch {
|
|
145
|
-
// Not found by ID, fall through to search
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
// Search by name via API with pagination
|
|
150
|
-
let page = 1;
|
|
151
|
-
let hasMore = true;
|
|
152
|
-
while (hasMore) {
|
|
153
|
-
const result = await fetchThemesPage(api, page, identifier);
|
|
154
|
-
const found = result.themes.find(
|
|
155
|
-
(t) => t.name.toLowerCase() === identifier.toLowerCase(),
|
|
156
|
-
);
|
|
157
|
-
if (found) return found;
|
|
158
|
-
hasMore = result.hasMore;
|
|
159
|
-
page++;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
console.error(`No theme found with identifier: ${identifier}`);
|
|
163
|
-
process.exit(1);
|
|
164
|
-
}
|
package/src/workspace.ts
DELETED
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
-
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
3
|
-
|
|
4
|
-
export interface FluidWorkspace {
|
|
5
|
-
/** Absolute path to the workspace root (where .fluid-workspace.json lives) */
|
|
6
|
-
root: string;
|
|
7
|
-
/** Parsed workspace config */
|
|
8
|
-
config: WorkspaceConfig;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
interface WorkspaceConfig {
|
|
12
|
-
type: string;
|
|
13
|
-
version: number;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
const WORKSPACE_FILE = ".fluid-workspace.json";
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Walk up from `startDir` looking for `.fluid-workspace.json`.
|
|
20
|
-
* Returns the workspace info if found, or `null` if not in a workspace.
|
|
21
|
-
*/
|
|
22
|
-
export function findWorkspace(startDir?: string): FluidWorkspace | null {
|
|
23
|
-
let dir = resolve(startDir ?? process.cwd());
|
|
24
|
-
|
|
25
|
-
// eslint-disable-next-line no-constant-condition
|
|
26
|
-
while (true) {
|
|
27
|
-
const candidate = join(dir, WORKSPACE_FILE);
|
|
28
|
-
if (existsSync(candidate)) {
|
|
29
|
-
try {
|
|
30
|
-
const raw = readFileSync(candidate, "utf-8");
|
|
31
|
-
const config = JSON.parse(raw) as WorkspaceConfig;
|
|
32
|
-
return { root: dir, config };
|
|
33
|
-
} catch {
|
|
34
|
-
return null;
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
const parent = dirname(dir);
|
|
38
|
-
if (parent === dir) break; // reached filesystem root
|
|
39
|
-
dir = parent;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
return null;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* If cwd is already inside `{workspace}/local/{company}/...`, return that
|
|
47
|
-
* theme root directory. Otherwise return null.
|
|
48
|
-
*
|
|
49
|
-
* Examples (workspace root = /code/fluid-theme-dev):
|
|
50
|
-
* cwd = /code/fluid-theme-dev/local/acme-co → /code/fluid-theme-dev/local/acme-co
|
|
51
|
-
* cwd = /code/fluid-theme-dev/local/acme-co/templates → /code/fluid-theme-dev/local/acme-co
|
|
52
|
-
* cwd = /code/fluid-theme-dev → null
|
|
53
|
-
* cwd = /code/fluid-theme-dev/local → null
|
|
54
|
-
*/
|
|
55
|
-
export function resolveThemeRootFromCwd(
|
|
56
|
-
workspace: FluidWorkspace,
|
|
57
|
-
): string | null {
|
|
58
|
-
const cwd = resolve(process.cwd());
|
|
59
|
-
const localDir = join(workspace.root, "local");
|
|
60
|
-
const rel = relative(localDir, cwd);
|
|
61
|
-
|
|
62
|
-
// Not under local/ at all, or exactly at local/
|
|
63
|
-
if (rel.startsWith("..") || rel === ".") return null;
|
|
64
|
-
|
|
65
|
-
// rel is like "acme-co" or "acme-co/templates/subfolder"
|
|
66
|
-
// The theme root is the first segment: local/{company}
|
|
67
|
-
const firstSegment = rel.split(sep)[0];
|
|
68
|
-
if (!firstSegment) return null;
|
|
69
|
-
|
|
70
|
-
return join(localDir, firstSegment);
|
|
71
|
-
}
|
package/tsconfig.json
DELETED
package/tsdown.config.ts
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
import { defineConfig } from "tsdown";
|
|
2
|
-
|
|
3
|
-
export default defineConfig({
|
|
4
|
-
entry: { index: "src/index.ts" },
|
|
5
|
-
format: ["esm"],
|
|
6
|
-
dts: { eager: true },
|
|
7
|
-
clean: true,
|
|
8
|
-
target: "node24",
|
|
9
|
-
deps: {
|
|
10
|
-
neverBundle: [
|
|
11
|
-
"@fluid-app/fluid-cli",
|
|
12
|
-
"commander",
|
|
13
|
-
"chokidar",
|
|
14
|
-
"open",
|
|
15
|
-
"ora",
|
|
16
|
-
"prompts",
|
|
17
|
-
],
|
|
18
|
-
},
|
|
19
|
-
});
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|