@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/.turbo/turbo-build.log
CHANGED
|
@@ -1,18 +1,16 @@
|
|
|
1
1
|
|
|
2
|
-
> @fluid-app/fluid-cli-theme-dev@0.1.
|
|
2
|
+
> @fluid-app/fluid-cli-theme-dev@0.1.12 build /home/runner/_work/fluid-mono/fluid-mono/packages/cli/theme-dev
|
|
3
3
|
> tsdown
|
|
4
4
|
|
|
5
5
|
[34mℹ[39m tsdown [2mv0.21.0[22m powered by rolldown [2mv1.0.0-rc.7[22m
|
|
6
6
|
[34mℹ[39m config file: [4m/home/runner/_work/fluid-mono/fluid-mono/packages/cli/theme-dev/tsdown.config.ts[24m
|
|
7
7
|
[34mℹ[39m entry: [34msrc/index.ts[39m
|
|
8
|
-
[34mℹ[39m target: [
|
|
8
|
+
[34mℹ[39m target: [34mnode24[39m
|
|
9
9
|
[34mℹ[39m tsconfig: [34mtsconfig.json[39m
|
|
10
10
|
[34mℹ[39m Build start
|
|
11
|
-
[34mℹ[39m [2mdist/[22m[1mindex.mjs[22m [2m
|
|
12
|
-
[34mℹ[39m [2mdist/[22mindex.mjs.map [
|
|
11
|
+
[34mℹ[39m [2mdist/[22m[1mindex.mjs[22m [2m 56.57 kB[22m [2m│ gzip: 15.51 kB[22m
|
|
12
|
+
[34mℹ[39m [2mdist/[22mindex.mjs.map [2m144.07 kB[22m [2m│ gzip: 32.49 kB[22m
|
|
13
13
|
[34mℹ[39m [2mdist/[22mindex.d.mts.map [2m 0.11 kB[22m [2m│ gzip: 0.12 kB[22m
|
|
14
14
|
[34mℹ[39m [2mdist/[22m[32m[1mindex.d.mts[22m[39m [2m 0.19 kB[22m [2m│ gzip: 0.16 kB[22m
|
|
15
|
-
[34mℹ[39m 4 files, total:
|
|
16
|
-
[
|
|
17
|
-
|
|
18
|
-
[32m✔[39m Build complete in [32m4607ms[39m
|
|
15
|
+
[34mℹ[39m 4 files, total: 200.94 kB
|
|
16
|
+
[32m✔[39m Build complete in [32m1210ms[39m
|
package/dist/index.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import { getAuthToken, readConfig, updateConfig } from "@fluid-app/fluid-cli";
|
|
|
3
3
|
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
5
|
import { createHash } from "node:crypto";
|
|
6
|
+
import { validateSchemaText } from "@fluid-app/theme-schema";
|
|
6
7
|
import http from "node:http";
|
|
7
8
|
import https from "node:https";
|
|
8
9
|
import chokidar from "chokidar";
|
|
@@ -209,6 +210,27 @@ function requireToken() {
|
|
|
209
210
|
return token;
|
|
210
211
|
}
|
|
211
212
|
//#endregion
|
|
213
|
+
//#region src/theme-config.ts
|
|
214
|
+
const CONFIG_FILE = ".fluid-theme.json";
|
|
215
|
+
function configPath(themeRoot) {
|
|
216
|
+
return join(themeRoot, CONFIG_FILE);
|
|
217
|
+
}
|
|
218
|
+
/** Read `.fluid-theme.json` from a theme directory, or null if it doesn't exist. */
|
|
219
|
+
function readThemeConfig(themeRoot) {
|
|
220
|
+
const path = configPath(themeRoot);
|
|
221
|
+
if (!existsSync(path)) return null;
|
|
222
|
+
try {
|
|
223
|
+
const raw = readFileSync(path, "utf-8");
|
|
224
|
+
return JSON.parse(raw);
|
|
225
|
+
} catch {
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/** Write `.fluid-theme.json` to a theme directory. */
|
|
230
|
+
function writeThemeConfig(themeRoot, config) {
|
|
231
|
+
writeFileSync(configPath(themeRoot), JSON.stringify(config, null, 2) + "\n", "utf-8");
|
|
232
|
+
}
|
|
233
|
+
//#endregion
|
|
212
234
|
//#region src/plugin-state.ts
|
|
213
235
|
const PLUGIN_KEY = "theme-dev";
|
|
214
236
|
function getPluginState() {
|
|
@@ -317,6 +339,15 @@ var ThemeFile = class {
|
|
|
317
339
|
size() {
|
|
318
340
|
return statSync(this.absolutePath).size;
|
|
319
341
|
}
|
|
342
|
+
get isTemplate() {
|
|
343
|
+
const parts = this.relativePath.split(/[/\\]/);
|
|
344
|
+
return parts[0] === "templates" && parts.length >= 3 && parts[1] !== "sections" && parts[1] !== "blocks" && parts[1] !== "components";
|
|
345
|
+
}
|
|
346
|
+
validateSchema() {
|
|
347
|
+
if (!this.isLiquid) return [];
|
|
348
|
+
const blocksSchemaType = this.isTemplate ? "object" : "array";
|
|
349
|
+
return validateSchemaText(this.read(), { blocksSchemaType });
|
|
350
|
+
}
|
|
320
351
|
};
|
|
321
352
|
//#endregion
|
|
322
353
|
//#region src/theme/fluid-ignore.ts
|
|
@@ -722,6 +753,10 @@ var Syncer = class {
|
|
|
722
753
|
remoteKeys() {
|
|
723
754
|
return [...this.checksums.keys()];
|
|
724
755
|
}
|
|
756
|
+
/** Snapshot of remote checksums (key → sha256). Available after fetchChecksums() or downloadAll(). */
|
|
757
|
+
remoteChecksums() {
|
|
758
|
+
return Object.fromEntries(this.checksums);
|
|
759
|
+
}
|
|
725
760
|
async uploadFile(file) {
|
|
726
761
|
if (file.isText) await updateThemeResource(this.api, this.themeId, { application_theme_resource: {
|
|
727
762
|
key: file.relativePath,
|
|
@@ -811,8 +846,20 @@ var Syncer = class {
|
|
|
811
846
|
uploaded: 0,
|
|
812
847
|
deleted: 0,
|
|
813
848
|
downloaded: 0,
|
|
814
|
-
errors: []
|
|
849
|
+
errors: [],
|
|
850
|
+
validationFailed: false
|
|
815
851
|
};
|
|
852
|
+
if (opts.validate) {
|
|
853
|
+
for (const file of localFiles) {
|
|
854
|
+
if (!file.isLiquid) continue;
|
|
855
|
+
const errors = file.validateSchema().filter((d) => d.severity === "error");
|
|
856
|
+
for (const d of errors) result.errors.push(`${file.relativePath}: ${d.message}`);
|
|
857
|
+
}
|
|
858
|
+
if (result.errors.length > 0) {
|
|
859
|
+
result.validationFailed = true;
|
|
860
|
+
return result;
|
|
861
|
+
}
|
|
862
|
+
}
|
|
816
863
|
const toUpload = localFiles.filter((f) => f.exists && this.hasChanged(f));
|
|
817
864
|
let done = 0;
|
|
818
865
|
for (const file of toUpload) {
|
|
@@ -842,10 +889,17 @@ var Syncer = class {
|
|
|
842
889
|
uploaded: 0,
|
|
843
890
|
deleted: 0,
|
|
844
891
|
downloaded: 0,
|
|
845
|
-
|
|
892
|
+
skipped: 0,
|
|
893
|
+
errors: [],
|
|
894
|
+
validationFailed: false
|
|
846
895
|
};
|
|
847
896
|
let done = 0;
|
|
848
897
|
for (const resource of resources) {
|
|
898
|
+
if (opts.skip?.has(resource.key)) {
|
|
899
|
+
result.skipped++;
|
|
900
|
+
opts.onProgress?.(++done, resources.length);
|
|
901
|
+
continue;
|
|
902
|
+
}
|
|
849
903
|
const file = this.themeRoot.file(resource.key);
|
|
850
904
|
if (!file.absolutePath.startsWith(this.themeRoot.root + sep)) {
|
|
851
905
|
result.errors.push(`Download ${resource.key}: path traversal detected`);
|
|
@@ -884,16 +938,29 @@ async function startDevServer(api, theme, themeRoot, opts, onReady) {
|
|
|
884
938
|
const syncer = new Syncer(api, theme.id, themeRoot);
|
|
885
939
|
const pendingUpdates = /* @__PURE__ */ new Set();
|
|
886
940
|
console.log(`\nSyncing theme ${theme.name} (#${theme.id})…`);
|
|
887
|
-
await syncer.uploadTheme({
|
|
941
|
+
const syncResult = await syncer.uploadTheme({
|
|
888
942
|
delete: true,
|
|
943
|
+
validate: opts.validate,
|
|
889
944
|
onProgress: (done, total) => {
|
|
890
945
|
process.stdout.write(`\r Uploading ${done}/${total} files…`);
|
|
891
946
|
}
|
|
892
947
|
});
|
|
893
948
|
process.stdout.write("\n");
|
|
949
|
+
if (syncResult.validationFailed) {
|
|
950
|
+
console.error(`\nSchema validation failed (${syncResult.errors.length} error(s)). Use --force to skip.\n`);
|
|
951
|
+
for (const e of syncResult.errors) console.error(` ${e}`);
|
|
952
|
+
process.exit(1);
|
|
953
|
+
} else if (syncResult.errors.length > 0) for (const e of syncResult.errors) console.error(` ${e}`);
|
|
894
954
|
const stopWatcher = watchTheme(themeRoot, async (modified, added, removed) => {
|
|
895
955
|
const changed = [...modified, ...added];
|
|
896
956
|
for (const file of changed) {
|
|
957
|
+
if (opts.validate && file.isLiquid) {
|
|
958
|
+
const diagnostics = file.validateSchema();
|
|
959
|
+
for (const d of diagnostics) {
|
|
960
|
+
const prefix = d.severity === "error" ? "Schema error" : "Schema warning";
|
|
961
|
+
console.warn(`\n[${prefix}] ${file.relativePath}: ${d.message}`);
|
|
962
|
+
}
|
|
963
|
+
}
|
|
897
964
|
pendingUpdates.add(file.relativePath);
|
|
898
965
|
try {
|
|
899
966
|
await syncer.uploadFile(file);
|
|
@@ -1051,6 +1118,52 @@ async function findTheme(api, identifier) {
|
|
|
1051
1118
|
process.exit(1);
|
|
1052
1119
|
}
|
|
1053
1120
|
//#endregion
|
|
1121
|
+
//#region src/workspace.ts
|
|
1122
|
+
const WORKSPACE_FILE = ".fluid-workspace.json";
|
|
1123
|
+
/**
|
|
1124
|
+
* Walk up from `startDir` looking for `.fluid-workspace.json`.
|
|
1125
|
+
* Returns the workspace info if found, or `null` if not in a workspace.
|
|
1126
|
+
*/
|
|
1127
|
+
function findWorkspace(startDir) {
|
|
1128
|
+
let dir = resolve(startDir ?? process.cwd());
|
|
1129
|
+
while (true) {
|
|
1130
|
+
const candidate = join(dir, WORKSPACE_FILE);
|
|
1131
|
+
if (existsSync(candidate)) try {
|
|
1132
|
+
const raw = readFileSync(candidate, "utf-8");
|
|
1133
|
+
const config = JSON.parse(raw);
|
|
1134
|
+
return {
|
|
1135
|
+
root: dir,
|
|
1136
|
+
config
|
|
1137
|
+
};
|
|
1138
|
+
} catch {
|
|
1139
|
+
return null;
|
|
1140
|
+
}
|
|
1141
|
+
const parent = dirname(dir);
|
|
1142
|
+
if (parent === dir) break;
|
|
1143
|
+
dir = parent;
|
|
1144
|
+
}
|
|
1145
|
+
return null;
|
|
1146
|
+
}
|
|
1147
|
+
/**
|
|
1148
|
+
* If cwd is already inside `{workspace}/local/{company}/...`, return that
|
|
1149
|
+
* theme root directory. Otherwise return null.
|
|
1150
|
+
*
|
|
1151
|
+
* Examples (workspace root = /code/fluid-theme-dev):
|
|
1152
|
+
* cwd = /code/fluid-theme-dev/local/acme-co → /code/fluid-theme-dev/local/acme-co
|
|
1153
|
+
* cwd = /code/fluid-theme-dev/local/acme-co/templates → /code/fluid-theme-dev/local/acme-co
|
|
1154
|
+
* cwd = /code/fluid-theme-dev → null
|
|
1155
|
+
* cwd = /code/fluid-theme-dev/local → null
|
|
1156
|
+
*/
|
|
1157
|
+
function resolveThemeRootFromCwd(workspace) {
|
|
1158
|
+
const cwd = resolve(process.cwd());
|
|
1159
|
+
const localDir = join(workspace.root, "local");
|
|
1160
|
+
const rel = relative(localDir, cwd);
|
|
1161
|
+
if (rel.startsWith("..") || rel === ".") return null;
|
|
1162
|
+
const firstSegment = rel.split(sep)[0];
|
|
1163
|
+
if (!firstSegment) return null;
|
|
1164
|
+
return join(localDir, firstSegment);
|
|
1165
|
+
}
|
|
1166
|
+
//#endregion
|
|
1054
1167
|
//#region src/commands/dev.ts
|
|
1055
1168
|
async function ensureDevTheme(api, identifier) {
|
|
1056
1169
|
if (identifier) return findTheme(api, identifier);
|
|
@@ -1077,9 +1190,14 @@ async function ensureDevTheme(api, identifier) {
|
|
|
1077
1190
|
function createDevCommand() {
|
|
1078
1191
|
return new Command("dev").description("Start the theme dev server with hot reload").option("--host <host>", "Local server host", "127.0.0.1").option("--port <port>", "Local server port", "9292").option("-t, --theme <name-or-id>", "Use an existing theme instead of dev theme").option("-f, --force", "Skip schema validation on upload").option("--live-reload <mode>", "Reload mode: full-page | off", "full-page").option("--navigate", "Open browser navigator after server starts").option("--root <path>", "Theme root directory", ".").action(async (opts) => {
|
|
1079
1192
|
requireToken();
|
|
1080
|
-
|
|
1193
|
+
let rootPath = opts.root;
|
|
1194
|
+
if (rootPath === ".") {
|
|
1195
|
+
const workspace = findWorkspace();
|
|
1196
|
+
if (workspace) rootPath = resolveThemeRootFromCwd(workspace) ?? rootPath;
|
|
1197
|
+
}
|
|
1198
|
+
const themeRoot = new ThemeRoot(rootPath);
|
|
1081
1199
|
if (!themeRoot.isValid()) {
|
|
1082
|
-
console.error(`'${
|
|
1200
|
+
console.error(`'${rootPath}' does not look like a theme directory.`);
|
|
1083
1201
|
process.exit(1);
|
|
1084
1202
|
}
|
|
1085
1203
|
const port = Number(opts.port);
|
|
@@ -1089,12 +1207,17 @@ function createDevCommand() {
|
|
|
1089
1207
|
}
|
|
1090
1208
|
const reloadMode = opts.liveReload === "off" ? "off" : "full-page";
|
|
1091
1209
|
const api = createApiClient();
|
|
1092
|
-
const
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1210
|
+
const config = readThemeConfig(themeRoot.root);
|
|
1211
|
+
let company;
|
|
1212
|
+
if (config?.company) company = config.company;
|
|
1213
|
+
else {
|
|
1214
|
+
company = (await api.get("/api/company/v1/companies/me")).data?.company?.subdomain ?? "";
|
|
1215
|
+
if (!company) {
|
|
1216
|
+
console.error("Could not determine company subdomain. Make sure your token is valid.");
|
|
1217
|
+
process.exit(1);
|
|
1218
|
+
}
|
|
1096
1219
|
}
|
|
1097
|
-
const theme = await ensureDevTheme(api, opts.theme);
|
|
1220
|
+
const theme = opts.theme ? await ensureDevTheme(api, opts.theme) : config ? await ensureDevTheme(api, String(config.themeId)) : await ensureDevTheme(api);
|
|
1098
1221
|
const editorUrl = `https://admin.fluid.app/themes/${theme.id}/editor`;
|
|
1099
1222
|
let stop;
|
|
1100
1223
|
const cleanup = () => {
|
|
@@ -1111,7 +1234,8 @@ function createDevCommand() {
|
|
|
1111
1234
|
}, themeRoot, {
|
|
1112
1235
|
host: opts.host,
|
|
1113
1236
|
port,
|
|
1114
|
-
reloadMode
|
|
1237
|
+
reloadMode,
|
|
1238
|
+
validate: !opts.force
|
|
1115
1239
|
}, (address) => {
|
|
1116
1240
|
console.log(`\n Dev server: ${address}`);
|
|
1117
1241
|
console.log(` Web editor: ${editorUrl}`);
|
|
@@ -1123,15 +1247,38 @@ function createDevCommand() {
|
|
|
1123
1247
|
}
|
|
1124
1248
|
//#endregion
|
|
1125
1249
|
//#region src/commands/push.ts
|
|
1250
|
+
/**
|
|
1251
|
+
* Detect files where the remote has changed since the last pull,
|
|
1252
|
+
* and we also have local changes (i.e. we'd overwrite someone else's work).
|
|
1253
|
+
*/
|
|
1254
|
+
function detectRemoteDrift(storedChecksums, remoteChecksums, themeRoot) {
|
|
1255
|
+
const conflicts = [];
|
|
1256
|
+
for (const [key, storedChecksum] of Object.entries(storedChecksums)) {
|
|
1257
|
+
const remoteChecksum = remoteChecksums[key];
|
|
1258
|
+
if (remoteChecksum === void 0) continue;
|
|
1259
|
+
if (remoteChecksum === storedChecksum) continue;
|
|
1260
|
+
const file = themeRoot.file(key);
|
|
1261
|
+
if (!file.exists) continue;
|
|
1262
|
+
if (file.checksum() === remoteChecksum) continue;
|
|
1263
|
+
conflicts.push(key);
|
|
1264
|
+
}
|
|
1265
|
+
return conflicts;
|
|
1266
|
+
}
|
|
1126
1267
|
function createPushCommand() {
|
|
1127
1268
|
return new Command("push").description("Push local theme files to a remote theme").option("-t, --theme <name-or-id>", "Theme name or ID to push to").option("-n, --nodelete", "Do not delete remote files missing locally").option("-f, --force", "Skip schema validation").option("-p, --publish", "Publish the theme after pushing").option("-u, --unpublished", "Create a new unpublished theme and push to it").option("--root <path>", "Theme root directory", ".").action(async (opts) => {
|
|
1128
1269
|
requireToken();
|
|
1129
|
-
|
|
1270
|
+
let rootPath = opts.root;
|
|
1271
|
+
if (rootPath === ".") {
|
|
1272
|
+
const workspace = findWorkspace();
|
|
1273
|
+
if (workspace) rootPath = resolveThemeRootFromCwd(workspace) ?? rootPath;
|
|
1274
|
+
}
|
|
1275
|
+
const themeRoot = new ThemeRoot(rootPath);
|
|
1130
1276
|
if (!themeRoot.isValid()) {
|
|
1131
|
-
console.error(`'${
|
|
1277
|
+
console.error(`'${rootPath}' does not look like a theme directory.`);
|
|
1132
1278
|
process.exit(1);
|
|
1133
1279
|
}
|
|
1134
1280
|
const api = createApiClient();
|
|
1281
|
+
const config = readThemeConfig(themeRoot.root);
|
|
1135
1282
|
let theme;
|
|
1136
1283
|
if (opts.unpublished) {
|
|
1137
1284
|
const { name } = await prompts({
|
|
@@ -1148,19 +1295,72 @@ function createPushCommand() {
|
|
|
1148
1295
|
status: "draft"
|
|
1149
1296
|
} })).application_theme;
|
|
1150
1297
|
console.log(`Created unpublished theme: ${theme.name} (#${theme.id})`);
|
|
1151
|
-
} else
|
|
1298
|
+
} else if (opts.theme) theme = await findTheme(api, opts.theme);
|
|
1299
|
+
else if (config) {
|
|
1300
|
+
console.log(` Using theme from .fluid-theme.json: ${chalk.bold(config.themeName)} (#${config.themeId})`);
|
|
1301
|
+
theme = (await getApplicationTheme(api, config.themeId)).application_theme;
|
|
1302
|
+
} else theme = await selectTheme(api, "Select a theme to push to");
|
|
1303
|
+
if (config?.checksums && !opts.force) {
|
|
1304
|
+
const driftSpinner = ora("Checking for remote changes…").start();
|
|
1305
|
+
const driftSyncer = new Syncer(api, theme.id, themeRoot);
|
|
1306
|
+
await driftSyncer.fetchChecksums();
|
|
1307
|
+
const remoteChecksums = driftSyncer.remoteChecksums();
|
|
1308
|
+
const conflicts = detectRemoteDrift(config.checksums, remoteChecksums, themeRoot);
|
|
1309
|
+
driftSpinner.stop();
|
|
1310
|
+
if (conflicts.length > 0) {
|
|
1311
|
+
console.log(chalk.yellow(`\n⚠ ${conflicts.length} file(s) changed on remote since last pull:\n`));
|
|
1312
|
+
for (const key of conflicts) console.log(` ${key}`);
|
|
1313
|
+
console.log();
|
|
1314
|
+
const { resolution } = await prompts({
|
|
1315
|
+
type: "select",
|
|
1316
|
+
name: "resolution",
|
|
1317
|
+
message: "How do you want to handle this?",
|
|
1318
|
+
choices: [
|
|
1319
|
+
{
|
|
1320
|
+
title: "Push anyway (overwrite remote changes)",
|
|
1321
|
+
value: "push"
|
|
1322
|
+
},
|
|
1323
|
+
{
|
|
1324
|
+
title: "Pull first, then push",
|
|
1325
|
+
value: "pull-first"
|
|
1326
|
+
},
|
|
1327
|
+
{
|
|
1328
|
+
title: "Abort",
|
|
1329
|
+
value: "abort"
|
|
1330
|
+
}
|
|
1331
|
+
]
|
|
1332
|
+
}, { onCancel: () => process.exit(130) });
|
|
1333
|
+
if (resolution === "abort") {
|
|
1334
|
+
console.log("Aborted.");
|
|
1335
|
+
process.exit(0);
|
|
1336
|
+
}
|
|
1337
|
+
if (resolution === "pull-first") {
|
|
1338
|
+
console.log(`Run ${chalk.cyan("fluid theme pull")} first, then push again.`);
|
|
1339
|
+
process.exit(0);
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1152
1343
|
const syncer = new Syncer(api, theme.id, themeRoot);
|
|
1153
1344
|
const spinner = ora(`Pushing to ${theme.name} (#${theme.id})…`).start();
|
|
1154
1345
|
const result = await syncer.uploadTheme({
|
|
1155
1346
|
delete: !opts.nodelete,
|
|
1347
|
+
validate: !opts.force,
|
|
1156
1348
|
onProgress: (d, total) => {
|
|
1157
1349
|
spinner.text = `Pushing ${d}/${total} files…`;
|
|
1158
1350
|
}
|
|
1159
1351
|
});
|
|
1160
|
-
if (result.
|
|
1352
|
+
if (result.validationFailed) {
|
|
1353
|
+
spinner.fail(`Schema validation failed (${result.errors.length} error(s)). Use --force to skip.`);
|
|
1354
|
+
for (const e of result.errors) console.error(` ${e}`);
|
|
1355
|
+
process.exit(1);
|
|
1356
|
+
} else if (result.errors.length) {
|
|
1161
1357
|
spinner.warn(`Pushed with ${result.errors.length} error(s).`);
|
|
1162
1358
|
for (const e of result.errors) console.error(` ${e}`);
|
|
1163
1359
|
} else spinner.succeed(`Pushed ${result.uploaded} file(s), deleted ${result.deleted} remote file(s).`);
|
|
1360
|
+
if (config) writeThemeConfig(themeRoot.root, {
|
|
1361
|
+
...config,
|
|
1362
|
+
checksums: syncer.remoteChecksums()
|
|
1363
|
+
});
|
|
1164
1364
|
if (opts.publish) {
|
|
1165
1365
|
const pubSpinner = ora("Publishing theme…").start();
|
|
1166
1366
|
try {
|
|
@@ -1174,24 +1374,146 @@ function createPushCommand() {
|
|
|
1174
1374
|
}
|
|
1175
1375
|
//#endregion
|
|
1176
1376
|
//#region src/commands/pull.ts
|
|
1377
|
+
async function fetchCompanySubdomain(api) {
|
|
1378
|
+
const subdomain = (await api.get("/api/company/v1/companies/me")).data?.company?.subdomain;
|
|
1379
|
+
if (!subdomain) {
|
|
1380
|
+
console.error("Could not determine company subdomain. Make sure your token is valid.");
|
|
1381
|
+
process.exit(1);
|
|
1382
|
+
}
|
|
1383
|
+
return subdomain;
|
|
1384
|
+
}
|
|
1385
|
+
function formatRelativeTime(iso) {
|
|
1386
|
+
const diff = Date.now() - new Date(iso).getTime();
|
|
1387
|
+
const minutes = Math.floor(diff / 6e4);
|
|
1388
|
+
if (minutes < 1) return "just now";
|
|
1389
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
1390
|
+
const hours = Math.floor(minutes / 60);
|
|
1391
|
+
if (hours < 24) return `${hours}h ago`;
|
|
1392
|
+
const days = Math.floor(hours / 24);
|
|
1393
|
+
if (days === 1) return "yesterday";
|
|
1394
|
+
return `${days}d ago (${new Date(iso).toLocaleDateString("en-US", {
|
|
1395
|
+
month: "short",
|
|
1396
|
+
day: "numeric",
|
|
1397
|
+
year: "numeric"
|
|
1398
|
+
})})`;
|
|
1399
|
+
}
|
|
1400
|
+
/**
|
|
1401
|
+
* Detect files where both local and remote have changed since the last pull.
|
|
1402
|
+
* Returns the set of conflicting resource keys.
|
|
1403
|
+
*/
|
|
1404
|
+
function detectConflicts(storedChecksums, remoteChecksums, themeRoot) {
|
|
1405
|
+
const conflicts = [];
|
|
1406
|
+
for (const [key, storedChecksum] of Object.entries(storedChecksums)) {
|
|
1407
|
+
const remoteChecksum = remoteChecksums[key];
|
|
1408
|
+
if (remoteChecksum === void 0) continue;
|
|
1409
|
+
if (remoteChecksum === storedChecksum) continue;
|
|
1410
|
+
const file = themeRoot.file(key);
|
|
1411
|
+
if (!file.exists) continue;
|
|
1412
|
+
const localChecksum = file.checksum();
|
|
1413
|
+
if (localChecksum === storedChecksum) continue;
|
|
1414
|
+
if (localChecksum === remoteChecksum) continue;
|
|
1415
|
+
conflicts.push(key);
|
|
1416
|
+
}
|
|
1417
|
+
return conflicts;
|
|
1418
|
+
}
|
|
1177
1419
|
function createPullCommand() {
|
|
1178
|
-
return new Command("pull").description("Pull a remote theme to your local directory").option("-t, --theme <name-or-id>", "Theme name or ID to pull").option("-n, --nodelete", "Do not delete local files missing on remote").option("--root <path>", "Theme root directory", "
|
|
1420
|
+
return new Command("pull").description("Pull a remote theme to your local directory").option("-t, --theme <name-or-id>", "Theme name or ID to pull").option("-n, --nodelete", "Do not delete local files missing on remote").option("--root <path>", "Theme root directory").option("-y, --yes", "Skip confirmation prompt").action(async (opts) => {
|
|
1179
1421
|
requireToken();
|
|
1180
1422
|
const api = createApiClient();
|
|
1423
|
+
const workspace = findWorkspace();
|
|
1181
1424
|
const theme = opts.theme ? await findTheme(api, opts.theme) : await selectTheme(api, "Select a theme to pull");
|
|
1182
|
-
const
|
|
1425
|
+
const subdomain = await fetchCompanySubdomain(api);
|
|
1426
|
+
let root;
|
|
1427
|
+
if (opts.root) root = opts.root;
|
|
1428
|
+
else if (workspace) root = resolveThemeRootFromCwd(workspace) ?? join(workspace.root, "local", subdomain);
|
|
1429
|
+
else root = `.`;
|
|
1430
|
+
const absoluteRoot = resolve(root);
|
|
1431
|
+
const existingConfig = readThemeConfig(absoluteRoot);
|
|
1432
|
+
console.log();
|
|
1433
|
+
console.log(` Theme: ${chalk.bold(theme.name)} (#${theme.id})`);
|
|
1434
|
+
console.log(` Company: ${chalk.bold(subdomain)}`);
|
|
1435
|
+
console.log(` Target: ${chalk.bold(absoluteRoot)}`);
|
|
1436
|
+
if (existingConfig?.lastPulledAt) console.log(` Last pulled: ${formatRelativeTime(existingConfig.lastPulledAt)}`);
|
|
1437
|
+
console.log();
|
|
1438
|
+
const themeRoot = new ThemeRoot(root);
|
|
1439
|
+
let skipKeys;
|
|
1440
|
+
if (existingConfig?.checksums) {
|
|
1441
|
+
const fetchSpinner = ora("Checking for conflicts…").start();
|
|
1442
|
+
const syncer = new Syncer(api, theme.id, themeRoot);
|
|
1443
|
+
await syncer.fetchChecksums();
|
|
1444
|
+
const remoteChecksums = syncer.remoteChecksums();
|
|
1445
|
+
const conflicts = detectConflicts(existingConfig.checksums, remoteChecksums, themeRoot);
|
|
1446
|
+
fetchSpinner.stop();
|
|
1447
|
+
if (conflicts.length > 0) {
|
|
1448
|
+
console.log(chalk.yellow(`⚠ ${conflicts.length} conflict(s) detected:\n`));
|
|
1449
|
+
for (const key of conflicts) console.log(` ${key}`);
|
|
1450
|
+
console.log();
|
|
1451
|
+
const { resolution } = await prompts({
|
|
1452
|
+
type: "select",
|
|
1453
|
+
name: "resolution",
|
|
1454
|
+
message: "How do you want to handle conflicts?",
|
|
1455
|
+
choices: [
|
|
1456
|
+
{
|
|
1457
|
+
title: "Keep local (skip conflicting files)",
|
|
1458
|
+
value: "keep-local"
|
|
1459
|
+
},
|
|
1460
|
+
{
|
|
1461
|
+
title: "Use remote (overwrite local changes)",
|
|
1462
|
+
value: "use-remote"
|
|
1463
|
+
},
|
|
1464
|
+
{
|
|
1465
|
+
title: "Abort",
|
|
1466
|
+
value: "abort"
|
|
1467
|
+
}
|
|
1468
|
+
]
|
|
1469
|
+
}, { onCancel: () => process.exit(130) });
|
|
1470
|
+
if (resolution === "abort") {
|
|
1471
|
+
console.log("Aborted.");
|
|
1472
|
+
process.exit(0);
|
|
1473
|
+
}
|
|
1474
|
+
if (resolution === "keep-local") skipKeys = new Set(conflicts);
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
if (!opts.yes && !skipKeys) {
|
|
1478
|
+
const { confirmed } = await prompts({
|
|
1479
|
+
type: "confirm",
|
|
1480
|
+
name: "confirmed",
|
|
1481
|
+
message: "Pull theme to this directory?",
|
|
1482
|
+
initial: true
|
|
1483
|
+
}, { onCancel: () => process.exit(130) });
|
|
1484
|
+
if (!confirmed) {
|
|
1485
|
+
console.log("Aborted.");
|
|
1486
|
+
process.exit(0);
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1183
1489
|
const syncer = new Syncer(api, theme.id, themeRoot);
|
|
1184
1490
|
const spinner = ora(`Pulling ${theme.name} (#${theme.id})…`).start();
|
|
1185
1491
|
const result = await syncer.downloadTheme({
|
|
1186
1492
|
delete: !opts.nodelete,
|
|
1493
|
+
skip: skipKeys,
|
|
1187
1494
|
onProgress: (d, total) => {
|
|
1188
1495
|
spinner.text = `Downloading ${d}/${total} files…`;
|
|
1189
1496
|
}
|
|
1190
1497
|
});
|
|
1498
|
+
const newChecksums = syncer.remoteChecksums();
|
|
1499
|
+
if (skipKeys && existingConfig?.checksums) for (const key of skipKeys) {
|
|
1500
|
+
const oldChecksum = existingConfig.checksums[key];
|
|
1501
|
+
if (oldChecksum) newChecksums[key] = oldChecksum;
|
|
1502
|
+
}
|
|
1503
|
+
writeThemeConfig(absoluteRoot, {
|
|
1504
|
+
themeId: theme.id,
|
|
1505
|
+
themeName: theme.name,
|
|
1506
|
+
company: subdomain,
|
|
1507
|
+
lastPulledAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1508
|
+
checksums: newChecksums
|
|
1509
|
+
});
|
|
1510
|
+
const parts = [`Downloaded ${result.downloaded} file(s)`];
|
|
1511
|
+
if (result.deleted > 0) parts.push(`deleted ${result.deleted} local file(s)`);
|
|
1512
|
+
if (result.skipped > 0) parts.push(`skipped ${result.skipped} conflict(s)`);
|
|
1191
1513
|
if (result.errors.length) {
|
|
1192
1514
|
spinner.warn(`Pulled with ${result.errors.length} error(s).`);
|
|
1193
1515
|
for (const e of result.errors) console.error(` ${e}`);
|
|
1194
|
-
} else spinner.succeed(
|
|
1516
|
+
} else spinner.succeed(`${parts.join(", ")}.`);
|
|
1195
1517
|
});
|
|
1196
1518
|
}
|
|
1197
1519
|
//#endregion
|