@fluid-app/fluid-cli-theme-dev 0.1.9 → 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.9",
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.5"
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,32 @@ 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);
130
+ const editorUrl = `https://admin.fluid.app/themes/${theme.id}/editor`;
107
131
 
108
132
  let stop: (() => void) | undefined;
109
133
 
@@ -120,14 +144,13 @@ export function createDevCommand(): Command {
120
144
  id: theme.id,
121
145
  name: theme.name,
122
146
  company,
123
- editorUrl: theme.editor_url ?? undefined,
147
+ editorUrl,
124
148
  },
125
149
  themeRoot,
126
150
  { host: opts.host, port, reloadMode },
127
151
  (address) => {
128
152
  console.log(`\n Dev server: ${address}`);
129
- if (theme.editor_url)
130
- console.log(` Web editor: ${theme.editor_url}`);
153
+ console.log(` Web editor: ${editorUrl}`);
131
154
  console.log("\n Watching for file changes…\n");
132
155
 
133
156
  if (opts.navigate) {
@@ -4,6 +4,36 @@ import { requireToken, createApiClient } from "../api.js";
4
4
  import { getPluginState } from "../plugin-state.js";
5
5
  import { themes } from "@fluid-app/themes-api-client";
6
6
 
7
+ function localSuggest(
8
+ input: string,
9
+ choices: prompts.Choice[],
10
+ ): prompts.Choice[] {
11
+ if (!input) return choices;
12
+ const lower = input.toLowerCase();
13
+ return choices.filter((c) => c.title.toLowerCase().includes(lower));
14
+ }
15
+
16
+ interface ThemeTemplate {
17
+ id: number;
18
+ name: string;
19
+ themeable_type: string;
20
+ default: boolean;
21
+ }
22
+
23
+ interface TemplatesResponse {
24
+ templates: ThemeTemplate[];
25
+ }
26
+
27
+ const THEMEABLE_TYPE_MAP: Record<string, string> = {
28
+ "/home": "home_page",
29
+ "/home/shop": "shop_page",
30
+ "/home/join": "join_page",
31
+ "/cart": "cart_page",
32
+ "/home/blog": "post_page",
33
+ "/home/categories": "category_page",
34
+ "/home/collections": "collection_page",
35
+ };
36
+
7
37
  const STATIC_ROUTES = [
8
38
  { label: "Home", path: "/home" },
9
39
  { label: "Shop", path: "/home/shop" },
@@ -65,6 +95,50 @@ const RESOURCE_ROUTES = [
65
95
  },
66
96
  ] as const;
67
97
 
98
+ async function fetchTemplatesForType(
99
+ api: ReturnType<typeof createApiClient>,
100
+ themeId: number,
101
+ themeableType: string,
102
+ ): Promise<ThemeTemplate[]> {
103
+ const params = new URLSearchParams({
104
+ application_theme_id: String(themeId),
105
+ themeable_type: themeableType,
106
+ published: "true",
107
+ });
108
+ const body = await api.get<TemplatesResponse>(
109
+ `/api/application_theme_templates?${params}`,
110
+ );
111
+ return body.templates ?? [];
112
+ }
113
+
114
+ async function selectTemplate(
115
+ api: ReturnType<typeof createApiClient>,
116
+ themeId: number,
117
+ themeableType: string,
118
+ onCancel: () => void,
119
+ ): Promise<number | null> {
120
+ const templates = await fetchTemplatesForType(api, themeId, themeableType);
121
+ if (templates.length <= 1) return null;
122
+
123
+ const templateChoices = templates.map((t) => ({
124
+ title: `${t.name}${t.default ? " (default)" : ""}`,
125
+ value: t.id,
126
+ }));
127
+ const { templateId } = await prompts(
128
+ {
129
+ type: "autocomplete",
130
+ name: "templateId",
131
+ message: "Select a template",
132
+ choices: templateChoices,
133
+ suggest: (input: string, choices: prompts.Choice[]) =>
134
+ Promise.resolve(localSuggest(input, choices)),
135
+ },
136
+ { onCancel },
137
+ );
138
+
139
+ return templateId ?? null;
140
+ }
141
+
68
142
  export function createNavigateCommand(): Command {
69
143
  return new Command("navigate")
70
144
  .description("Interactively navigate to a route in the dev server browser")
@@ -115,21 +189,27 @@ export function createNavigateCommand(): Command {
115
189
 
116
190
  const { dest } = await prompts(
117
191
  {
118
- type: "select",
192
+ type: "autocomplete",
119
193
  name: "dest",
120
194
  message: "Select a route",
121
195
  choices,
196
+ suggest: (input: string, choices: prompts.Choice[]) =>
197
+ Promise.resolve(localSuggest(input, choices)),
122
198
  },
123
199
  { onCancel },
124
200
  );
125
201
 
126
202
  if (!dest) return;
127
203
 
204
+ const api = createApiClient();
128
205
  let path: string;
206
+ let themeableType: string | undefined;
207
+
129
208
  if (typeof dest === "string") {
130
209
  path = dest;
210
+ themeableType = THEMEABLE_TYPE_MAP[dest];
131
211
  } else {
132
- const api = createApiClient();
212
+ themeableType = dest.resourceType;
133
213
  const body = await themes.getApplicationThemeAvailableThemeables(
134
214
  api,
135
215
  themeId,
@@ -141,15 +221,18 @@ export function createNavigateCommand(): Command {
141
221
  console.log(`No ${dest.label} resources found, using listing page.`);
142
222
  path = dest.fallback;
143
223
  } else {
224
+ const resourceChoices = resources.map((r) => ({
225
+ title: r.title ?? r.slug ?? "Untitled",
226
+ value: r.slug,
227
+ }));
144
228
  const { slug } = await prompts(
145
229
  {
146
- type: "select",
230
+ type: "autocomplete",
147
231
  name: "slug",
148
232
  message: `Select a ${dest.label.toLowerCase()}`,
149
- choices: resources.map((r) => ({
150
- title: r.title ?? r.slug ?? "Untitled",
151
- value: r.slug,
152
- })),
233
+ choices: resourceChoices,
234
+ suggest: (input: string, choices: prompts.Choice[]) =>
235
+ Promise.resolve(localSuggest(input, choices)),
153
236
  },
154
237
  { onCancel },
155
238
  );
@@ -157,7 +240,20 @@ export function createNavigateCommand(): Command {
157
240
  }
158
241
  }
159
242
 
160
- const url = `${address}${path}`;
243
+ let templateParam = "";
244
+ if (themeableType) {
245
+ const templateId = await selectTemplate(
246
+ api,
247
+ themeId,
248
+ themeableType,
249
+ onCancel,
250
+ );
251
+ if (templateId) {
252
+ templateParam = `?theme_template_id=${templateId}`;
253
+ }
254
+ }
255
+
256
+ const url = `${address}${path}${templateParam}`;
161
257
  console.log(`\nNavigating to: ${url}\n`);
162
258
  const open = (await import("open")).default;
163
259
  await open(url);
@@ -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 {