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

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fluid-app/fluid-cli-theme-dev",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
4
4
  "description": "Fluid CLI plugin for theme developer workflows — dev server, push, pull, init",
5
5
  "type": "module",
6
6
  "main": "./dist/index.mjs",
@@ -23,15 +23,15 @@
23
23
  "open": "^10.0.0",
24
24
  "ora": "^8.0.0",
25
25
  "prompts": "^2.4.2",
26
- "@fluid-app/fluid-cli": "0.1.6"
26
+ "@fluid-app/fluid-cli": "0.1.7"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@types/node": "^24",
30
30
  "@types/prompts": "^2.4.9",
31
31
  "tsdown": "^0.21.0",
32
32
  "typescript": "^5",
33
- "@fluid-app/api-client-core": "0.1.0",
34
33
  "@fluid-app/typescript-config": "0.0.0",
34
+ "@fluid-app/api-client-core": "0.1.0",
35
35
  "@fluid-app/themes-api-client": "0.1.0"
36
36
  },
37
37
  "engines": {
@@ -1,10 +1,12 @@
1
1
  import { Command } from "commander";
2
2
  import { requireToken, createApiClient } from "../api.js";
3
+ import { readThemeConfig } from "../theme-config.js";
3
4
  import { getPluginState, setPluginState } from "../plugin-state.js";
4
5
  import { ThemeRoot } from "../theme/root.js";
5
6
  import { startDevServer } from "../theme/dev-server/index.js";
6
7
  import { themes } from "@fluid-app/themes-api-client";
7
8
  import { findTheme, type ApplicationTheme } from "../theme-picker.js";
9
+ import { findWorkspace, resolveThemeRootFromCwd } from "../workspace.js";
8
10
 
9
11
  interface CompanyMe {
10
12
  data: { company: { subdomain?: string; name?: string } };
@@ -75,9 +77,18 @@ export function createDevCommand(): Command {
75
77
  }) => {
76
78
  requireToken();
77
79
 
78
- const themeRoot = new ThemeRoot(opts.root);
80
+ // If no explicit --root and we're inside a workspace, resolve to the theme root
81
+ let rootPath = opts.root;
82
+ if (rootPath === ".") {
83
+ const workspace = findWorkspace();
84
+ if (workspace) {
85
+ rootPath = resolveThemeRootFromCwd(workspace) ?? rootPath;
86
+ }
87
+ }
88
+
89
+ const themeRoot = new ThemeRoot(rootPath);
79
90
  if (!themeRoot.isValid()) {
80
- console.error(`'${opts.root}' does not look like a theme directory.`);
91
+ console.error(`'${rootPath}' does not look like a theme directory.`);
81
92
  process.exit(1);
82
93
  }
83
94
 
@@ -91,19 +102,31 @@ export function createDevCommand(): Command {
91
102
 
92
103
  const reloadMode = opts.liveReload === "off" ? "off" : "full-page";
93
104
  const api = createApiClient();
94
-
95
- const companyRes = await api.get<CompanyMe>(
96
- "/api/company/v1/companies/me",
97
- );
98
- const company = companyRes.data?.company?.subdomain;
99
- if (!company) {
100
- console.error(
101
- "Could not determine company subdomain. Make sure your token is valid.",
105
+ const config = readThemeConfig(themeRoot.root);
106
+
107
+ // Use company from .fluid-theme.json if available, otherwise fetch
108
+ let company: string;
109
+ if (config?.company) {
110
+ company = config.company;
111
+ } else {
112
+ const companyRes = await api.get<CompanyMe>(
113
+ "/api/company/v1/companies/me",
102
114
  );
103
- process.exit(1);
115
+ company = companyRes.data?.company?.subdomain ?? "";
116
+ if (!company) {
117
+ console.error(
118
+ "Could not determine company subdomain. Make sure your token is valid.",
119
+ );
120
+ process.exit(1);
121
+ }
104
122
  }
105
123
 
106
- const theme = await ensureDevTheme(api, opts.theme);
124
+ // Use theme from .fluid-theme.json if available and no --theme flag
125
+ const theme = opts.theme
126
+ ? await ensureDevTheme(api, opts.theme)
127
+ : config
128
+ ? await ensureDevTheme(api, String(config.themeId))
129
+ : await ensureDevTheme(api);
107
130
  const editorUrl = `https://admin.fluid.app/themes/${theme.id}/editor`;
108
131
 
109
132
  let stop: (() => void) | undefined;
@@ -1,43 +1,241 @@
1
+ import { join, resolve } from "node:path";
2
+ import chalk from "chalk";
1
3
  import { Command } from "commander";
2
4
  import ora from "ora";
5
+ import prompts from "prompts";
3
6
  import { requireToken, createApiClient } from "../api.js";
7
+ import { readThemeConfig, writeThemeConfig } from "../theme-config.js";
4
8
  import { ThemeRoot } from "../theme/root.js";
5
9
  import { Syncer } from "../theme/syncer.js";
6
10
  import { selectTheme, findTheme } from "../theme-picker.js";
11
+ import { findWorkspace, resolveThemeRootFromCwd } from "../workspace.js";
12
+
13
+ interface CompanyMe {
14
+ data: { company: { subdomain?: string; name?: string } };
15
+ }
16
+
17
+ async function fetchCompanySubdomain(
18
+ api: ReturnType<typeof createApiClient>,
19
+ ): Promise<string> {
20
+ const res = await api.get<CompanyMe>("/api/company/v1/companies/me");
21
+ const subdomain = res.data?.company?.subdomain;
22
+ if (!subdomain) {
23
+ console.error(
24
+ "Could not determine company subdomain. Make sure your token is valid.",
25
+ );
26
+ process.exit(1);
27
+ }
28
+ return subdomain;
29
+ }
30
+
31
+ function formatRelativeTime(iso: string): string {
32
+ const diff = Date.now() - new Date(iso).getTime();
33
+ const minutes = Math.floor(diff / 60_000);
34
+ if (minutes < 1) return "just now";
35
+ if (minutes < 60) return `${minutes}m ago`;
36
+ const hours = Math.floor(minutes / 60);
37
+ if (hours < 24) return `${hours}h ago`;
38
+ const days = Math.floor(hours / 24);
39
+ if (days === 1) return "yesterday";
40
+ const date = new Date(iso);
41
+ return `${days}d ago (${date.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })})`;
42
+ }
43
+
44
+ /**
45
+ * Detect files where both local and remote have changed since the last pull.
46
+ * Returns the set of conflicting resource keys.
47
+ */
48
+ function detectConflicts(
49
+ storedChecksums: Record<string, string>,
50
+ remoteChecksums: Record<string, string>,
51
+ themeRoot: ThemeRoot,
52
+ ): string[] {
53
+ const conflicts: string[] = [];
54
+ for (const [key, storedChecksum] of Object.entries(storedChecksums)) {
55
+ const remoteChecksum = remoteChecksums[key];
56
+ if (remoteChecksum === undefined) continue; // deleted on remote, not a conflict
57
+ if (remoteChecksum === storedChecksum) continue; // remote unchanged
58
+
59
+ // Remote changed — check if local also changed
60
+ const file = themeRoot.file(key);
61
+ if (!file.exists) continue; // local deleted, not a conflict (remote wins)
62
+ const localChecksum = file.checksum();
63
+ if (localChecksum === storedChecksum) continue; // local unchanged, safe to overwrite
64
+ if (localChecksum === remoteChecksum) continue; // both sides made same change
65
+
66
+ conflicts.push(key);
67
+ }
68
+ return conflicts;
69
+ }
7
70
 
8
71
  export function createPullCommand(): Command {
9
72
  return new Command("pull")
10
73
  .description("Pull a remote theme to your local directory")
11
74
  .option("-t, --theme <name-or-id>", "Theme name or ID to pull")
12
75
  .option("-n, --nodelete", "Do not delete local files missing on remote")
13
- .option("--root <path>", "Theme root directory", ".")
76
+ .option("--root <path>", "Theme root directory")
77
+ .option("-y, --yes", "Skip confirmation prompt")
14
78
  .action(
15
- async (opts: { theme?: string; nodelete?: boolean; root: string }) => {
79
+ async (opts: {
80
+ theme?: string;
81
+ nodelete?: boolean;
82
+ root?: string;
83
+ yes?: boolean;
84
+ }) => {
16
85
  requireToken();
17
86
 
18
87
  const api = createApiClient();
88
+ const workspace = findWorkspace();
89
+
19
90
  const theme = opts.theme
20
91
  ? await findTheme(api, opts.theme)
21
92
  : await selectTheme(api, "Select a theme to pull");
22
- const themeRoot = new ThemeRoot(opts.root);
93
+
94
+ // Resolve output directory
95
+ const subdomain = await fetchCompanySubdomain(api);
96
+ let root: string;
97
+ if (opts.root) {
98
+ root = opts.root;
99
+ } else if (workspace) {
100
+ // If already inside local/{company}/, use that directory
101
+ root =
102
+ resolveThemeRootFromCwd(workspace) ??
103
+ join(workspace.root, "local", subdomain);
104
+ } else {
105
+ root = `.`;
106
+ }
107
+
108
+ const absoluteRoot = resolve(root);
109
+ const existingConfig = readThemeConfig(absoluteRoot);
110
+
111
+ // Pre-flight summary
112
+ console.log();
113
+ console.log(` Theme: ${chalk.bold(theme.name)} (#${theme.id})`);
114
+ console.log(` Company: ${chalk.bold(subdomain)}`);
115
+ console.log(` Target: ${chalk.bold(absoluteRoot)}`);
116
+ if (existingConfig?.lastPulledAt) {
117
+ console.log(
118
+ ` Last pulled: ${formatRelativeTime(existingConfig.lastPulledAt)}`,
119
+ );
120
+ }
121
+ console.log();
122
+
123
+ // Conflict detection — only possible if we have a previous pull's checksums
124
+ const themeRoot = new ThemeRoot(root);
125
+ let skipKeys: Set<string> | undefined;
126
+
127
+ if (existingConfig?.checksums) {
128
+ const fetchSpinner = ora("Checking for conflicts…").start();
129
+ const syncer = new Syncer(api, theme.id, themeRoot);
130
+ await syncer.fetchChecksums();
131
+ const remoteChecksums = syncer.remoteChecksums();
132
+ const conflicts = detectConflicts(
133
+ existingConfig.checksums,
134
+ remoteChecksums,
135
+ themeRoot,
136
+ );
137
+ fetchSpinner.stop();
138
+
139
+ if (conflicts.length > 0) {
140
+ console.log(
141
+ chalk.yellow(`⚠ ${conflicts.length} conflict(s) detected:\n`),
142
+ );
143
+ for (const key of conflicts) {
144
+ console.log(` ${key}`);
145
+ }
146
+ console.log();
147
+
148
+ const { resolution } = await prompts(
149
+ {
150
+ type: "select",
151
+ name: "resolution",
152
+ message: "How do you want to handle conflicts?",
153
+ choices: [
154
+ {
155
+ title: "Keep local (skip conflicting files)",
156
+ value: "keep-local",
157
+ },
158
+ {
159
+ title: "Use remote (overwrite local changes)",
160
+ value: "use-remote",
161
+ },
162
+ { title: "Abort", value: "abort" },
163
+ ],
164
+ },
165
+ { onCancel: () => process.exit(130) },
166
+ );
167
+
168
+ if (resolution === "abort") {
169
+ console.log("Aborted.");
170
+ process.exit(0);
171
+ }
172
+
173
+ if (resolution === "keep-local") {
174
+ skipKeys = new Set(conflicts);
175
+ }
176
+ // "use-remote" → skipKeys stays undefined, everything gets overwritten
177
+ }
178
+ }
179
+
180
+ if (!opts.yes && !skipKeys) {
181
+ const { confirmed } = await prompts(
182
+ {
183
+ type: "confirm",
184
+ name: "confirmed",
185
+ message: "Pull theme to this directory?",
186
+ initial: true,
187
+ },
188
+ { onCancel: () => process.exit(130) },
189
+ );
190
+ if (!confirmed) {
191
+ console.log("Aborted.");
192
+ process.exit(0);
193
+ }
194
+ }
23
195
 
24
196
  const syncer = new Syncer(api, theme.id, themeRoot);
25
197
  const spinner = ora(`Pulling ${theme.name} (#${theme.id})…`).start();
26
198
 
27
199
  const result = await syncer.downloadTheme({
28
200
  delete: !opts.nodelete,
201
+ skip: skipKeys,
29
202
  onProgress: (d, total) => {
30
203
  spinner.text = `Downloading ${d}/${total} files…`;
31
204
  },
32
205
  });
33
206
 
207
+ // Write .fluid-theme.json with the post-pull state.
208
+ // For skipped files, preserve the old stored checksum so the conflict
209
+ // is still detected on the next pull (instead of silently overwriting).
210
+ const newChecksums = syncer.remoteChecksums();
211
+ if (skipKeys && existingConfig?.checksums) {
212
+ for (const key of skipKeys) {
213
+ const oldChecksum = existingConfig.checksums[key];
214
+ if (oldChecksum) {
215
+ newChecksums[key] = oldChecksum;
216
+ }
217
+ }
218
+ }
219
+
220
+ writeThemeConfig(absoluteRoot, {
221
+ themeId: theme.id,
222
+ themeName: theme.name,
223
+ company: subdomain,
224
+ lastPulledAt: new Date().toISOString(),
225
+ checksums: newChecksums,
226
+ });
227
+
228
+ const parts: string[] = [`Downloaded ${result.downloaded} file(s)`];
229
+ if (result.deleted > 0)
230
+ parts.push(`deleted ${result.deleted} local file(s)`);
231
+ if (result.skipped > 0)
232
+ parts.push(`skipped ${result.skipped} conflict(s)`);
233
+
34
234
  if (result.errors.length) {
35
235
  spinner.warn(`Pulled with ${result.errors.length} error(s).`);
36
236
  for (const e of result.errors) console.error(` ${e}`);
37
237
  } else {
38
- spinner.succeed(
39
- `Downloaded ${result.downloaded} file(s), deleted ${result.deleted} local file(s).`,
40
- );
238
+ spinner.succeed(`${parts.join(", ")}.`);
41
239
  }
42
240
  },
43
241
  );
@@ -1,7 +1,9 @@
1
+ import chalk from "chalk";
1
2
  import { Command } from "commander";
2
3
  import ora from "ora";
3
4
  import prompts from "prompts";
4
5
  import { requireToken, createApiClient } from "../api.js";
6
+ import { readThemeConfig, writeThemeConfig } from "../theme-config.js";
5
7
  import { ThemeRoot } from "../theme/root.js";
6
8
  import { Syncer } from "../theme/syncer.js";
7
9
  import { themes } from "@fluid-app/themes-api-client";
@@ -10,6 +12,33 @@ import {
10
12
  findTheme,
11
13
  type ApplicationTheme,
12
14
  } from "../theme-picker.js";
15
+ import { findWorkspace, resolveThemeRootFromCwd } from "../workspace.js";
16
+
17
+ /**
18
+ * Detect files where the remote has changed since the last pull,
19
+ * and we also have local changes (i.e. we'd overwrite someone else's work).
20
+ */
21
+ function detectRemoteDrift(
22
+ storedChecksums: Record<string, string>,
23
+ remoteChecksums: Record<string, string>,
24
+ themeRoot: ThemeRoot,
25
+ ): string[] {
26
+ const conflicts: string[] = [];
27
+ for (const [key, storedChecksum] of Object.entries(storedChecksums)) {
28
+ const remoteChecksum = remoteChecksums[key];
29
+ if (remoteChecksum === undefined) continue;
30
+ if (remoteChecksum === storedChecksum) continue; // remote unchanged since pull
31
+
32
+ // Remote changed — check if we also have this file locally (and it differs)
33
+ const file = themeRoot.file(key);
34
+ if (!file.exists) continue;
35
+ const localChecksum = file.checksum();
36
+ if (localChecksum === remoteChecksum) continue; // local matches remote already
37
+
38
+ conflicts.push(key);
39
+ }
40
+ return conflicts;
41
+ }
13
42
 
14
43
  export function createPushCommand(): Command {
15
44
  return new Command("push")
@@ -34,13 +63,23 @@ export function createPushCommand(): Command {
34
63
  }) => {
35
64
  requireToken();
36
65
 
37
- const themeRoot = new ThemeRoot(opts.root);
66
+ // If no explicit --root and we're inside a workspace, resolve to the theme root
67
+ let rootPath = opts.root;
68
+ if (rootPath === ".") {
69
+ const workspace = findWorkspace();
70
+ if (workspace) {
71
+ rootPath = resolveThemeRootFromCwd(workspace) ?? rootPath;
72
+ }
73
+ }
74
+
75
+ const themeRoot = new ThemeRoot(rootPath);
38
76
  if (!themeRoot.isValid()) {
39
- console.error(`'${opts.root}' does not look like a theme directory.`);
77
+ console.error(`'${rootPath}' does not look like a theme directory.`);
40
78
  process.exit(1);
41
79
  }
42
80
 
43
81
  const api = createApiClient();
82
+ const config = readThemeConfig(themeRoot.root);
44
83
  let theme: ApplicationTheme;
45
84
 
46
85
  if (opts.unpublished) {
@@ -63,10 +102,74 @@ export function createPushCommand(): Command {
63
102
  console.log(
64
103
  `Created unpublished theme: ${theme.name} (#${theme.id})`,
65
104
  );
105
+ } else if (opts.theme) {
106
+ theme = await findTheme(api, opts.theme);
107
+ } else if (config) {
108
+ // Use .fluid-theme.json as the default
109
+ console.log(
110
+ ` Using theme from .fluid-theme.json: ${chalk.bold(config.themeName)} (#${config.themeId})`,
111
+ );
112
+ const body = await themes.getApplicationTheme(api, config.themeId);
113
+ theme = body.application_theme;
66
114
  } else {
67
- theme = opts.theme
68
- ? await findTheme(api, opts.theme)
69
- : await selectTheme(api, "Select a theme to push to");
115
+ theme = await selectTheme(api, "Select a theme to push to");
116
+ }
117
+
118
+ // Check for remote drift if we have stored checksums
119
+ if (config?.checksums && !opts.force) {
120
+ const driftSpinner = ora("Checking for remote changes…").start();
121
+ const driftSyncer = new Syncer(api, theme.id, themeRoot);
122
+ await driftSyncer.fetchChecksums();
123
+ const remoteChecksums = driftSyncer.remoteChecksums();
124
+ const conflicts = detectRemoteDrift(
125
+ config.checksums,
126
+ remoteChecksums,
127
+ themeRoot,
128
+ );
129
+ driftSpinner.stop();
130
+
131
+ if (conflicts.length > 0) {
132
+ console.log(
133
+ chalk.yellow(
134
+ `\n⚠ ${conflicts.length} file(s) changed on remote since last pull:\n`,
135
+ ),
136
+ );
137
+ for (const key of conflicts) {
138
+ console.log(` ${key}`);
139
+ }
140
+ console.log();
141
+
142
+ const { resolution } = await prompts(
143
+ {
144
+ type: "select",
145
+ name: "resolution",
146
+ message: "How do you want to handle this?",
147
+ choices: [
148
+ {
149
+ title: "Push anyway (overwrite remote changes)",
150
+ value: "push",
151
+ },
152
+ {
153
+ title: "Pull first, then push",
154
+ value: "pull-first",
155
+ },
156
+ { title: "Abort", value: "abort" },
157
+ ],
158
+ },
159
+ { onCancel: () => process.exit(130) },
160
+ );
161
+
162
+ if (resolution === "abort") {
163
+ console.log("Aborted.");
164
+ process.exit(0);
165
+ }
166
+ if (resolution === "pull-first") {
167
+ console.log(
168
+ `Run ${chalk.cyan("fluid theme pull")} first, then push again.`,
169
+ );
170
+ process.exit(0);
171
+ }
172
+ }
70
173
  }
71
174
 
72
175
  const syncer = new Syncer(api, theme.id, themeRoot);
@@ -88,6 +191,14 @@ export function createPushCommand(): Command {
88
191
  );
89
192
  }
90
193
 
194
+ // Update stored checksums after successful push
195
+ if (config) {
196
+ writeThemeConfig(themeRoot.root, {
197
+ ...config,
198
+ checksums: syncer.remoteChecksums(),
199
+ });
200
+ }
201
+
91
202
  if (opts.publish) {
92
203
  const pubSpinner = ora("Publishing theme…").start();
93
204
  try {
@@ -46,6 +46,11 @@ export class Syncer {
46
46
  return [...this.checksums.keys()];
47
47
  }
48
48
 
49
+ /** Snapshot of remote checksums (key → sha256). Available after fetchChecksums() or downloadAll(). */
50
+ remoteChecksums(): Record<string, string> {
51
+ return Object.fromEntries(this.checksums);
52
+ }
53
+
49
54
  // ─── Upload ───────────────────────────────────────────────────────────────
50
55
 
51
56
  async uploadFile(file: ThemeFile): Promise<void> {
@@ -242,19 +247,27 @@ export class Syncer {
242
247
  async downloadTheme(
243
248
  opts: {
244
249
  delete?: boolean;
250
+ skip?: Set<string>;
245
251
  onProgress?: (done: number, total: number) => void;
246
252
  } = {},
247
- ): Promise<SyncResult> {
253
+ ): Promise<SyncResult & { skipped: number }> {
248
254
  const resources = await this.downloadAll();
249
- const result: SyncResult = {
255
+ const result: SyncResult & { skipped: number } = {
250
256
  uploaded: 0,
251
257
  deleted: 0,
252
258
  downloaded: 0,
259
+ skipped: 0,
253
260
  errors: [],
254
261
  };
255
262
 
256
263
  let done = 0;
257
264
  for (const resource of resources) {
265
+ if (opts.skip?.has(resource.key)) {
266
+ result.skipped++;
267
+ opts.onProgress?.(++done, resources.length);
268
+ continue;
269
+ }
270
+
258
271
  const file = this.themeRoot.file(resource.key);
259
272
 
260
273
  // 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",