@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.
- package/.turbo/turbo-build.log +6 -8
- package/dist/index.mjs +340 -18
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -2
- package/src/commands/dev.ts +36 -13
- package/src/commands/pull.ts +204 -6
- package/src/commands/push.ts +124 -6
- package/src/theme/dev-server/index.ts +22 -2
- package/src/theme/file.ts +28 -0
- package/src/theme/syncer.ts +35 -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.12",
|
|
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,7 +23,8 @@
|
|
|
23
23
|
"open": "^10.0.0",
|
|
24
24
|
"ora": "^8.0.0",
|
|
25
25
|
"prompts": "^2.4.2",
|
|
26
|
-
"@fluid-app/
|
|
26
|
+
"@fluid-app/theme-schema": "0.1.0",
|
|
27
|
+
"@fluid-app/fluid-cli": "0.1.7"
|
|
27
28
|
},
|
|
28
29
|
"devDependencies": {
|
|
29
30
|
"@types/node": "^24",
|
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,31 @@ 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);
|
|
107
130
|
const editorUrl = `https://admin.fluid.app/themes/${theme.id}/editor`;
|
|
108
131
|
|
|
109
132
|
let stop: (() => void) | undefined;
|
|
@@ -124,7 +147,7 @@ export function createDevCommand(): Command {
|
|
|
124
147
|
editorUrl,
|
|
125
148
|
},
|
|
126
149
|
themeRoot,
|
|
127
|
-
{ host: opts.host, port, reloadMode },
|
|
150
|
+
{ host: opts.host, port, reloadMode, validate: !opts.force },
|
|
128
151
|
(address) => {
|
|
129
152
|
console.log(`\n Dev server: ${address}`);
|
|
130
153
|
console.log(` Web editor: ${editorUrl}`);
|
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);
|
|
@@ -74,12 +177,19 @@ export function createPushCommand(): Command {
|
|
|
74
177
|
|
|
75
178
|
const result = await syncer.uploadTheme({
|
|
76
179
|
delete: !opts.nodelete,
|
|
180
|
+
validate: !opts.force,
|
|
77
181
|
onProgress: (d, total) => {
|
|
78
182
|
spinner.text = `Pushing ${d}/${total} files…`;
|
|
79
183
|
},
|
|
80
184
|
});
|
|
81
185
|
|
|
82
|
-
if (result.
|
|
186
|
+
if (result.validationFailed) {
|
|
187
|
+
spinner.fail(
|
|
188
|
+
`Schema validation failed (${result.errors.length} error(s)). Use --force to skip.`,
|
|
189
|
+
);
|
|
190
|
+
for (const e of result.errors) console.error(` ${e}`);
|
|
191
|
+
process.exit(1);
|
|
192
|
+
} else if (result.errors.length) {
|
|
83
193
|
spinner.warn(`Pushed with ${result.errors.length} error(s).`);
|
|
84
194
|
for (const e of result.errors) console.error(` ${e}`);
|
|
85
195
|
} else {
|
|
@@ -88,6 +198,14 @@ export function createPushCommand(): Command {
|
|
|
88
198
|
);
|
|
89
199
|
}
|
|
90
200
|
|
|
201
|
+
// Update stored checksums after successful push
|
|
202
|
+
if (config) {
|
|
203
|
+
writeThemeConfig(themeRoot.root, {
|
|
204
|
+
...config,
|
|
205
|
+
checksums: syncer.remoteChecksums(),
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
91
209
|
if (opts.publish) {
|
|
92
210
|
const pubSpinner = ora("Publishing theme…").start();
|
|
93
211
|
try {
|
|
@@ -23,7 +23,7 @@ export async function startDevServer(
|
|
|
23
23
|
api: ApiClient,
|
|
24
24
|
theme: DevServerTheme,
|
|
25
25
|
themeRoot: ThemeRoot,
|
|
26
|
-
opts: DevServerOptions,
|
|
26
|
+
opts: DevServerOptions & { validate?: boolean },
|
|
27
27
|
onReady?: (address: string) => void,
|
|
28
28
|
): Promise<() => void> {
|
|
29
29
|
const sse = new SSEStream();
|
|
@@ -33,13 +33,23 @@ export async function startDevServer(
|
|
|
33
33
|
|
|
34
34
|
// ── Initial sync ─────────────────────────────────────────────────────────
|
|
35
35
|
console.log(`\nSyncing theme ${theme.name} (#${theme.id})…`);
|
|
36
|
-
await syncer.uploadTheme({
|
|
36
|
+
const syncResult = await syncer.uploadTheme({
|
|
37
37
|
delete: true,
|
|
38
|
+
validate: opts.validate,
|
|
38
39
|
onProgress: (done, total) => {
|
|
39
40
|
process.stdout.write(`\r Uploading ${done}/${total} files…`);
|
|
40
41
|
},
|
|
41
42
|
});
|
|
42
43
|
process.stdout.write("\n");
|
|
44
|
+
if (syncResult.validationFailed) {
|
|
45
|
+
console.error(
|
|
46
|
+
`\nSchema validation failed (${syncResult.errors.length} error(s)). Use --force to skip.\n`,
|
|
47
|
+
);
|
|
48
|
+
for (const e of syncResult.errors) console.error(` ${e}`);
|
|
49
|
+
process.exit(1);
|
|
50
|
+
} else if (syncResult.errors.length > 0) {
|
|
51
|
+
for (const e of syncResult.errors) console.error(` ${e}`);
|
|
52
|
+
}
|
|
43
53
|
|
|
44
54
|
// ── File watcher ─────────────────────────────────────────────────────────
|
|
45
55
|
const stopWatcher = watchTheme(
|
|
@@ -48,6 +58,16 @@ export async function startDevServer(
|
|
|
48
58
|
const changed = [...modified, ...added];
|
|
49
59
|
|
|
50
60
|
for (const file of changed) {
|
|
61
|
+
// Validate schema on liquid files during dev (warn, don't block)
|
|
62
|
+
if (opts.validate && file.isLiquid) {
|
|
63
|
+
const diagnostics = file.validateSchema();
|
|
64
|
+
for (const d of diagnostics) {
|
|
65
|
+
const prefix =
|
|
66
|
+
d.severity === "error" ? "Schema error" : "Schema warning";
|
|
67
|
+
console.warn(`\n[${prefix}] ${file.relativePath}: ${d.message}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
51
71
|
pendingUpdates.add(file.relativePath);
|
|
52
72
|
try {
|
|
53
73
|
await syncer.uploadFile(file);
|
package/src/theme/file.ts
CHANGED
|
@@ -8,6 +8,11 @@ import {
|
|
|
8
8
|
import { extname, basename, relative, dirname } from "node:path";
|
|
9
9
|
import { createHash } from "node:crypto";
|
|
10
10
|
import { mimeTypeFor, type MimeType } from "./mime-type.js";
|
|
11
|
+
import {
|
|
12
|
+
validateSchemaText,
|
|
13
|
+
type Diagnostic,
|
|
14
|
+
type BlocksSchemaType,
|
|
15
|
+
} from "@fluid-app/theme-schema";
|
|
11
16
|
|
|
12
17
|
export class ThemeFile {
|
|
13
18
|
readonly absolutePath: string;
|
|
@@ -65,4 +70,27 @@ export class ThemeFile {
|
|
|
65
70
|
size(): number {
|
|
66
71
|
return statSync(this.absolutePath).size;
|
|
67
72
|
}
|
|
73
|
+
|
|
74
|
+
get isTemplate(): boolean {
|
|
75
|
+
// Template files (home_page, product, etc.) expect blocks as objects.
|
|
76
|
+
// Section files expect blocks as arrays.
|
|
77
|
+
const parts = this.relativePath.split(/[/\\]/);
|
|
78
|
+
return (
|
|
79
|
+
parts[0] === "templates" &&
|
|
80
|
+
parts.length >= 3 &&
|
|
81
|
+
parts[1] !== "sections" &&
|
|
82
|
+
parts[1] !== "blocks" &&
|
|
83
|
+
parts[1] !== "components"
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
validateSchema(): Diagnostic[] {
|
|
88
|
+
if (!this.isLiquid) return [];
|
|
89
|
+
|
|
90
|
+
const blocksSchemaType: BlocksSchemaType = this.isTemplate
|
|
91
|
+
? "object"
|
|
92
|
+
: "array";
|
|
93
|
+
|
|
94
|
+
return validateSchemaText(this.read(), { blocksSchemaType });
|
|
95
|
+
}
|
|
68
96
|
}
|