@fluid-app/fluid-cli-theme-dev 0.1.9 → 0.1.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +8 -6
- package/dist/index.mjs +360 -27
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/commands/dev.ts +38 -15
- package/src/commands/navigate.ts +104 -8
- package/src/commands/pull.ts +204 -6
- package/src/commands/push.ts +116 -5
- package/src/theme/syncer.ts +15 -2
- package/src/theme-config.ts +34 -0
- package/src/workspace.ts +71 -0
- package/tsdown.config.ts +1 -1
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
|
|
2
|
-
> @fluid-app/fluid-cli-theme-dev@0.1.
|
|
2
|
+
> @fluid-app/fluid-cli-theme-dev@0.1.11 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 54.76 kB[22m [2m│ gzip: 15.09 kB[22m
|
|
12
|
+
[34mℹ[39m [2mdist/[22mindex.mjs.map [2m140.30 kB[22m [2m│ gzip: 31.59 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
|
-
[
|
|
15
|
+
[34mℹ[39m 4 files, total: 195.36 kB
|
|
16
|
+
[33m[PLUGIN_TIMINGS] Warning:[0m Your build spent significant time in plugin `rolldown-plugin-dts:generate`. See https://rolldown.rs/options/checks#plugintimings for more details.
|
|
17
|
+
[32m✔[39m Build complete in [32m3560ms[39m
|
|
18
|
+
|
package/dist/index.mjs
CHANGED
|
@@ -37,7 +37,7 @@ var ApiError = class ApiError extends Error {
|
|
|
37
37
|
* Creates a configured fetch client instance
|
|
38
38
|
*/
|
|
39
39
|
function createFetchClient(config) {
|
|
40
|
-
const { baseUrl, getAuthToken, onAuthError, defaultHeaders = {} } = config;
|
|
40
|
+
const { baseUrl, getAuthToken, onAuthError, defaultHeaders = {}, credentials } = config;
|
|
41
41
|
/**
|
|
42
42
|
* Build headers for a request
|
|
43
43
|
*/
|
|
@@ -126,6 +126,7 @@ function createFetchClient(config) {
|
|
|
126
126
|
method,
|
|
127
127
|
headers
|
|
128
128
|
};
|
|
129
|
+
if (credentials) fetchOptions.credentials = credentials;
|
|
129
130
|
const serializedBody = body && method !== "GET" ? JSON.stringify(body) : null;
|
|
130
131
|
if (serializedBody) fetchOptions.body = serializedBody;
|
|
131
132
|
if (signal) fetchOptions.signal = signal;
|
|
@@ -150,6 +151,7 @@ function createFetchClient(config) {
|
|
|
150
151
|
headers,
|
|
151
152
|
body: formData
|
|
152
153
|
};
|
|
154
|
+
if (credentials) fetchOptions.credentials = credentials;
|
|
153
155
|
if (signal) fetchOptions.signal = signal;
|
|
154
156
|
response = await fetch(url, fetchOptions);
|
|
155
157
|
} catch (networkError) {
|
|
@@ -207,6 +209,27 @@ function requireToken() {
|
|
|
207
209
|
return token;
|
|
208
210
|
}
|
|
209
211
|
//#endregion
|
|
212
|
+
//#region src/theme-config.ts
|
|
213
|
+
const CONFIG_FILE = ".fluid-theme.json";
|
|
214
|
+
function configPath(themeRoot) {
|
|
215
|
+
return join(themeRoot, CONFIG_FILE);
|
|
216
|
+
}
|
|
217
|
+
/** Read `.fluid-theme.json` from a theme directory, or null if it doesn't exist. */
|
|
218
|
+
function readThemeConfig(themeRoot) {
|
|
219
|
+
const path = configPath(themeRoot);
|
|
220
|
+
if (!existsSync(path)) return null;
|
|
221
|
+
try {
|
|
222
|
+
const raw = readFileSync(path, "utf-8");
|
|
223
|
+
return JSON.parse(raw);
|
|
224
|
+
} catch {
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
/** Write `.fluid-theme.json` to a theme directory. */
|
|
229
|
+
function writeThemeConfig(themeRoot, config) {
|
|
230
|
+
writeFileSync(configPath(themeRoot), JSON.stringify(config, null, 2) + "\n", "utf-8");
|
|
231
|
+
}
|
|
232
|
+
//#endregion
|
|
210
233
|
//#region src/plugin-state.ts
|
|
211
234
|
const PLUGIN_KEY = "theme-dev";
|
|
212
235
|
function getPluginState() {
|
|
@@ -720,6 +743,10 @@ var Syncer = class {
|
|
|
720
743
|
remoteKeys() {
|
|
721
744
|
return [...this.checksums.keys()];
|
|
722
745
|
}
|
|
746
|
+
/** Snapshot of remote checksums (key → sha256). Available after fetchChecksums() or downloadAll(). */
|
|
747
|
+
remoteChecksums() {
|
|
748
|
+
return Object.fromEntries(this.checksums);
|
|
749
|
+
}
|
|
723
750
|
async uploadFile(file) {
|
|
724
751
|
if (file.isText) await updateThemeResource(this.api, this.themeId, { application_theme_resource: {
|
|
725
752
|
key: file.relativePath,
|
|
@@ -840,10 +867,16 @@ var Syncer = class {
|
|
|
840
867
|
uploaded: 0,
|
|
841
868
|
deleted: 0,
|
|
842
869
|
downloaded: 0,
|
|
870
|
+
skipped: 0,
|
|
843
871
|
errors: []
|
|
844
872
|
};
|
|
845
873
|
let done = 0;
|
|
846
874
|
for (const resource of resources) {
|
|
875
|
+
if (opts.skip?.has(resource.key)) {
|
|
876
|
+
result.skipped++;
|
|
877
|
+
opts.onProgress?.(++done, resources.length);
|
|
878
|
+
continue;
|
|
879
|
+
}
|
|
847
880
|
const file = this.themeRoot.file(resource.key);
|
|
848
881
|
if (!file.absolutePath.startsWith(this.themeRoot.root + sep)) {
|
|
849
882
|
result.errors.push(`Download ${resource.key}: path traversal detected`);
|
|
@@ -1049,6 +1082,52 @@ async function findTheme(api, identifier) {
|
|
|
1049
1082
|
process.exit(1);
|
|
1050
1083
|
}
|
|
1051
1084
|
//#endregion
|
|
1085
|
+
//#region src/workspace.ts
|
|
1086
|
+
const WORKSPACE_FILE = ".fluid-workspace.json";
|
|
1087
|
+
/**
|
|
1088
|
+
* Walk up from `startDir` looking for `.fluid-workspace.json`.
|
|
1089
|
+
* Returns the workspace info if found, or `null` if not in a workspace.
|
|
1090
|
+
*/
|
|
1091
|
+
function findWorkspace(startDir) {
|
|
1092
|
+
let dir = resolve(startDir ?? process.cwd());
|
|
1093
|
+
while (true) {
|
|
1094
|
+
const candidate = join(dir, WORKSPACE_FILE);
|
|
1095
|
+
if (existsSync(candidate)) try {
|
|
1096
|
+
const raw = readFileSync(candidate, "utf-8");
|
|
1097
|
+
const config = JSON.parse(raw);
|
|
1098
|
+
return {
|
|
1099
|
+
root: dir,
|
|
1100
|
+
config
|
|
1101
|
+
};
|
|
1102
|
+
} catch {
|
|
1103
|
+
return null;
|
|
1104
|
+
}
|
|
1105
|
+
const parent = dirname(dir);
|
|
1106
|
+
if (parent === dir) break;
|
|
1107
|
+
dir = parent;
|
|
1108
|
+
}
|
|
1109
|
+
return null;
|
|
1110
|
+
}
|
|
1111
|
+
/**
|
|
1112
|
+
* If cwd is already inside `{workspace}/local/{company}/...`, return that
|
|
1113
|
+
* theme root directory. Otherwise return null.
|
|
1114
|
+
*
|
|
1115
|
+
* Examples (workspace root = /code/fluid-theme-dev):
|
|
1116
|
+
* cwd = /code/fluid-theme-dev/local/acme-co → /code/fluid-theme-dev/local/acme-co
|
|
1117
|
+
* cwd = /code/fluid-theme-dev/local/acme-co/templates → /code/fluid-theme-dev/local/acme-co
|
|
1118
|
+
* cwd = /code/fluid-theme-dev → null
|
|
1119
|
+
* cwd = /code/fluid-theme-dev/local → null
|
|
1120
|
+
*/
|
|
1121
|
+
function resolveThemeRootFromCwd(workspace) {
|
|
1122
|
+
const cwd = resolve(process.cwd());
|
|
1123
|
+
const localDir = join(workspace.root, "local");
|
|
1124
|
+
const rel = relative(localDir, cwd);
|
|
1125
|
+
if (rel.startsWith("..") || rel === ".") return null;
|
|
1126
|
+
const firstSegment = rel.split(sep)[0];
|
|
1127
|
+
if (!firstSegment) return null;
|
|
1128
|
+
return join(localDir, firstSegment);
|
|
1129
|
+
}
|
|
1130
|
+
//#endregion
|
|
1052
1131
|
//#region src/commands/dev.ts
|
|
1053
1132
|
async function ensureDevTheme(api, identifier) {
|
|
1054
1133
|
if (identifier) return findTheme(api, identifier);
|
|
@@ -1075,9 +1154,14 @@ async function ensureDevTheme(api, identifier) {
|
|
|
1075
1154
|
function createDevCommand() {
|
|
1076
1155
|
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) => {
|
|
1077
1156
|
requireToken();
|
|
1078
|
-
|
|
1157
|
+
let rootPath = opts.root;
|
|
1158
|
+
if (rootPath === ".") {
|
|
1159
|
+
const workspace = findWorkspace();
|
|
1160
|
+
if (workspace) rootPath = resolveThemeRootFromCwd(workspace) ?? rootPath;
|
|
1161
|
+
}
|
|
1162
|
+
const themeRoot = new ThemeRoot(rootPath);
|
|
1079
1163
|
if (!themeRoot.isValid()) {
|
|
1080
|
-
console.error(`'${
|
|
1164
|
+
console.error(`'${rootPath}' does not look like a theme directory.`);
|
|
1081
1165
|
process.exit(1);
|
|
1082
1166
|
}
|
|
1083
1167
|
const port = Number(opts.port);
|
|
@@ -1087,12 +1171,18 @@ function createDevCommand() {
|
|
|
1087
1171
|
}
|
|
1088
1172
|
const reloadMode = opts.liveReload === "off" ? "off" : "full-page";
|
|
1089
1173
|
const api = createApiClient();
|
|
1090
|
-
const
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1174
|
+
const config = readThemeConfig(themeRoot.root);
|
|
1175
|
+
let company;
|
|
1176
|
+
if (config?.company) company = config.company;
|
|
1177
|
+
else {
|
|
1178
|
+
company = (await api.get("/api/company/v1/companies/me")).data?.company?.subdomain ?? "";
|
|
1179
|
+
if (!company) {
|
|
1180
|
+
console.error("Could not determine company subdomain. Make sure your token is valid.");
|
|
1181
|
+
process.exit(1);
|
|
1182
|
+
}
|
|
1094
1183
|
}
|
|
1095
|
-
const theme = await ensureDevTheme(api, opts.theme);
|
|
1184
|
+
const theme = opts.theme ? await ensureDevTheme(api, opts.theme) : config ? await ensureDevTheme(api, String(config.themeId)) : await ensureDevTheme(api);
|
|
1185
|
+
const editorUrl = `https://admin.fluid.app/themes/${theme.id}/editor`;
|
|
1096
1186
|
let stop;
|
|
1097
1187
|
const cleanup = () => {
|
|
1098
1188
|
stop?.();
|
|
@@ -1104,14 +1194,14 @@ function createDevCommand() {
|
|
|
1104
1194
|
id: theme.id,
|
|
1105
1195
|
name: theme.name,
|
|
1106
1196
|
company,
|
|
1107
|
-
editorUrl
|
|
1197
|
+
editorUrl
|
|
1108
1198
|
}, themeRoot, {
|
|
1109
1199
|
host: opts.host,
|
|
1110
1200
|
port,
|
|
1111
1201
|
reloadMode
|
|
1112
1202
|
}, (address) => {
|
|
1113
1203
|
console.log(`\n Dev server: ${address}`);
|
|
1114
|
-
|
|
1204
|
+
console.log(` Web editor: ${editorUrl}`);
|
|
1115
1205
|
console.log("\n Watching for file changes…\n");
|
|
1116
1206
|
if (opts.navigate) import("open").then((m) => m.default(`${address}/home`));
|
|
1117
1207
|
});
|
|
@@ -1120,15 +1210,38 @@ function createDevCommand() {
|
|
|
1120
1210
|
}
|
|
1121
1211
|
//#endregion
|
|
1122
1212
|
//#region src/commands/push.ts
|
|
1213
|
+
/**
|
|
1214
|
+
* Detect files where the remote has changed since the last pull,
|
|
1215
|
+
* and we also have local changes (i.e. we'd overwrite someone else's work).
|
|
1216
|
+
*/
|
|
1217
|
+
function detectRemoteDrift(storedChecksums, remoteChecksums, themeRoot) {
|
|
1218
|
+
const conflicts = [];
|
|
1219
|
+
for (const [key, storedChecksum] of Object.entries(storedChecksums)) {
|
|
1220
|
+
const remoteChecksum = remoteChecksums[key];
|
|
1221
|
+
if (remoteChecksum === void 0) continue;
|
|
1222
|
+
if (remoteChecksum === storedChecksum) continue;
|
|
1223
|
+
const file = themeRoot.file(key);
|
|
1224
|
+
if (!file.exists) continue;
|
|
1225
|
+
if (file.checksum() === remoteChecksum) continue;
|
|
1226
|
+
conflicts.push(key);
|
|
1227
|
+
}
|
|
1228
|
+
return conflicts;
|
|
1229
|
+
}
|
|
1123
1230
|
function createPushCommand() {
|
|
1124
1231
|
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) => {
|
|
1125
1232
|
requireToken();
|
|
1126
|
-
|
|
1233
|
+
let rootPath = opts.root;
|
|
1234
|
+
if (rootPath === ".") {
|
|
1235
|
+
const workspace = findWorkspace();
|
|
1236
|
+
if (workspace) rootPath = resolveThemeRootFromCwd(workspace) ?? rootPath;
|
|
1237
|
+
}
|
|
1238
|
+
const themeRoot = new ThemeRoot(rootPath);
|
|
1127
1239
|
if (!themeRoot.isValid()) {
|
|
1128
|
-
console.error(`'${
|
|
1240
|
+
console.error(`'${rootPath}' does not look like a theme directory.`);
|
|
1129
1241
|
process.exit(1);
|
|
1130
1242
|
}
|
|
1131
1243
|
const api = createApiClient();
|
|
1244
|
+
const config = readThemeConfig(themeRoot.root);
|
|
1132
1245
|
let theme;
|
|
1133
1246
|
if (opts.unpublished) {
|
|
1134
1247
|
const { name } = await prompts({
|
|
@@ -1145,7 +1258,51 @@ function createPushCommand() {
|
|
|
1145
1258
|
status: "draft"
|
|
1146
1259
|
} })).application_theme;
|
|
1147
1260
|
console.log(`Created unpublished theme: ${theme.name} (#${theme.id})`);
|
|
1148
|
-
} else
|
|
1261
|
+
} else if (opts.theme) theme = await findTheme(api, opts.theme);
|
|
1262
|
+
else if (config) {
|
|
1263
|
+
console.log(` Using theme from .fluid-theme.json: ${chalk.bold(config.themeName)} (#${config.themeId})`);
|
|
1264
|
+
theme = (await getApplicationTheme(api, config.themeId)).application_theme;
|
|
1265
|
+
} else theme = await selectTheme(api, "Select a theme to push to");
|
|
1266
|
+
if (config?.checksums && !opts.force) {
|
|
1267
|
+
const driftSpinner = ora("Checking for remote changes…").start();
|
|
1268
|
+
const driftSyncer = new Syncer(api, theme.id, themeRoot);
|
|
1269
|
+
await driftSyncer.fetchChecksums();
|
|
1270
|
+
const remoteChecksums = driftSyncer.remoteChecksums();
|
|
1271
|
+
const conflicts = detectRemoteDrift(config.checksums, remoteChecksums, themeRoot);
|
|
1272
|
+
driftSpinner.stop();
|
|
1273
|
+
if (conflicts.length > 0) {
|
|
1274
|
+
console.log(chalk.yellow(`\n⚠ ${conflicts.length} file(s) changed on remote since last pull:\n`));
|
|
1275
|
+
for (const key of conflicts) console.log(` ${key}`);
|
|
1276
|
+
console.log();
|
|
1277
|
+
const { resolution } = await prompts({
|
|
1278
|
+
type: "select",
|
|
1279
|
+
name: "resolution",
|
|
1280
|
+
message: "How do you want to handle this?",
|
|
1281
|
+
choices: [
|
|
1282
|
+
{
|
|
1283
|
+
title: "Push anyway (overwrite remote changes)",
|
|
1284
|
+
value: "push"
|
|
1285
|
+
},
|
|
1286
|
+
{
|
|
1287
|
+
title: "Pull first, then push",
|
|
1288
|
+
value: "pull-first"
|
|
1289
|
+
},
|
|
1290
|
+
{
|
|
1291
|
+
title: "Abort",
|
|
1292
|
+
value: "abort"
|
|
1293
|
+
}
|
|
1294
|
+
]
|
|
1295
|
+
}, { onCancel: () => process.exit(130) });
|
|
1296
|
+
if (resolution === "abort") {
|
|
1297
|
+
console.log("Aborted.");
|
|
1298
|
+
process.exit(0);
|
|
1299
|
+
}
|
|
1300
|
+
if (resolution === "pull-first") {
|
|
1301
|
+
console.log(`Run ${chalk.cyan("fluid theme pull")} first, then push again.`);
|
|
1302
|
+
process.exit(0);
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1149
1306
|
const syncer = new Syncer(api, theme.id, themeRoot);
|
|
1150
1307
|
const spinner = ora(`Pushing to ${theme.name} (#${theme.id})…`).start();
|
|
1151
1308
|
const result = await syncer.uploadTheme({
|
|
@@ -1158,6 +1315,10 @@ function createPushCommand() {
|
|
|
1158
1315
|
spinner.warn(`Pushed with ${result.errors.length} error(s).`);
|
|
1159
1316
|
for (const e of result.errors) console.error(` ${e}`);
|
|
1160
1317
|
} else spinner.succeed(`Pushed ${result.uploaded} file(s), deleted ${result.deleted} remote file(s).`);
|
|
1318
|
+
if (config) writeThemeConfig(themeRoot.root, {
|
|
1319
|
+
...config,
|
|
1320
|
+
checksums: syncer.remoteChecksums()
|
|
1321
|
+
});
|
|
1161
1322
|
if (opts.publish) {
|
|
1162
1323
|
const pubSpinner = ora("Publishing theme…").start();
|
|
1163
1324
|
try {
|
|
@@ -1171,24 +1332,146 @@ function createPushCommand() {
|
|
|
1171
1332
|
}
|
|
1172
1333
|
//#endregion
|
|
1173
1334
|
//#region src/commands/pull.ts
|
|
1335
|
+
async function fetchCompanySubdomain(api) {
|
|
1336
|
+
const subdomain = (await api.get("/api/company/v1/companies/me")).data?.company?.subdomain;
|
|
1337
|
+
if (!subdomain) {
|
|
1338
|
+
console.error("Could not determine company subdomain. Make sure your token is valid.");
|
|
1339
|
+
process.exit(1);
|
|
1340
|
+
}
|
|
1341
|
+
return subdomain;
|
|
1342
|
+
}
|
|
1343
|
+
function formatRelativeTime(iso) {
|
|
1344
|
+
const diff = Date.now() - new Date(iso).getTime();
|
|
1345
|
+
const minutes = Math.floor(diff / 6e4);
|
|
1346
|
+
if (minutes < 1) return "just now";
|
|
1347
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
1348
|
+
const hours = Math.floor(minutes / 60);
|
|
1349
|
+
if (hours < 24) return `${hours}h ago`;
|
|
1350
|
+
const days = Math.floor(hours / 24);
|
|
1351
|
+
if (days === 1) return "yesterday";
|
|
1352
|
+
return `${days}d ago (${new Date(iso).toLocaleDateString("en-US", {
|
|
1353
|
+
month: "short",
|
|
1354
|
+
day: "numeric",
|
|
1355
|
+
year: "numeric"
|
|
1356
|
+
})})`;
|
|
1357
|
+
}
|
|
1358
|
+
/**
|
|
1359
|
+
* Detect files where both local and remote have changed since the last pull.
|
|
1360
|
+
* Returns the set of conflicting resource keys.
|
|
1361
|
+
*/
|
|
1362
|
+
function detectConflicts(storedChecksums, remoteChecksums, themeRoot) {
|
|
1363
|
+
const conflicts = [];
|
|
1364
|
+
for (const [key, storedChecksum] of Object.entries(storedChecksums)) {
|
|
1365
|
+
const remoteChecksum = remoteChecksums[key];
|
|
1366
|
+
if (remoteChecksum === void 0) continue;
|
|
1367
|
+
if (remoteChecksum === storedChecksum) continue;
|
|
1368
|
+
const file = themeRoot.file(key);
|
|
1369
|
+
if (!file.exists) continue;
|
|
1370
|
+
const localChecksum = file.checksum();
|
|
1371
|
+
if (localChecksum === storedChecksum) continue;
|
|
1372
|
+
if (localChecksum === remoteChecksum) continue;
|
|
1373
|
+
conflicts.push(key);
|
|
1374
|
+
}
|
|
1375
|
+
return conflicts;
|
|
1376
|
+
}
|
|
1174
1377
|
function createPullCommand() {
|
|
1175
|
-
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", "
|
|
1378
|
+
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) => {
|
|
1176
1379
|
requireToken();
|
|
1177
1380
|
const api = createApiClient();
|
|
1381
|
+
const workspace = findWorkspace();
|
|
1178
1382
|
const theme = opts.theme ? await findTheme(api, opts.theme) : await selectTheme(api, "Select a theme to pull");
|
|
1179
|
-
const
|
|
1383
|
+
const subdomain = await fetchCompanySubdomain(api);
|
|
1384
|
+
let root;
|
|
1385
|
+
if (opts.root) root = opts.root;
|
|
1386
|
+
else if (workspace) root = resolveThemeRootFromCwd(workspace) ?? join(workspace.root, "local", subdomain);
|
|
1387
|
+
else root = `.`;
|
|
1388
|
+
const absoluteRoot = resolve(root);
|
|
1389
|
+
const existingConfig = readThemeConfig(absoluteRoot);
|
|
1390
|
+
console.log();
|
|
1391
|
+
console.log(` Theme: ${chalk.bold(theme.name)} (#${theme.id})`);
|
|
1392
|
+
console.log(` Company: ${chalk.bold(subdomain)}`);
|
|
1393
|
+
console.log(` Target: ${chalk.bold(absoluteRoot)}`);
|
|
1394
|
+
if (existingConfig?.lastPulledAt) console.log(` Last pulled: ${formatRelativeTime(existingConfig.lastPulledAt)}`);
|
|
1395
|
+
console.log();
|
|
1396
|
+
const themeRoot = new ThemeRoot(root);
|
|
1397
|
+
let skipKeys;
|
|
1398
|
+
if (existingConfig?.checksums) {
|
|
1399
|
+
const fetchSpinner = ora("Checking for conflicts…").start();
|
|
1400
|
+
const syncer = new Syncer(api, theme.id, themeRoot);
|
|
1401
|
+
await syncer.fetchChecksums();
|
|
1402
|
+
const remoteChecksums = syncer.remoteChecksums();
|
|
1403
|
+
const conflicts = detectConflicts(existingConfig.checksums, remoteChecksums, themeRoot);
|
|
1404
|
+
fetchSpinner.stop();
|
|
1405
|
+
if (conflicts.length > 0) {
|
|
1406
|
+
console.log(chalk.yellow(`⚠ ${conflicts.length} conflict(s) detected:\n`));
|
|
1407
|
+
for (const key of conflicts) console.log(` ${key}`);
|
|
1408
|
+
console.log();
|
|
1409
|
+
const { resolution } = await prompts({
|
|
1410
|
+
type: "select",
|
|
1411
|
+
name: "resolution",
|
|
1412
|
+
message: "How do you want to handle conflicts?",
|
|
1413
|
+
choices: [
|
|
1414
|
+
{
|
|
1415
|
+
title: "Keep local (skip conflicting files)",
|
|
1416
|
+
value: "keep-local"
|
|
1417
|
+
},
|
|
1418
|
+
{
|
|
1419
|
+
title: "Use remote (overwrite local changes)",
|
|
1420
|
+
value: "use-remote"
|
|
1421
|
+
},
|
|
1422
|
+
{
|
|
1423
|
+
title: "Abort",
|
|
1424
|
+
value: "abort"
|
|
1425
|
+
}
|
|
1426
|
+
]
|
|
1427
|
+
}, { onCancel: () => process.exit(130) });
|
|
1428
|
+
if (resolution === "abort") {
|
|
1429
|
+
console.log("Aborted.");
|
|
1430
|
+
process.exit(0);
|
|
1431
|
+
}
|
|
1432
|
+
if (resolution === "keep-local") skipKeys = new Set(conflicts);
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
if (!opts.yes && !skipKeys) {
|
|
1436
|
+
const { confirmed } = await prompts({
|
|
1437
|
+
type: "confirm",
|
|
1438
|
+
name: "confirmed",
|
|
1439
|
+
message: "Pull theme to this directory?",
|
|
1440
|
+
initial: true
|
|
1441
|
+
}, { onCancel: () => process.exit(130) });
|
|
1442
|
+
if (!confirmed) {
|
|
1443
|
+
console.log("Aborted.");
|
|
1444
|
+
process.exit(0);
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1180
1447
|
const syncer = new Syncer(api, theme.id, themeRoot);
|
|
1181
1448
|
const spinner = ora(`Pulling ${theme.name} (#${theme.id})…`).start();
|
|
1182
1449
|
const result = await syncer.downloadTheme({
|
|
1183
1450
|
delete: !opts.nodelete,
|
|
1451
|
+
skip: skipKeys,
|
|
1184
1452
|
onProgress: (d, total) => {
|
|
1185
1453
|
spinner.text = `Downloading ${d}/${total} files…`;
|
|
1186
1454
|
}
|
|
1187
1455
|
});
|
|
1456
|
+
const newChecksums = syncer.remoteChecksums();
|
|
1457
|
+
if (skipKeys && existingConfig?.checksums) for (const key of skipKeys) {
|
|
1458
|
+
const oldChecksum = existingConfig.checksums[key];
|
|
1459
|
+
if (oldChecksum) newChecksums[key] = oldChecksum;
|
|
1460
|
+
}
|
|
1461
|
+
writeThemeConfig(absoluteRoot, {
|
|
1462
|
+
themeId: theme.id,
|
|
1463
|
+
themeName: theme.name,
|
|
1464
|
+
company: subdomain,
|
|
1465
|
+
lastPulledAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1466
|
+
checksums: newChecksums
|
|
1467
|
+
});
|
|
1468
|
+
const parts = [`Downloaded ${result.downloaded} file(s)`];
|
|
1469
|
+
if (result.deleted > 0) parts.push(`deleted ${result.deleted} local file(s)`);
|
|
1470
|
+
if (result.skipped > 0) parts.push(`skipped ${result.skipped} conflict(s)`);
|
|
1188
1471
|
if (result.errors.length) {
|
|
1189
1472
|
spinner.warn(`Pulled with ${result.errors.length} error(s).`);
|
|
1190
1473
|
for (const e of result.errors) console.error(` ${e}`);
|
|
1191
|
-
} else spinner.succeed(
|
|
1474
|
+
} else spinner.succeed(`${parts.join(", ")}.`);
|
|
1192
1475
|
});
|
|
1193
1476
|
}
|
|
1194
1477
|
//#endregion
|
|
@@ -1231,6 +1514,20 @@ function createInitCommand() {
|
|
|
1231
1514
|
}
|
|
1232
1515
|
//#endregion
|
|
1233
1516
|
//#region src/commands/navigate.ts
|
|
1517
|
+
function localSuggest(input, choices) {
|
|
1518
|
+
if (!input) return choices;
|
|
1519
|
+
const lower = input.toLowerCase();
|
|
1520
|
+
return choices.filter((c) => c.title.toLowerCase().includes(lower));
|
|
1521
|
+
}
|
|
1522
|
+
const THEMEABLE_TYPE_MAP = {
|
|
1523
|
+
"/home": "home_page",
|
|
1524
|
+
"/home/shop": "shop_page",
|
|
1525
|
+
"/home/join": "join_page",
|
|
1526
|
+
"/cart": "cart_page",
|
|
1527
|
+
"/home/blog": "post_page",
|
|
1528
|
+
"/home/categories": "category_page",
|
|
1529
|
+
"/home/collections": "collection_page"
|
|
1530
|
+
};
|
|
1234
1531
|
const STATIC_ROUTES = [
|
|
1235
1532
|
{
|
|
1236
1533
|
label: "Home",
|
|
@@ -1311,6 +1608,29 @@ const RESOURCE_ROUTES = [
|
|
|
1311
1608
|
fallback: "/home/pages"
|
|
1312
1609
|
}
|
|
1313
1610
|
];
|
|
1611
|
+
async function fetchTemplatesForType(api, themeId, themeableType) {
|
|
1612
|
+
const params = new URLSearchParams({
|
|
1613
|
+
application_theme_id: String(themeId),
|
|
1614
|
+
themeable_type: themeableType,
|
|
1615
|
+
published: "true"
|
|
1616
|
+
});
|
|
1617
|
+
return (await api.get(`/api/application_theme_templates?${params}`)).templates ?? [];
|
|
1618
|
+
}
|
|
1619
|
+
async function selectTemplate(api, themeId, themeableType, onCancel) {
|
|
1620
|
+
const templates = await fetchTemplatesForType(api, themeId, themeableType);
|
|
1621
|
+
if (templates.length <= 1) return null;
|
|
1622
|
+
const { templateId } = await prompts({
|
|
1623
|
+
type: "autocomplete",
|
|
1624
|
+
name: "templateId",
|
|
1625
|
+
message: "Select a template",
|
|
1626
|
+
choices: templates.map((t) => ({
|
|
1627
|
+
title: `${t.name}${t.default ? " (default)" : ""}`,
|
|
1628
|
+
value: t.id
|
|
1629
|
+
})),
|
|
1630
|
+
suggest: (input, choices) => Promise.resolve(localSuggest(input, choices))
|
|
1631
|
+
}, { onCancel });
|
|
1632
|
+
return templateId ?? null;
|
|
1633
|
+
}
|
|
1314
1634
|
function createNavigateCommand() {
|
|
1315
1635
|
return new Command("navigate").description("Interactively navigate to a route in the dev server browser").option("--host <host>", "Dev server host", "127.0.0.1").option("--port <port>", "Dev server port", "9292").option("-t, --theme <id>", "Theme ID (defaults to active dev theme)").action(async (opts) => {
|
|
1316
1636
|
requireToken();
|
|
@@ -1334,16 +1654,22 @@ function createNavigateCommand() {
|
|
|
1334
1654
|
}))];
|
|
1335
1655
|
const onCancel = () => process.exit(130);
|
|
1336
1656
|
const { dest } = await prompts({
|
|
1337
|
-
type: "
|
|
1657
|
+
type: "autocomplete",
|
|
1338
1658
|
name: "dest",
|
|
1339
1659
|
message: "Select a route",
|
|
1340
|
-
choices
|
|
1660
|
+
choices,
|
|
1661
|
+
suggest: (input, choices) => Promise.resolve(localSuggest(input, choices))
|
|
1341
1662
|
}, { onCancel });
|
|
1342
1663
|
if (!dest) return;
|
|
1664
|
+
const api = createApiClient();
|
|
1343
1665
|
let path;
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1666
|
+
let themeableType;
|
|
1667
|
+
if (typeof dest === "string") {
|
|
1668
|
+
path = dest;
|
|
1669
|
+
themeableType = THEMEABLE_TYPE_MAP[dest];
|
|
1670
|
+
} else {
|
|
1671
|
+
themeableType = dest.resourceType;
|
|
1672
|
+
const resources = (await getApplicationThemeAvailableThemeables(api, themeId, {
|
|
1347
1673
|
themeable: dest.resourceType,
|
|
1348
1674
|
per_page: 50
|
|
1349
1675
|
})).available_themeables ?? [];
|
|
@@ -1351,19 +1677,26 @@ function createNavigateCommand() {
|
|
|
1351
1677
|
console.log(`No ${dest.label} resources found, using listing page.`);
|
|
1352
1678
|
path = dest.fallback;
|
|
1353
1679
|
} else {
|
|
1680
|
+
const resourceChoices = resources.map((r) => ({
|
|
1681
|
+
title: r.title ?? r.slug ?? "Untitled",
|
|
1682
|
+
value: r.slug
|
|
1683
|
+
}));
|
|
1354
1684
|
const { slug } = await prompts({
|
|
1355
|
-
type: "
|
|
1685
|
+
type: "autocomplete",
|
|
1356
1686
|
name: "slug",
|
|
1357
1687
|
message: `Select a ${dest.label.toLowerCase()}`,
|
|
1358
|
-
choices:
|
|
1359
|
-
|
|
1360
|
-
value: r.slug
|
|
1361
|
-
}))
|
|
1688
|
+
choices: resourceChoices,
|
|
1689
|
+
suggest: (input, choices) => Promise.resolve(localSuggest(input, choices))
|
|
1362
1690
|
}, { onCancel });
|
|
1363
1691
|
path = dest.template.replace("%s", slug);
|
|
1364
1692
|
}
|
|
1365
1693
|
}
|
|
1366
|
-
|
|
1694
|
+
let templateParam = "";
|
|
1695
|
+
if (themeableType) {
|
|
1696
|
+
const templateId = await selectTemplate(api, themeId, themeableType, onCancel);
|
|
1697
|
+
if (templateId) templateParam = `?theme_template_id=${templateId}`;
|
|
1698
|
+
}
|
|
1699
|
+
const url = `${address}${path}${templateParam}`;
|
|
1367
1700
|
console.log(`\nNavigating to: ${url}\n`);
|
|
1368
1701
|
const open = (await import("open")).default;
|
|
1369
1702
|
await open(url);
|