@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/.turbo/turbo-build.log +6 -6
- package/dist/index.mjs +293 -13
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/commands/dev.ts +35 -12
- 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,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;
|
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 {
|
package/src/theme/syncer.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/workspace.ts
ADDED
|
@@ -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
|
+
}
|