@fluid-app/fluid-cli-theme-dev 0.1.10 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,6 +11,7 @@ export interface SyncResult {
11
11
  downloaded: number;
12
12
  deleted: number;
13
13
  errors: string[];
14
+ validationFailed: boolean;
14
15
  }
15
16
 
16
17
  export class Syncer {
@@ -46,6 +47,11 @@ export class Syncer {
46
47
  return [...this.checksums.keys()];
47
48
  }
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
+
49
55
  // ─── Upload ───────────────────────────────────────────────────────────────
50
56
 
51
57
  async uploadFile(file: ThemeFile): Promise<void> {
@@ -196,6 +202,7 @@ export class Syncer {
196
202
  async uploadTheme(
197
203
  opts: {
198
204
  delete?: boolean;
205
+ validate?: boolean;
199
206
  onProgress?: (done: number, total: number) => void;
200
207
  } = {},
201
208
  ): Promise<SyncResult> {
@@ -207,8 +214,25 @@ export class Syncer {
207
214
  deleted: 0,
208
215
  downloaded: 0,
209
216
  errors: [],
217
+ validationFailed: false,
210
218
  };
211
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
+
212
236
  const toUpload = localFiles.filter((f) => f.exists && this.hasChanged(f));
213
237
  let done = 0;
214
238
  for (const file of toUpload) {
@@ -242,19 +266,28 @@ export class Syncer {
242
266
  async downloadTheme(
243
267
  opts: {
244
268
  delete?: boolean;
269
+ skip?: Set<string>;
245
270
  onProgress?: (done: number, total: number) => void;
246
271
  } = {},
247
- ): Promise<SyncResult> {
272
+ ): Promise<SyncResult & { skipped: number }> {
248
273
  const resources = await this.downloadAll();
249
- const result: SyncResult = {
274
+ const result: SyncResult & { skipped: number } = {
250
275
  uploaded: 0,
251
276
  deleted: 0,
252
277
  downloaded: 0,
278
+ skipped: 0,
253
279
  errors: [],
280
+ validationFailed: false,
254
281
  };
255
282
 
256
283
  let done = 0;
257
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
+
258
291
  const file = this.themeRoot.file(resource.key);
259
292
 
260
293
  // Guard against path traversal from malicious API responses
@@ -0,0 +1,34 @@
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
+ }
@@ -0,0 +1,71 @@
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/tsdown.config.ts CHANGED
@@ -5,7 +5,7 @@ export default defineConfig({
5
5
  format: ["esm"],
6
6
  dts: { eager: true },
7
7
  clean: true,
8
- target: "node18",
8
+ target: "node24",
9
9
  deps: {
10
10
  neverBundle: [
11
11
  "@fluid-app/fluid-cli",