@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.
@@ -1,18 +1,18 @@
1
1
 
2
- > @fluid-app/fluid-cli-theme-dev@0.1.10 build /home/runner/_work/fluid-mono/fluid-mono/packages/cli/theme-dev
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
  ℹ tsdown v0.21.0 powered by rolldown v1.0.0-rc.7
6
6
  ℹ config file: /home/runner/_work/fluid-mono/fluid-mono/packages/cli/theme-dev/tsdown.config.ts
7
7
  ℹ entry: src/index.ts
8
- ℹ target: node18
8
+ ℹ target: node24
9
9
  ℹ tsconfig: tsconfig.json
10
10
  ℹ Build start
11
- ℹ dist/index.mjs  45.06 kB │ gzip: 12.79 kB
12
- ℹ dist/index.mjs.map 118.77 kB │ gzip: 26.94 kB
11
+ ℹ dist/index.mjs  54.76 kB │ gzip: 15.09 kB
12
+ ℹ dist/index.mjs.map 140.30 kB │ gzip: 31.59 kB
13
13
  ℹ dist/index.d.mts.map  0.11 kB │ gzip: 0.12 kB
14
14
  ℹ dist/index.d.mts  0.19 kB │ gzip: 0.16 kB
15
- ℹ 4 files, total: 164.13 kB
15
+ ℹ 4 files, total: 195.36 kB
16
16
  [PLUGIN_TIMINGS] Warning: Your build spent significant time in plugin `rolldown-plugin-dts:generate`. See https://rolldown.rs/options/checks#plugintimings for more details.
17
+ ✔ Build complete in 3560ms
17
18
 
18
- ✔ Build complete in 4607ms
package/dist/index.mjs CHANGED
@@ -209,6 +209,27 @@ function requireToken() {
209
209
  return token;
210
210
  }
211
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
212
233
  //#region src/plugin-state.ts
213
234
  const PLUGIN_KEY = "theme-dev";
214
235
  function getPluginState() {
@@ -722,6 +743,10 @@ var Syncer = class {
722
743
  remoteKeys() {
723
744
  return [...this.checksums.keys()];
724
745
  }
746
+ /** Snapshot of remote checksums (key → sha256). Available after fetchChecksums() or downloadAll(). */
747
+ remoteChecksums() {
748
+ return Object.fromEntries(this.checksums);
749
+ }
725
750
  async uploadFile(file) {
726
751
  if (file.isText) await updateThemeResource(this.api, this.themeId, { application_theme_resource: {
727
752
  key: file.relativePath,
@@ -842,10 +867,16 @@ var Syncer = class {
842
867
  uploaded: 0,
843
868
  deleted: 0,
844
869
  downloaded: 0,
870
+ skipped: 0,
845
871
  errors: []
846
872
  };
847
873
  let done = 0;
848
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
+ }
849
880
  const file = this.themeRoot.file(resource.key);
850
881
  if (!file.absolutePath.startsWith(this.themeRoot.root + sep)) {
851
882
  result.errors.push(`Download ${resource.key}: path traversal detected`);
@@ -1051,6 +1082,52 @@ async function findTheme(api, identifier) {
1051
1082
  process.exit(1);
1052
1083
  }
1053
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
1054
1131
  //#region src/commands/dev.ts
1055
1132
  async function ensureDevTheme(api, identifier) {
1056
1133
  if (identifier) return findTheme(api, identifier);
@@ -1077,9 +1154,14 @@ async function ensureDevTheme(api, identifier) {
1077
1154
  function createDevCommand() {
1078
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) => {
1079
1156
  requireToken();
1080
- const themeRoot = new ThemeRoot(opts.root);
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);
1081
1163
  if (!themeRoot.isValid()) {
1082
- console.error(`'${opts.root}' does not look like a theme directory.`);
1164
+ console.error(`'${rootPath}' does not look like a theme directory.`);
1083
1165
  process.exit(1);
1084
1166
  }
1085
1167
  const port = Number(opts.port);
@@ -1089,12 +1171,17 @@ function createDevCommand() {
1089
1171
  }
1090
1172
  const reloadMode = opts.liveReload === "off" ? "off" : "full-page";
1091
1173
  const api = createApiClient();
1092
- const company = (await api.get("/api/company/v1/companies/me")).data?.company?.subdomain;
1093
- if (!company) {
1094
- console.error("Could not determine company subdomain. Make sure your token is valid.");
1095
- process.exit(1);
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
+ }
1096
1183
  }
1097
- 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);
1098
1185
  const editorUrl = `https://admin.fluid.app/themes/${theme.id}/editor`;
1099
1186
  let stop;
1100
1187
  const cleanup = () => {
@@ -1123,15 +1210,38 @@ function createDevCommand() {
1123
1210
  }
1124
1211
  //#endregion
1125
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
+ }
1126
1230
  function createPushCommand() {
1127
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) => {
1128
1232
  requireToken();
1129
- const themeRoot = new ThemeRoot(opts.root);
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);
1130
1239
  if (!themeRoot.isValid()) {
1131
- console.error(`'${opts.root}' does not look like a theme directory.`);
1240
+ console.error(`'${rootPath}' does not look like a theme directory.`);
1132
1241
  process.exit(1);
1133
1242
  }
1134
1243
  const api = createApiClient();
1244
+ const config = readThemeConfig(themeRoot.root);
1135
1245
  let theme;
1136
1246
  if (opts.unpublished) {
1137
1247
  const { name } = await prompts({
@@ -1148,7 +1258,51 @@ function createPushCommand() {
1148
1258
  status: "draft"
1149
1259
  } })).application_theme;
1150
1260
  console.log(`Created unpublished theme: ${theme.name} (#${theme.id})`);
1151
- } else theme = opts.theme ? await findTheme(api, opts.theme) : await selectTheme(api, "Select a theme to push to");
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
+ }
1152
1306
  const syncer = new Syncer(api, theme.id, themeRoot);
1153
1307
  const spinner = ora(`Pushing to ${theme.name} (#${theme.id})…`).start();
1154
1308
  const result = await syncer.uploadTheme({
@@ -1161,6 +1315,10 @@ function createPushCommand() {
1161
1315
  spinner.warn(`Pushed with ${result.errors.length} error(s).`);
1162
1316
  for (const e of result.errors) console.error(` ${e}`);
1163
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
+ });
1164
1322
  if (opts.publish) {
1165
1323
  const pubSpinner = ora("Publishing theme…").start();
1166
1324
  try {
@@ -1174,24 +1332,146 @@ function createPushCommand() {
1174
1332
  }
1175
1333
  //#endregion
1176
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
+ }
1177
1377
  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", ".").action(async (opts) => {
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) => {
1179
1379
  requireToken();
1180
1380
  const api = createApiClient();
1381
+ const workspace = findWorkspace();
1181
1382
  const theme = opts.theme ? await findTheme(api, opts.theme) : await selectTheme(api, "Select a theme to pull");
1182
- const themeRoot = new ThemeRoot(opts.root);
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
+ }
1183
1447
  const syncer = new Syncer(api, theme.id, themeRoot);
1184
1448
  const spinner = ora(`Pulling ${theme.name} (#${theme.id})…`).start();
1185
1449
  const result = await syncer.downloadTheme({
1186
1450
  delete: !opts.nodelete,
1451
+ skip: skipKeys,
1187
1452
  onProgress: (d, total) => {
1188
1453
  spinner.text = `Downloading ${d}/${total} files…`;
1189
1454
  }
1190
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)`);
1191
1471
  if (result.errors.length) {
1192
1472
  spinner.warn(`Pulled with ${result.errors.length} error(s).`);
1193
1473
  for (const e of result.errors) console.error(` ${e}`);
1194
- } else spinner.succeed(`Downloaded ${result.downloaded} file(s), deleted ${result.deleted} local file(s).`);
1474
+ } else spinner.succeed(`${parts.join(", ")}.`);
1195
1475
  });
1196
1476
  }
1197
1477
  //#endregion