@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/.turbo/turbo-build.log +8 -6
- package/dist/index.mjs +360 -27
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/commands/dev.ts +38 -15
- package/src/commands/navigate.ts +104 -8
- package/src/commands/pull.ts +204 -6
- package/src/commands/push.ts +116 -5
- package/src/theme/syncer.ts +15 -2
- package/src/theme-config.ts +34 -0
- package/src/workspace.ts +71 -0
- package/tsdown.config.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fluid-app/fluid-cli-theme-dev",
|
|
3
|
-
"version": "0.1.
|
|
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.
|
|
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": {
|
package/src/commands/dev.ts
CHANGED
|
@@ -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
|
-
|
|
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(`'${
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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) {
|
package/src/commands/navigate.ts
CHANGED
|
@@ -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: "
|
|
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
|
-
|
|
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: "
|
|
230
|
+
type: "autocomplete",
|
|
147
231
|
name: "slug",
|
|
148
232
|
message: `Select a ${dest.label.toLowerCase()}`,
|
|
149
|
-
choices:
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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);
|
package/src/commands/pull.ts
CHANGED
|
@@ -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: {
|
|
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
|
-
|
|
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
|
);
|
package/src/commands/push.ts
CHANGED
|
@@ -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
|
-
|
|
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(`'${
|
|
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 =
|
|
68
|
-
|
|
69
|
-
|
|
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 {
|