@fluid-app/fluid-cli-theme-dev 0.1.0
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 +18 -0
- package/dist/index.d.mts +7 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +1240 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +43 -0
- package/src/api.ts +25 -0
- package/src/commands/dev.ts +150 -0
- package/src/commands/init.ts +51 -0
- package/src/commands/navigate.ts +159 -0
- package/src/commands/pull.ts +90 -0
- package/src/commands/push.ts +121 -0
- package/src/commands/theme.ts +21 -0
- package/src/index.ts +12 -0
- package/src/plugin-state.ts +26 -0
- package/src/theme/dev-server/hot-reload.ts +65 -0
- package/src/theme/dev-server/index.ts +125 -0
- package/src/theme/dev-server/proxy.ts +125 -0
- package/src/theme/dev-server/sse.ts +43 -0
- package/src/theme/dev-server/watcher.ts +54 -0
- package/src/theme/file.ts +68 -0
- package/src/theme/fluid-ignore.ts +64 -0
- package/src/theme/mime-type.ts +45 -0
- package/src/theme/root.ts +51 -0
- package/src/theme/syncer.ts +310 -0
- package/tsconfig.json +10 -0
- package/tsdown.config.ts +19 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import {
|
|
2
|
+
readFileSync,
|
|
3
|
+
writeFileSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
existsSync,
|
|
6
|
+
statSync,
|
|
7
|
+
} from "node:fs";
|
|
8
|
+
import { extname, basename, relative, dirname } from "node:path";
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import { mimeTypeFor, type MimeType } from "./mime-type.js";
|
|
11
|
+
|
|
12
|
+
export class ThemeFile {
|
|
13
|
+
readonly absolutePath: string;
|
|
14
|
+
readonly relativePath: string;
|
|
15
|
+
readonly mime: MimeType;
|
|
16
|
+
|
|
17
|
+
constructor(absolutePath: string, root: string) {
|
|
18
|
+
this.absolutePath = absolutePath;
|
|
19
|
+
this.relativePath = relative(root, absolutePath);
|
|
20
|
+
this.mime = mimeTypeFor(extname(absolutePath).toLowerCase());
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
get name(): string {
|
|
24
|
+
return basename(this.absolutePath);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
get isText(): boolean {
|
|
28
|
+
return this.mime.isText;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
get isLiquid(): boolean {
|
|
32
|
+
return this.absolutePath.endsWith(".liquid");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
get isJson(): boolean {
|
|
36
|
+
return this.absolutePath.endsWith(".json");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
get exists(): boolean {
|
|
40
|
+
return existsSync(this.absolutePath);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
read(): string {
|
|
44
|
+
return readFileSync(this.absolutePath, "utf-8");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
readBinary(): Buffer {
|
|
48
|
+
return readFileSync(this.absolutePath);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
write(content: string | Buffer): void {
|
|
52
|
+
mkdirSync(dirname(this.absolutePath), { recursive: true });
|
|
53
|
+
if (typeof content === "string") {
|
|
54
|
+
writeFileSync(this.absolutePath, content, "utf-8");
|
|
55
|
+
} else {
|
|
56
|
+
writeFileSync(this.absolutePath, content);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
checksum(): string {
|
|
61
|
+
const content = this.isText ? this.read() : this.readBinary();
|
|
62
|
+
return createHash("sha256").update(content).digest("hex");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
size(): number {
|
|
66
|
+
return statSync(this.absolutePath).size;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
2
|
+
import { join, basename } from "node:path";
|
|
3
|
+
|
|
4
|
+
const IGNORE_FILE = ".fluidignore";
|
|
5
|
+
|
|
6
|
+
interface Pattern {
|
|
7
|
+
negated: boolean;
|
|
8
|
+
pattern: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class FluidIgnore {
|
|
12
|
+
private patterns: Pattern[];
|
|
13
|
+
|
|
14
|
+
constructor(root: string) {
|
|
15
|
+
this.patterns = this.parse(join(root, IGNORE_FILE));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
ignore(relativePath: string): boolean {
|
|
19
|
+
let result = false;
|
|
20
|
+
for (const { negated, pattern } of this.patterns) {
|
|
21
|
+
if (this.match(pattern, relativePath)) {
|
|
22
|
+
result = !negated;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return result;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
private parse(filePath: string): Pattern[] {
|
|
29
|
+
if (!existsSync(filePath)) return [];
|
|
30
|
+
return readFileSync(filePath, "utf-8")
|
|
31
|
+
.split("\n")
|
|
32
|
+
.map((l) => l.trim())
|
|
33
|
+
.filter((l) => l && !l.startsWith("#"))
|
|
34
|
+
.map((l) => {
|
|
35
|
+
const negated = l.startsWith("!");
|
|
36
|
+
let pattern = negated ? l.slice(1) : l;
|
|
37
|
+
if (pattern.startsWith("/")) pattern = pattern.slice(1);
|
|
38
|
+
return { negated, pattern };
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private match(pattern: string, path: string): boolean {
|
|
43
|
+
if (pattern.endsWith("/")) {
|
|
44
|
+
return path.startsWith(pattern) || path === pattern.slice(0, -1);
|
|
45
|
+
}
|
|
46
|
+
if (pattern.includes("/")) {
|
|
47
|
+
return this.fnmatch(pattern, path);
|
|
48
|
+
}
|
|
49
|
+
return this.fnmatch(pattern, path) || this.fnmatch(pattern, basename(path));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private fnmatch(pattern: string, str: string): boolean {
|
|
53
|
+
const re = pattern
|
|
54
|
+
.split("**")
|
|
55
|
+
.map((p) =>
|
|
56
|
+
p
|
|
57
|
+
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
58
|
+
.replace(/\*/g, "[^/]*")
|
|
59
|
+
.replace(/\?/g, "[^/]"),
|
|
60
|
+
)
|
|
61
|
+
.join(".*");
|
|
62
|
+
return new RegExp(`^${re}$`).test(str);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
const TEXT_TYPES: Record<string, string> = {
|
|
2
|
+
".liquid": "text/x-liquid",
|
|
3
|
+
".json": "application/json",
|
|
4
|
+
".css": "text/css",
|
|
5
|
+
".js": "application/javascript",
|
|
6
|
+
".html": "text/html",
|
|
7
|
+
".txt": "text/plain",
|
|
8
|
+
".md": "text/markdown",
|
|
9
|
+
".svg": "image/svg+xml",
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const BINARY_TYPES: Record<string, string> = {
|
|
13
|
+
".png": "image/png",
|
|
14
|
+
".jpg": "image/jpeg",
|
|
15
|
+
".jpeg": "image/jpeg",
|
|
16
|
+
".gif": "image/gif",
|
|
17
|
+
".webp": "image/webp",
|
|
18
|
+
".ico": "image/x-icon",
|
|
19
|
+
".woff": "font/woff",
|
|
20
|
+
".woff2": "font/woff2",
|
|
21
|
+
".ttf": "font/ttf",
|
|
22
|
+
".eot": "application/vnd.ms-fontobject",
|
|
23
|
+
".otf": "font/otf",
|
|
24
|
+
".pdf": "application/pdf",
|
|
25
|
+
".zip": "application/zip",
|
|
26
|
+
".mp4": "video/mp4",
|
|
27
|
+
".webm": "video/webm",
|
|
28
|
+
".mp3": "audio/mpeg",
|
|
29
|
+
".wav": "audio/wav",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export interface MimeType {
|
|
33
|
+
name: string;
|
|
34
|
+
isText: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function mimeTypeFor(ext: string): MimeType {
|
|
38
|
+
const text = TEXT_TYPES[ext];
|
|
39
|
+
if (text) return { name: text, isText: true };
|
|
40
|
+
|
|
41
|
+
const binary = BINARY_TYPES[ext];
|
|
42
|
+
if (binary) return { name: binary, isText: false };
|
|
43
|
+
|
|
44
|
+
return { name: "application/octet-stream", isText: false };
|
|
45
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { ThemeFile } from "./file.js";
|
|
4
|
+
import { FluidIgnore } from "./fluid-ignore.js";
|
|
5
|
+
|
|
6
|
+
const THEME_MARKERS = ["templates", "assets", "config"];
|
|
7
|
+
|
|
8
|
+
export class ThemeRoot {
|
|
9
|
+
readonly root: string;
|
|
10
|
+
readonly ignore: FluidIgnore;
|
|
11
|
+
|
|
12
|
+
constructor(root: string) {
|
|
13
|
+
this.root = resolve(root);
|
|
14
|
+
this.ignore = new FluidIgnore(this.root);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
isValid(): boolean {
|
|
18
|
+
return THEME_MARKERS.some((m) => {
|
|
19
|
+
try {
|
|
20
|
+
return statSync(join(this.root, m)).isDirectory();
|
|
21
|
+
} catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
files(): ThemeFile[] {
|
|
28
|
+
return this.glob(this.root).filter(
|
|
29
|
+
(f) => !this.ignore.ignore(f.relativePath),
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
file(pathOrFile: string | ThemeFile): ThemeFile {
|
|
34
|
+
if (pathOrFile instanceof ThemeFile) return pathOrFile;
|
|
35
|
+
return new ThemeFile(join(this.root, pathOrFile), this.root);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
private glob(dir: string): ThemeFile[] {
|
|
39
|
+
const results: ThemeFile[] = [];
|
|
40
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
41
|
+
if (entry.name.startsWith(".")) continue;
|
|
42
|
+
const full = join(dir, entry.name);
|
|
43
|
+
if (entry.isDirectory()) {
|
|
44
|
+
results.push(...this.glob(full));
|
|
45
|
+
} else if (entry.isFile()) {
|
|
46
|
+
results.push(new ThemeFile(full, this.root));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return results;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import { sep } from "node:path";
|
|
2
|
+
import type { ApiClient } from "../api.js";
|
|
3
|
+
import type { ThemeFile } from "./file.js";
|
|
4
|
+
import type { ThemeRoot } from "./root.js";
|
|
5
|
+
|
|
6
|
+
export interface RemoteResource {
|
|
7
|
+
key: string;
|
|
8
|
+
checksum: string;
|
|
9
|
+
content?: string;
|
|
10
|
+
url?: string;
|
|
11
|
+
resource_type: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface SyncResult {
|
|
15
|
+
uploaded: number;
|
|
16
|
+
downloaded: number;
|
|
17
|
+
deleted: number;
|
|
18
|
+
errors: string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class Syncer {
|
|
22
|
+
private checksums = new Map<string, string>();
|
|
23
|
+
|
|
24
|
+
constructor(
|
|
25
|
+
private api: ApiClient,
|
|
26
|
+
private themeId: number,
|
|
27
|
+
private themeRoot: ThemeRoot,
|
|
28
|
+
) {}
|
|
29
|
+
|
|
30
|
+
// ─── Checksum Management ──────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
async fetchChecksums(): Promise<void> {
|
|
33
|
+
const body = await this.api.get<{
|
|
34
|
+
application_theme_resources: RemoteResource[];
|
|
35
|
+
}>(`/api/application_themes/${this.themeId}/resources`);
|
|
36
|
+
this.updateChecksums(body.application_theme_resources ?? []);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private updateChecksums(resources: RemoteResource[]): void {
|
|
40
|
+
for (const r of resources) {
|
|
41
|
+
if (r.key) this.checksums.set(r.key, r.checksum);
|
|
42
|
+
}
|
|
43
|
+
for (const key of this.checksums.keys()) {
|
|
44
|
+
if (this.checksums.has(`${key}.liquid`)) this.checksums.delete(key);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
hasChanged(file: ThemeFile): boolean {
|
|
49
|
+
return file.checksum() !== this.checksums.get(file.relativePath);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
remoteKeys(): string[] {
|
|
53
|
+
return [...this.checksums.keys()];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ─── Upload ───────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
async uploadFile(file: ThemeFile): Promise<void> {
|
|
59
|
+
const path = `/api/application_themes/${this.themeId}/resources`;
|
|
60
|
+
if (file.isText) {
|
|
61
|
+
await this.api.put(path, {
|
|
62
|
+
application_theme_resource: {
|
|
63
|
+
key: file.relativePath,
|
|
64
|
+
content: file.read(),
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
} else {
|
|
68
|
+
await this.uploadBinaryFile(file, path);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private async uploadBinaryFile(
|
|
73
|
+
file: ThemeFile,
|
|
74
|
+
resourcePath: string,
|
|
75
|
+
): Promise<void> {
|
|
76
|
+
// Step 1: Create DAM placeholder
|
|
77
|
+
const placeholderBody = await this.api.post<{
|
|
78
|
+
asset: { id: number; canonical_path: string };
|
|
79
|
+
}>("/api/dam/assets", {
|
|
80
|
+
placeholder_asset: {
|
|
81
|
+
description: `Uploaded via Fluid CLI: ${file.name}`,
|
|
82
|
+
mime_type: file.mime.name,
|
|
83
|
+
name: file.name,
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
const asset = placeholderBody.asset;
|
|
87
|
+
|
|
88
|
+
// Step 2: Get ImageKit auth token
|
|
89
|
+
const authBody = await this.api.post<{
|
|
90
|
+
token: string;
|
|
91
|
+
signature: string;
|
|
92
|
+
expire: number;
|
|
93
|
+
}>("/api/dam/assets/imagekit_auth", {});
|
|
94
|
+
|
|
95
|
+
// Step 3: Upload to ImageKit via multipart
|
|
96
|
+
const folder = this.canonicalPathToImageKitFolder(asset.canonical_path);
|
|
97
|
+
const formData = new FormData();
|
|
98
|
+
const blob = new Blob([file.readBinary() as unknown as ArrayBuffer], {
|
|
99
|
+
type: file.mime.name,
|
|
100
|
+
});
|
|
101
|
+
formData.append("file", blob, file.name);
|
|
102
|
+
formData.append("token", authBody.token);
|
|
103
|
+
formData.append("signature", authBody.signature);
|
|
104
|
+
formData.append("expire", String(authBody.expire));
|
|
105
|
+
formData.append("folder", folder);
|
|
106
|
+
formData.append("fileName", file.name);
|
|
107
|
+
formData.append("publicKey", "public_j7s4Ih9ETh/OCp41mVQH7tlXBdU=");
|
|
108
|
+
|
|
109
|
+
const ikResp = await fetch(
|
|
110
|
+
"https://upload.imagekit.io/api/v1/files/upload",
|
|
111
|
+
{
|
|
112
|
+
method: "POST",
|
|
113
|
+
body: formData,
|
|
114
|
+
},
|
|
115
|
+
);
|
|
116
|
+
if (!ikResp.ok) throw new Error(`ImageKit upload failed: ${ikResp.status}`);
|
|
117
|
+
const ikBody = (await ikResp.json()) as {
|
|
118
|
+
fileId: string;
|
|
119
|
+
url: string;
|
|
120
|
+
thumbnailUrl: string;
|
|
121
|
+
size: number;
|
|
122
|
+
height?: number;
|
|
123
|
+
width?: number;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
// Step 4: Backfill DAM asset
|
|
127
|
+
const backfillPayload: Record<string, unknown> = {
|
|
128
|
+
asset: {
|
|
129
|
+
id: asset.id,
|
|
130
|
+
imagekit_file_id: ikBody.fileId,
|
|
131
|
+
imagekit_url: ikBody.url,
|
|
132
|
+
mime_type: file.mime.name,
|
|
133
|
+
name: file.name,
|
|
134
|
+
file_size: ikBody.size,
|
|
135
|
+
expected_path: asset.canonical_path,
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
if (ikBody.height)
|
|
139
|
+
(backfillPayload["asset"] as Record<string, unknown>)["height"] =
|
|
140
|
+
ikBody.height;
|
|
141
|
+
if (ikBody.width)
|
|
142
|
+
(backfillPayload["asset"] as Record<string, unknown>)["width"] =
|
|
143
|
+
ikBody.width;
|
|
144
|
+
|
|
145
|
+
const backfillBody = await this.api.post<{
|
|
146
|
+
asset: { code: string; default_variant_url: string };
|
|
147
|
+
}>("/api/dam/assets/backfill_imagekit", backfillPayload);
|
|
148
|
+
|
|
149
|
+
// Step 5: Associate with theme resource
|
|
150
|
+
await this.api.put(resourcePath, {
|
|
151
|
+
application_theme_resource: {
|
|
152
|
+
key: file.relativePath,
|
|
153
|
+
dam_asset: {
|
|
154
|
+
dam_asset_code: backfillBody.asset.code,
|
|
155
|
+
content_type: file.mime.name,
|
|
156
|
+
content_size: ikBody.size,
|
|
157
|
+
filename: file.name,
|
|
158
|
+
handle: backfillBody.asset.code,
|
|
159
|
+
url: backfillBody.asset.default_variant_url,
|
|
160
|
+
preview_image_url: ikBody.thumbnailUrl,
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private canonicalPathToImageKitFolder(canonicalPath: string): string {
|
|
167
|
+
const parts = canonicalPath.split(".");
|
|
168
|
+
const companyId = parts[0] ?? "unknown";
|
|
169
|
+
const category = parts[1] ?? "files";
|
|
170
|
+
const assetCode = parts[2] ?? "unknown";
|
|
171
|
+
const folderMap: Record<string, string> = {
|
|
172
|
+
images: "images",
|
|
173
|
+
videos: "videos",
|
|
174
|
+
audio: "audio",
|
|
175
|
+
documents: "documents",
|
|
176
|
+
files: "files",
|
|
177
|
+
};
|
|
178
|
+
return `${companyId}/${folderMap[category] ?? "files"}/${assetCode}`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ─── Delete ───────────────────────────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
async deleteRemoteFile(relativePath: string): Promise<void> {
|
|
184
|
+
await this.api.delete(`/api/application_themes/${this.themeId}/resources`, {
|
|
185
|
+
body: { application_theme_resource: { key: relativePath } },
|
|
186
|
+
});
|
|
187
|
+
this.checksums.delete(relativePath);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ─── Download ─────────────────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
async downloadAll(): Promise<RemoteResource[]> {
|
|
193
|
+
const body = await this.api.get<{
|
|
194
|
+
application_theme_resources: RemoteResource[];
|
|
195
|
+
}>(`/api/application_themes/${this.themeId}/resources`);
|
|
196
|
+
this.updateChecksums(body.application_theme_resources ?? []);
|
|
197
|
+
return body.application_theme_resources ?? [];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async downloadBinaryAsset(url: string): Promise<Buffer> {
|
|
201
|
+
const resp = await fetch(url);
|
|
202
|
+
if (!resp.ok) throw new Error(`Failed to download asset: ${resp.status}`);
|
|
203
|
+
return Buffer.from(await resp.arrayBuffer());
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ─── Full Upload ──────────────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
async uploadTheme(
|
|
209
|
+
opts: {
|
|
210
|
+
delete?: boolean;
|
|
211
|
+
onProgress?: (done: number, total: number) => void;
|
|
212
|
+
} = {},
|
|
213
|
+
): Promise<SyncResult> {
|
|
214
|
+
await this.fetchChecksums();
|
|
215
|
+
|
|
216
|
+
const localFiles = this.themeRoot.files();
|
|
217
|
+
const result: SyncResult = {
|
|
218
|
+
uploaded: 0,
|
|
219
|
+
deleted: 0,
|
|
220
|
+
downloaded: 0,
|
|
221
|
+
errors: [],
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const toUpload = localFiles.filter((f) => f.exists && this.hasChanged(f));
|
|
225
|
+
let done = 0;
|
|
226
|
+
for (const file of toUpload) {
|
|
227
|
+
try {
|
|
228
|
+
await this.uploadFile(file);
|
|
229
|
+
result.uploaded++;
|
|
230
|
+
} catch (e) {
|
|
231
|
+
result.errors.push(`Upload ${file.relativePath}: ${e}`);
|
|
232
|
+
}
|
|
233
|
+
opts.onProgress?.(++done, toUpload.length);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (opts.delete) {
|
|
237
|
+
const localPaths = new Set(localFiles.map((f) => f.relativePath));
|
|
238
|
+
const toDelete = this.remoteKeys().filter((k) => !localPaths.has(k));
|
|
239
|
+
for (const key of toDelete) {
|
|
240
|
+
try {
|
|
241
|
+
await this.deleteRemoteFile(key);
|
|
242
|
+
result.deleted++;
|
|
243
|
+
} catch (e) {
|
|
244
|
+
result.errors.push(`Delete ${key}: ${e}`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return result;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ─── Full Download ────────────────────────────────────────────────────────
|
|
253
|
+
|
|
254
|
+
async downloadTheme(
|
|
255
|
+
opts: {
|
|
256
|
+
delete?: boolean;
|
|
257
|
+
onProgress?: (done: number, total: number) => void;
|
|
258
|
+
} = {},
|
|
259
|
+
): Promise<SyncResult> {
|
|
260
|
+
const resources = await this.downloadAll();
|
|
261
|
+
const result: SyncResult = {
|
|
262
|
+
uploaded: 0,
|
|
263
|
+
deleted: 0,
|
|
264
|
+
downloaded: 0,
|
|
265
|
+
errors: [],
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
let done = 0;
|
|
269
|
+
for (const resource of resources) {
|
|
270
|
+
const file = this.themeRoot.file(resource.key);
|
|
271
|
+
|
|
272
|
+
// Guard against path traversal from malicious API responses
|
|
273
|
+
if (!file.absolutePath.startsWith(this.themeRoot.root + sep)) {
|
|
274
|
+
result.errors.push(`Download ${resource.key}: path traversal detected`);
|
|
275
|
+
opts.onProgress?.(++done, resources.length);
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
try {
|
|
280
|
+
if (resource.resource_type === "FileResource" && resource.url) {
|
|
281
|
+
const buf = await this.downloadBinaryAsset(resource.url);
|
|
282
|
+
file.write(buf);
|
|
283
|
+
} else if (resource.content !== undefined) {
|
|
284
|
+
file.write(resource.content);
|
|
285
|
+
}
|
|
286
|
+
result.downloaded++;
|
|
287
|
+
} catch (e) {
|
|
288
|
+
result.errors.push(`Download ${resource.key}: ${e}`);
|
|
289
|
+
}
|
|
290
|
+
opts.onProgress?.(++done, resources.length);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (opts.delete) {
|
|
294
|
+
const remoteKeys = new Set(resources.map((r) => r.key));
|
|
295
|
+
for (const file of this.themeRoot.files()) {
|
|
296
|
+
if (!remoteKeys.has(file.relativePath)) {
|
|
297
|
+
try {
|
|
298
|
+
const { unlinkSync } = await import("node:fs");
|
|
299
|
+
unlinkSync(file.absolutePath);
|
|
300
|
+
result.deleted++;
|
|
301
|
+
} catch {
|
|
302
|
+
// ignore
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return result;
|
|
309
|
+
}
|
|
310
|
+
}
|
package/tsconfig.json
ADDED
package/tsdown.config.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { defineConfig } from "tsdown";
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
entry: { index: "src/index.ts" },
|
|
5
|
+
format: ["esm"],
|
|
6
|
+
dts: { eager: true },
|
|
7
|
+
clean: true,
|
|
8
|
+
target: "node18",
|
|
9
|
+
deps: {
|
|
10
|
+
neverBundle: [
|
|
11
|
+
"@fluid-app/fluid-cli",
|
|
12
|
+
"commander",
|
|
13
|
+
"chokidar",
|
|
14
|
+
"open",
|
|
15
|
+
"ora",
|
|
16
|
+
"prompts",
|
|
17
|
+
],
|
|
18
|
+
},
|
|
19
|
+
});
|