@stackable-labs/cli-app-extension 1.7.2 → 1.9.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.
Files changed (3) hide show
  1. package/README.md +3 -3
  2. package/dist/index.js +225 -144
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @stackable-labs/cli-app-extension
2
2
 
3
- CLI for scaffolding new [Stackable](https://www.npmjs.com/package/@stackable-labs/sdk-extension-react) extension projects.
3
+ CLI for scaffolding new [Stackable](https://www.npmjs.com/package/@stackable-labs/sdk-extension-react) App Extension projects.
4
4
 
5
5
  ## Quick Start
6
6
 
@@ -79,10 +79,10 @@ my-extension/
79
79
  ```bash
80
80
  cd my-extension
81
81
  pnpm install # if --skip-install was used
82
- pnpm dev # starts both extension + preview with hot-reload
82
+ pnpm dev # starts both Extension + Preview with hot-reload
83
83
  ```
84
84
 
85
- The preview host runs at the configured preview port and hot-reloads whenever the extension changes.
85
+ The preview host runs at the configured preview port and hot-reloads whenever the Extension changes.
86
86
 
87
87
  ## Requirements
88
88
 
package/dist/index.js CHANGED
@@ -6,7 +6,7 @@ import { program } from "commander";
6
6
  import { render } from "ink";
7
7
 
8
8
  // src/App.tsx
9
- import { join as join2 } from "path";
9
+ import { join as join3 } from "path";
10
10
  import { Box as Box13, Text as Text13, useApp } from "ink";
11
11
  import { useCallback, useState as useState8 } from "react";
12
12
 
@@ -776,7 +776,7 @@ var createExtensionRemote = async (appId, payload) => {
776
776
  });
777
777
  if (!res.ok) {
778
778
  const body = await res.text();
779
- throw new Error(`Failed to create extension: ${res.status} ${body}`);
779
+ throw new Error(`Failed to create Extension: ${res.status} ${body}`);
780
780
  }
781
781
  return res.json();
782
782
  };
@@ -784,7 +784,7 @@ var fetchExtensions = async (appId) => {
784
784
  const baseUrl = getAdminApiBaseUrl();
785
785
  const res = await fetch(`${baseUrl}/app-extension/${appId}/extensions`);
786
786
  if (!res.ok) {
787
- throw new Error(`Failed to fetch extensions: ${res.status} ${res.statusText}`);
787
+ throw new Error(`Failed to fetch Extensions: ${res.status} ${res.statusText}`);
788
788
  }
789
789
  const adminExtensions = await res.json();
790
790
  return Object.fromEntries(
@@ -1020,9 +1020,96 @@ var postScaffold = async (options) => {
1020
1020
  };
1021
1021
 
1022
1022
  // src/lib/scaffold.ts
1023
- import { readFile, readdir, rm, writeFile } from "fs/promises";
1024
- import { join } from "path";
1023
+ import { readFile as readFile2, readdir, rm, writeFile as writeFile2 } from "fs/promises";
1024
+ import { join as join2 } from "path";
1025
1025
  import { downloadTemplate } from "giget";
1026
+
1027
+ // src/lib/devContext.ts
1028
+ import { readFile, writeFile } from "fs/promises";
1029
+ import { join } from "path";
1030
+ var parseEnvFile = (content) => {
1031
+ const lines = content.split("\n");
1032
+ const env = {};
1033
+ for (const line of lines) {
1034
+ const trimmed = line.trim();
1035
+ if (trimmed && !trimmed.startsWith("#")) {
1036
+ const [key, ...rest] = trimmed.split("=");
1037
+ if (key && rest.length > 0) {
1038
+ env[key.trim()] = rest.join("=").trim();
1039
+ }
1040
+ }
1041
+ }
1042
+ return env;
1043
+ };
1044
+ var readEnvFile = async (filePath) => {
1045
+ try {
1046
+ const content = await readFile(filePath, "utf8");
1047
+ return parseEnvFile(content);
1048
+ } catch {
1049
+ return {};
1050
+ }
1051
+ };
1052
+ var writeEnvFile = async (filePath, env) => {
1053
+ const lines = Object.entries(env).map(([key, value]) => `${key}=${value}`).join("\n");
1054
+ await writeFile(filePath, `${lines}
1055
+ `);
1056
+ };
1057
+ var readDevContext = async (projectRoot) => {
1058
+ const stackableEnv = await readEnvFile(join(projectRoot, ".env.stackable"));
1059
+ const env = await readEnvFile(join(projectRoot, ".env"));
1060
+ let extensionName = stackableEnv.EXTENSION_NAME || "Unknown Extension";
1061
+ try {
1062
+ const manifestPath = join(projectRoot, "packages/extension/public/manifest.json");
1063
+ const manifestContent = await readFile(manifestPath, "utf8");
1064
+ const manifest = JSON.parse(manifestContent);
1065
+ extensionName = manifest.name;
1066
+ } catch {
1067
+ }
1068
+ const devLocalEnv = await readEnvFile(join(projectRoot, ".env.development.local"));
1069
+ const extensionPort = parseInt(devLocalEnv.VITE_EXTENSION_PORT || env.VITE_EXTENSION_PORT || "5173", 10);
1070
+ const previewPort = parseInt(devLocalEnv.VITE_PREVIEW_PORT || env.VITE_PREVIEW_PORT || "5174", 10);
1071
+ return {
1072
+ projectRoot,
1073
+ extensionName,
1074
+ appId: stackableEnv.APP_ID || null,
1075
+ extensionId: stackableEnv.EXTENSION_ID || null,
1076
+ appName: stackableEnv.APP_NAME || null,
1077
+ originalBundleUrl: stackableEnv.ORIGINAL_BUNDLE_URL || null,
1078
+ extensionPort,
1079
+ previewPort
1080
+ };
1081
+ };
1082
+ var DEV_LOCAL_ENV = ".env.development.local";
1083
+ var patchProjectEnv = async (projectRoot, key, value) => {
1084
+ const envPath = join(projectRoot, DEV_LOCAL_ENV);
1085
+ const existing = await readEnvFile(envPath);
1086
+ existing[key] = value;
1087
+ await writeEnvFile(envPath, existing);
1088
+ };
1089
+ var removeProjectEnvKey = async (projectRoot, key) => {
1090
+ const envPath = join(projectRoot, DEV_LOCAL_ENV);
1091
+ const existing = await readEnvFile(envPath);
1092
+ delete existing[key];
1093
+ await writeEnvFile(envPath, existing);
1094
+ };
1095
+ var writeDevContext = async (projectRoot, ctx) => {
1096
+ const env = {
1097
+ APP_ID: ctx.appId || "",
1098
+ EXTENSION_ID: ctx.extensionId || ""
1099
+ };
1100
+ if (ctx.appName) {
1101
+ env.APP_NAME = ctx.appName;
1102
+ }
1103
+ if (ctx.extensionName && ctx.extensionName !== "Unknown Extension") {
1104
+ env.EXTENSION_NAME = ctx.extensionName;
1105
+ }
1106
+ if (ctx.originalBundleUrl) {
1107
+ env.ORIGINAL_BUNDLE_URL = ctx.originalBundleUrl;
1108
+ }
1109
+ await writeEnvFile(join(projectRoot, ".env.stackable"), env);
1110
+ };
1111
+
1112
+ // src/lib/scaffold.ts
1026
1113
  var toKebabCase = (value) => value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
1027
1114
  var isTextFile = (filePath) => /\.(ts|tsx|js|jsx|json|md|html|yml|yaml|env|gitignore|nvmrc)$/i.test(filePath);
1028
1115
  var normalizeTargets = (targets) => Array.from(new Set(targets));
@@ -1042,7 +1129,7 @@ var derivePermissions = (targets) => {
1042
1129
  };
1043
1130
  var upsertOrRemove = async (filePath, shouldExist, content) => {
1044
1131
  if (shouldExist) {
1045
- await writeFile(filePath, content);
1132
+ await writeFile2(filePath, content);
1046
1133
  return;
1047
1134
  }
1048
1135
  await rm(filePath, { force: true });
@@ -1054,7 +1141,7 @@ var walkFiles = async (rootDir) => {
1054
1141
  if (entry.name === ".git" || entry.name === "node_modules" || entry.name === "dist") {
1055
1142
  continue;
1056
1143
  }
1057
- const fullPath = join(rootDir, entry.name);
1144
+ const fullPath = join2(rootDir, entry.name);
1058
1145
  if (entry.isDirectory()) {
1059
1146
  files.push(...await walkFiles(fullPath));
1060
1147
  continue;
@@ -1069,7 +1156,7 @@ var replacePlaceholders = async (rootDir, replacements) => {
1069
1156
  if (!isTextFile(filePath)) {
1070
1157
  continue;
1071
1158
  }
1072
- let content = await readFile(filePath, "utf8");
1159
+ let content = await readFile2(filePath, "utf8");
1073
1160
  let changed = false;
1074
1161
  for (const [needle, value] of Object.entries(replacements)) {
1075
1162
  if (content.includes(needle)) {
@@ -1078,18 +1165,18 @@ var replacePlaceholders = async (rootDir, replacements) => {
1078
1165
  }
1079
1166
  }
1080
1167
  if (changed) {
1081
- await writeFile(filePath, content);
1168
+ await writeFile2(filePath, content);
1082
1169
  }
1083
1170
  }
1084
1171
  };
1085
1172
  var generateManifest = async (rootDir, extensionName, targets, permissions) => {
1086
- const manifestPath = join(rootDir, "packages/extension/public/manifest.json");
1087
- const raw = await readFile(manifestPath, "utf8");
1173
+ const manifestPath = join2(rootDir, "packages/extension/public/manifest.json");
1174
+ const raw = await readFile2(manifestPath, "utf8");
1088
1175
  const manifest = JSON.parse(raw);
1089
1176
  manifest.name = extensionName;
1090
1177
  manifest.targets = targets;
1091
1178
  manifest.permissions = permissions;
1092
- await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}
1179
+ await writeFile2(manifestPath, `${JSON.stringify(manifest, null, 2)}
1093
1180
  `);
1094
1181
  };
1095
1182
  var buildFooterSurface = (targets) => {
@@ -1116,12 +1203,12 @@ ${blocks.join("\n")}
1116
1203
  `;
1117
1204
  };
1118
1205
  var generateSurfaceFiles = async (rootDir, targets) => {
1119
- const surfaceDir = join(rootDir, "packages/extension/src/surfaces");
1206
+ const surfaceDir = join2(rootDir, "packages/extension/src/surfaces");
1120
1207
  const wantsHeader = targets.includes("slot.header");
1121
1208
  const wantsContent = targets.includes("slot.content");
1122
1209
  const wantsFooter = targets.includes("slot.footer") || targets.includes("slot.footer-links");
1123
1210
  await upsertOrRemove(
1124
- join(surfaceDir, "Header.tsx"),
1211
+ join2(surfaceDir, "Header.tsx"),
1125
1212
  wantsHeader,
1126
1213
  `import { ui, Surface } from '@stackable-labs/sdk-extension-react'
1127
1214
 
@@ -1135,7 +1222,7 @@ export function Header() {
1135
1222
  `
1136
1223
  );
1137
1224
  await upsertOrRemove(
1138
- join(surfaceDir, "Content.tsx"),
1225
+ join2(surfaceDir, "Content.tsx"),
1139
1226
  wantsContent,
1140
1227
  `import { ui, useStore, useContextData, Surface } from '@stackable-labs/sdk-extension-react'
1141
1228
  import { appStore } from '../store'
@@ -1168,14 +1255,14 @@ export function Content() {
1168
1255
  `
1169
1256
  );
1170
1257
  await upsertOrRemove(
1171
- join(rootDir, "packages/extension/src/store.ts"),
1258
+ join2(rootDir, "packages/extension/src/store.ts"),
1172
1259
  wantsContent,
1173
1260
  "import { createStore } from '@stackable-labs/sdk-extension-react'\n\nexport type ViewState = { type: 'menu' }\n\nexport interface AppState {\n viewState: ViewState\n}\n\nexport const appStore = createStore<AppState>({\n viewState: { type: 'menu' },\n})\n"
1174
1261
  );
1175
- await upsertOrRemove(join(surfaceDir, "Footer.tsx"), wantsFooter, buildFooterSurface(targets));
1262
+ await upsertOrRemove(join2(surfaceDir, "Footer.tsx"), wantsFooter, buildFooterSurface(targets));
1176
1263
  };
1177
1264
  var rewriteExtensionIndex = async (rootDir, extensionId, targets) => {
1178
- const indexPath = join(rootDir, "packages/extension/src/index.tsx");
1265
+ const indexPath = join2(rootDir, "packages/extension/src/index.tsx");
1179
1266
  const imports = ["import { createExtension } from '@stackable-labs/sdk-extension-react'"];
1180
1267
  const components = [];
1181
1268
  if (targets.includes("slot.header")) {
@@ -1201,10 +1288,10 @@ ${components.join("\n")}
1201
1288
  { extensionId: '${toKebabCase(extensionId)}' },
1202
1289
  )
1203
1290
  `;
1204
- await writeFile(indexPath, content);
1291
+ await writeFile2(indexPath, content);
1205
1292
  };
1206
1293
  var rewritePreviewApp = async (rootDir, targets, permissions) => {
1207
- const appPath = join(rootDir, "packages/preview/src/App.tsx");
1294
+ const appPath = join2(rootDir, "packages/preview/src/App.tsx");
1208
1295
  const includeDataQuery = permissions.includes("data:query");
1209
1296
  const includeToast = permissions.includes("actions:toast");
1210
1297
  const includeInvoke = permissions.includes("actions:invoke");
@@ -1280,17 +1367,17 @@ export default function App() {
1280
1367
  )
1281
1368
  }
1282
1369
  `;
1283
- await writeFile(appPath, appContent);
1370
+ await writeFile2(appPath, appContent);
1284
1371
  };
1285
1372
  var patchViteAllowedHosts = async (rootDir) => {
1286
1373
  const configs = [
1287
- join(rootDir, "packages/extension/vite.config.ts"),
1288
- join(rootDir, "packages/preview/vite.config.ts")
1374
+ join2(rootDir, "packages/extension/vite.config.ts"),
1375
+ join2(rootDir, "packages/preview/vite.config.ts")
1289
1376
  ];
1290
1377
  for (const configPath of configs) {
1291
1378
  let content;
1292
1379
  try {
1293
- content = await readFile(configPath, "utf8");
1380
+ content = await readFile2(configPath, "utf8");
1294
1381
  } catch {
1295
1382
  continue;
1296
1383
  }
@@ -1300,38 +1387,38 @@ var patchViteAllowedHosts = async (rootDir) => {
1300
1387
  "server: {\n allowedHosts: true,"
1301
1388
  );
1302
1389
  if (patched !== content) {
1303
- await writeFile(configPath, patched);
1390
+ await writeFile2(configPath, patched);
1304
1391
  }
1305
1392
  }
1306
1393
  };
1307
1394
  var rewriteTurboJson = async (rootDir) => {
1308
- const turboPath = join(rootDir, "turbo.json");
1309
- const raw = await readFile(turboPath, "utf8");
1395
+ const turboPath = join2(rootDir, "turbo.json");
1396
+ const raw = await readFile2(turboPath, "utf8");
1310
1397
  const turbo = JSON.parse(raw);
1311
1398
  delete turbo["extends"];
1312
1399
  turbo["globalEnv"] = ["VITE_EXTENSION_PORT", "VITE_PREVIEW_PORT", "VITE_EXTENSION_BUNDLE_URL"];
1313
- await writeFile(turboPath, `${JSON.stringify(turbo, null, 2)}
1400
+ await writeFile2(turboPath, `${JSON.stringify(turbo, null, 2)}
1314
1401
  `);
1315
1402
  };
1316
- var writeEnvFile = async (dir, extensionPort, previewPort) => {
1317
- const envPath = join(dir, ".env");
1403
+ var writeEnvFile2 = async (dir, extensionPort, previewPort) => {
1404
+ const envPath = join2(dir, ".env");
1318
1405
  const content = `VITE_EXTENSION_PORT=${extensionPort}
1319
1406
  VITE_PREVIEW_PORT=${previewPort}
1320
1407
  `;
1321
- await writeFile(envPath, content);
1408
+ await writeFile2(envPath, content);
1322
1409
  };
1323
1410
  var updateGitignore = async (dir) => {
1324
- const gitignorePath = join(dir, ".gitignore");
1411
+ const gitignorePath = join2(dir, ".gitignore");
1325
1412
  let gitignore = "";
1326
1413
  try {
1327
- gitignore = await readFile(gitignorePath, "utf8");
1414
+ gitignore = await readFile2(gitignorePath, "utf8");
1328
1415
  } catch {
1329
1416
  }
1330
1417
  if (!gitignore.includes(".env.stackable")) {
1331
1418
  const newGitignore = gitignore ? `${gitignore}
1332
1419
  .env.stackable
1333
1420
  ` : ".env.stackable\n";
1334
- await writeFile(gitignorePath, newGitignore);
1421
+ await writeFile2(gitignorePath, newGitignore);
1335
1422
  }
1336
1423
  };
1337
1424
  var scaffold = async (options) => {
@@ -1353,8 +1440,17 @@ var scaffold = async (options) => {
1353
1440
  await rewritePreviewApp(dir, selectedTargets, derivedPermissions);
1354
1441
  await rewriteTurboJson(dir);
1355
1442
  await patchViteAllowedHosts(dir);
1356
- await writeEnvFile(dir, options.extensionPort, options.previewPort);
1443
+ await writeEnvFile2(dir, options.extensionPort, options.previewPort);
1357
1444
  await updateGitignore(dir);
1445
+ await writeDevContext(dir, {
1446
+ projectRoot: dir,
1447
+ appId: options.appId,
1448
+ extensionId: toKebabCase(options.extensionId || options.name),
1449
+ appName: options.appName || null,
1450
+ extensionName: options.name,
1451
+ extensionPort: options.extensionPort,
1452
+ previewPort: options.previewPort
1453
+ });
1358
1454
  return options;
1359
1455
  };
1360
1456
 
@@ -1369,7 +1465,7 @@ var STEPS = {
1369
1465
  };
1370
1466
  var PROGRESS_STEPS = {
1371
1467
  ["create" /* CREATE */]: [
1372
- { label: "Registering extension", status: "pending" },
1468
+ { label: "Registering Extension", status: "pending" },
1373
1469
  { label: "Fetching template", status: "pending" },
1374
1470
  { label: "Generating files", status: "pending" },
1375
1471
  { label: "Installing dependencies", status: "pending" }
@@ -1380,7 +1476,7 @@ var PROGRESS_STEPS = {
1380
1476
  { label: "Installing dependencies", status: "pending" }
1381
1477
  ],
1382
1478
  ["update" /* UPDATE */]: [
1383
- { label: "Updating extension", status: "pending" }
1479
+ { label: "Updating Extension", status: "pending" }
1384
1480
  ],
1385
1481
  ["dev" /* DEV */]: []
1386
1482
  // Dev command has no progress steps
@@ -1486,7 +1582,7 @@ var App = ({ command, initialName, initialExtensionId, options }) => {
1486
1582
  const handleConfirmTargets = (value) => {
1487
1583
  setTargets(value);
1488
1584
  if (options?.extensionPort || options?.previewPort) {
1489
- setOutputDir(join2(process.cwd(), toKebabCase2(extensionId || name)));
1585
+ setOutputDir(join3(process.cwd(), toKebabCase2(extensionId || name)));
1490
1586
  setStep("confirm");
1491
1587
  } else {
1492
1588
  setStep("settings");
@@ -1500,7 +1596,7 @@ var App = ({ command, initialName, initialExtensionId, options }) => {
1500
1596
  const handleTargets = (value) => {
1501
1597
  setTargets(value);
1502
1598
  if (options?.extensionPort || options?.previewPort) {
1503
- setOutputDir(join2(process.cwd(), toKebabCase2(extensionId || name)));
1599
+ setOutputDir(join3(process.cwd(), toKebabCase2(extensionId || name)));
1504
1600
  setStep("confirm");
1505
1601
  } else {
1506
1602
  setStep("settings");
@@ -1575,6 +1671,7 @@ var App = ({ command, initialName, initialExtensionId, options }) => {
1575
1671
  updateStep(scaffoldStepOffset + 0, "running");
1576
1672
  await scaffold({
1577
1673
  appId: selectedApp.id,
1674
+ appName: selectedApp.name,
1578
1675
  name,
1579
1676
  extensionId: resolvedExtensionId,
1580
1677
  targets,
@@ -1653,7 +1750,7 @@ var App = ({ command, initialName, initialExtensionId, options }) => {
1653
1750
  return /* @__PURE__ */ jsx13(
1654
1751
  SettingsPrompt,
1655
1752
  {
1656
- defaultDir: join2(process.cwd(), toKebabCase2(extensionId || name)),
1753
+ defaultDir: join3(process.cwd(), toKebabCase2(extensionId || name)),
1657
1754
  onSubmit: handleSettings,
1658
1755
  onBack: goBack
1659
1756
  }
@@ -1745,86 +1842,7 @@ var App = ({ command, initialName, initialExtensionId, options }) => {
1745
1842
 
1746
1843
  // src/components/DevApp.tsx
1747
1844
  import { Box as Box16, Text as Text16 } from "ink";
1748
- import { useState as useState10, useEffect as useEffect6, useCallback as useCallback2 } from "react";
1749
-
1750
- // src/lib/devContext.ts
1751
- import { readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
1752
- import { join as join3 } from "path";
1753
- var parseEnvFile = (content) => {
1754
- const lines = content.split("\n");
1755
- const env = {};
1756
- for (const line of lines) {
1757
- const trimmed = line.trim();
1758
- if (trimmed && !trimmed.startsWith("#")) {
1759
- const [key, ...rest] = trimmed.split("=");
1760
- if (key && rest.length > 0) {
1761
- env[key.trim()] = rest.join("=").trim();
1762
- }
1763
- }
1764
- }
1765
- return env;
1766
- };
1767
- var readEnvFile = async (filePath) => {
1768
- try {
1769
- const content = await readFile2(filePath, "utf8");
1770
- return parseEnvFile(content);
1771
- } catch {
1772
- return {};
1773
- }
1774
- };
1775
- var writeEnvFile2 = async (filePath, env) => {
1776
- const lines = Object.entries(env).map(([key, value]) => `${key}=${value}`).join("\n");
1777
- await writeFile2(filePath, `${lines}
1778
- `);
1779
- };
1780
- var readDevContext = async (projectRoot) => {
1781
- const stackableEnv = await readEnvFile(join3(projectRoot, ".env.stackable"));
1782
- const env = await readEnvFile(join3(projectRoot, ".env"));
1783
- let extensionName = "Unknown Extension";
1784
- try {
1785
- const manifestPath = join3(projectRoot, "packages/extension/public/manifest.json");
1786
- const manifestContent = await readFile2(manifestPath, "utf8");
1787
- const manifest = JSON.parse(manifestContent);
1788
- extensionName = manifest.name;
1789
- } catch {
1790
- }
1791
- const extensionPort = parseInt(env.VITE_EXTENSION_PORT || stackableEnv.EXTENSION_PORT || "5173", 10);
1792
- const previewPort = parseInt(env.VITE_PREVIEW_PORT || stackableEnv.PREVIEW_PORT || "5174", 10);
1793
- return {
1794
- projectRoot,
1795
- extensionName,
1796
- appId: stackableEnv.APP_ID || null,
1797
- extensionId: stackableEnv.EXTENSION_ID || null,
1798
- appName: stackableEnv.APP_NAME || null,
1799
- extensionPort,
1800
- previewPort
1801
- };
1802
- };
1803
- var DEV_LOCAL_ENV = ".env.development.local";
1804
- var patchProjectEnv = async (projectRoot, key, value) => {
1805
- const envPath = join3(projectRoot, DEV_LOCAL_ENV);
1806
- const existing = await readEnvFile(envPath);
1807
- existing[key] = value;
1808
- await writeEnvFile2(envPath, existing);
1809
- };
1810
- var removeProjectEnvKey = async (projectRoot, key) => {
1811
- const envPath = join3(projectRoot, DEV_LOCAL_ENV);
1812
- const existing = await readEnvFile(envPath);
1813
- delete existing[key];
1814
- await writeEnvFile2(envPath, existing);
1815
- };
1816
- var writeDevContext = async (projectRoot, ctx) => {
1817
- const env = {
1818
- APP_ID: ctx.appId || "",
1819
- EXTENSION_ID: ctx.extensionId || "",
1820
- EXTENSION_PORT: ctx.extensionPort.toString(),
1821
- PREVIEW_PORT: ctx.previewPort.toString()
1822
- };
1823
- if (ctx.appName) {
1824
- env.APP_NAME = ctx.appName;
1825
- }
1826
- await writeEnvFile2(join3(projectRoot, ".env.stackable"), env);
1827
- };
1845
+ import { useRef, useState as useState10, useEffect as useEffect6, useCallback as useCallback2 } from "react";
1828
1846
 
1829
1847
  // src/lib/tunnel.ts
1830
1848
  import { Tunnel } from "cloudflared";
@@ -1908,6 +1926,7 @@ var DevDashboard = ({
1908
1926
  appId,
1909
1927
  appName,
1910
1928
  tunnelUrl,
1929
+ previewTunnelUrl,
1911
1930
  previewPort,
1912
1931
  extensionPort,
1913
1932
  bundleUrlUpdated,
@@ -1922,8 +1941,8 @@ var DevDashboard = ({
1922
1941
  process.off("SIGINT", handler);
1923
1942
  };
1924
1943
  }, [onQuit]);
1925
- useInput8((input) => {
1926
- if (input === "q") {
1944
+ useInput8((input, key) => {
1945
+ if (input === "q" || key.ctrl && input === "c") {
1927
1946
  onQuit();
1928
1947
  }
1929
1948
  });
@@ -1933,7 +1952,7 @@ var DevDashboard = ({
1933
1952
  StepShell,
1934
1953
  {
1935
1954
  title: "dev",
1936
- hint: "Live development with tunnel",
1955
+ hint: "Live development with Tunnel",
1937
1956
  footer: /* @__PURE__ */ jsxs15(Box15, { flexDirection: "column", gap: 1, children: [
1938
1957
  bundleUrlUpdated && /* @__PURE__ */ jsx15(Text15, { color: "green", children: "bundleUrl updated \u2713" }),
1939
1958
  /* @__PURE__ */ jsx15(Text15, { dimColor: true, children: "Press q or Ctrl-C to quit" })
@@ -1955,23 +1974,34 @@ var DevDashboard = ({
1955
1974
  ] })
1956
1975
  ] }),
1957
1976
  /* @__PURE__ */ jsxs15(Box15, { flexDirection: "column", gap: 1, children: [
1977
+ previewTunnelUrl && /* @__PURE__ */ jsxs15(Box15, { gap: 2, children: [
1978
+ /* @__PURE__ */ jsx15(Text15, { dimColor: true, children: "Tunnel Dev - Preview:" }),
1979
+ /* @__PURE__ */ jsx15(Text15, { children: previewTunnelUrl })
1980
+ ] }),
1958
1981
  /* @__PURE__ */ jsxs15(Box15, { gap: 2, children: [
1959
- /* @__PURE__ */ jsx15(Text15, { dimColor: true, children: "Preview:" }),
1982
+ /* @__PURE__ */ jsx15(Text15, { dimColor: true, children: "Local Dev - Preview:" }),
1960
1983
  /* @__PURE__ */ jsxs15(Text15, { children: [
1961
1984
  "http://localhost:",
1962
1985
  previewPort
1963
1986
  ] })
1964
1987
  ] }),
1988
+ tunnelUrl && /* @__PURE__ */ jsxs15(Box15, { gap: 2, children: [
1989
+ /* @__PURE__ */ jsx15(Text15, { dimColor: true, children: "Tunnel Dev - Extension:" }),
1990
+ /* @__PURE__ */ jsx15(Text15, { children: tunnelUrl })
1991
+ ] }),
1965
1992
  /* @__PURE__ */ jsxs15(Box15, { gap: 2, children: [
1966
- /* @__PURE__ */ jsx15(Text15, { dimColor: true, children: "Local:" }),
1993
+ /* @__PURE__ */ jsx15(Text15, { dimColor: true, children: "Local Dev - Extension:" }),
1967
1994
  /* @__PURE__ */ jsxs15(Text15, { children: [
1968
1995
  "http://localhost:",
1969
1996
  extensionPort
1970
1997
  ] })
1971
1998
  ] }),
1972
1999
  /* @__PURE__ */ jsxs15(Box15, { gap: 2, children: [
1973
- /* @__PURE__ */ jsx15(Text15, { dimColor: true, children: "Tunnel:" }),
1974
- /* @__PURE__ */ jsx15(Text15, { children: tunnelUrl || "Connecting\u2026" })
2000
+ /* @__PURE__ */ jsx15(Text15, { dimColor: true, children: "Bundle URL:" }),
2001
+ /* @__PURE__ */ jsxs15(Text15, { children: [
2002
+ tunnelUrl || `http://localhost:${extensionPort}`,
2003
+ bundleUrlUpdated ? " (updated \u2713)" : ""
2004
+ ] })
1975
2005
  ] })
1976
2006
  ] })
1977
2007
  ]
@@ -1982,15 +2012,25 @@ var DevDashboard = ({
1982
2012
 
1983
2013
  // src/components/DevApp.tsx
1984
2014
  import { jsx as jsx16 } from "react/jsx-runtime";
2015
+ var isEphemeralUrl = (url) => {
2016
+ try {
2017
+ const { hostname } = new URL(url);
2018
+ return hostname.endsWith(".ngrok.io") || hostname.endsWith(".ngrok-free.app") || hostname.endsWith(".trycloudflare.com");
2019
+ } catch {
2020
+ return true;
2021
+ }
2022
+ };
1985
2023
  var DevApp = ({ options = {} }) => {
1986
2024
  const [state, setState] = useState10("setup");
1987
2025
  const [devContext, setDevContext] = useState10(null);
1988
2026
  const [resolvedContext, setResolvedContext] = useState10(null);
1989
2027
  const [tunnelUrl, setTunnelUrl] = useState10(null);
2028
+ const [previewTunnelUrl, setPreviewTunnelUrl] = useState10(null);
1990
2029
  const [bundleUrlUpdated, setBundleUrlUpdated] = useState10(false);
1991
2030
  const [tunnelHandle, setTunnelHandle] = useState10(null);
2031
+ const [previewTunnelHandle, setPreviewTunnelHandle] = useState10(null);
1992
2032
  const [devServerHandle, setDevServerHandle] = useState10(null);
1993
- const [originalBundleUrl, setOriginalBundleUrl] = useState10("");
2033
+ const shuttingDown = useRef(false);
1994
2034
  const useTunnel = options.tunnel !== false;
1995
2035
  const useUpdate = options.update !== false;
1996
2036
  const useRestore = options.restore !== false;
@@ -2013,19 +2053,49 @@ var DevApp = ({ options = {} }) => {
2013
2053
  appName: resolved.appName || null
2014
2054
  });
2015
2055
  const extensionPort = options.extensionPort ? parseInt(options.extensionPort, 10) : devContext.extensionPort;
2056
+ const previewPort = options.previewPort ? parseInt(options.previewPort, 10) : devContext.previewPort;
2016
2057
  await patchViteAllowedHosts(devContext.projectRoot);
2058
+ let cachedOriginalUrl = devContext.originalBundleUrl;
2059
+ if (useUpdate) {
2060
+ try {
2061
+ console.log("[dev] Fetching current bundleUrl from API...");
2062
+ const extensions = await fetchExtensions(resolved.appId);
2063
+ const currentExtension = extensions[resolved.extensionId];
2064
+ if (currentExtension?.bundleUrl) {
2065
+ console.log(`[dev] Current bundleUrl: ${currentExtension.bundleUrl}`);
2066
+ if (!isEphemeralUrl(currentExtension.bundleUrl)) {
2067
+ console.log("[dev] Caching stable bundleUrl to .env.stackable");
2068
+ cachedOriginalUrl = currentExtension.bundleUrl;
2069
+ await writeDevContext(devContext.projectRoot, {
2070
+ ...devContext,
2071
+ appId: resolved.appId,
2072
+ extensionId: resolved.extensionId,
2073
+ appName: resolved.appName || null,
2074
+ originalBundleUrl: cachedOriginalUrl
2075
+ });
2076
+ } else {
2077
+ console.log("[dev] Current bundleUrl is ephemeral, keeping previous cached value");
2078
+ }
2079
+ }
2080
+ } catch (err) {
2081
+ console.warn("[dev] Failed to fetch current bundleUrl:", err);
2082
+ }
2083
+ }
2017
2084
  if (useTunnel) {
2018
2085
  try {
2019
- console.log(`[dev] Starting tunnel on port ${extensionPort}...`);
2086
+ console.log(`[dev] Starting extension tunnel on port ${extensionPort}...`);
2020
2087
  const tunnelResult = await startTunnel(extensionPort);
2021
- console.log(`[dev] Tunnel ready: ${tunnelResult.url}`);
2088
+ console.log(`[dev] Extension tunnel ready: ${tunnelResult.url}`);
2022
2089
  setTunnelHandle(tunnelResult);
2023
2090
  setTunnelUrl(tunnelResult.url);
2091
+ console.log(`[dev] Starting preview tunnel on port ${previewPort}...`);
2092
+ const previewTunnelResult = await startTunnel(previewPort);
2093
+ console.log(`[dev] Preview tunnel ready: ${previewTunnelResult.url}`);
2094
+ setPreviewTunnelHandle(previewTunnelResult);
2095
+ setPreviewTunnelUrl(previewTunnelResult.url);
2024
2096
  console.log(`[dev] Writing VITE_EXTENSION_BUNDLE_URL=${tunnelResult.url} to .env`);
2025
2097
  await patchProjectEnv(devContext.projectRoot, "VITE_EXTENSION_BUNDLE_URL", tunnelResult.url);
2026
2098
  if (useUpdate) {
2027
- const originalUrl = `http://localhost:${extensionPort}`;
2028
- setOriginalBundleUrl(originalUrl);
2029
2099
  console.log(`[dev] Updating bundleUrl to ${tunnelResult.url}`);
2030
2100
  await updateExtension(resolved.appId, resolved.extensionId, {
2031
2101
  bundleUrl: tunnelResult.url
@@ -2044,7 +2114,7 @@ var DevApp = ({ options = {} }) => {
2044
2114
  setDevServerHandle(serverHandle);
2045
2115
  console.log("[dev] Ready");
2046
2116
  setState("running");
2047
- }, [devContext, options.extensionPort, useTunnel, useUpdate]);
2117
+ }, [devContext, options.extensionPort, options.previewPort, useTunnel, useUpdate]);
2048
2118
  useEffect6(() => {
2049
2119
  if (state === "setup" && devContext && devContext.appId && devContext.extensionId) {
2050
2120
  handleSetupReady({
@@ -2055,23 +2125,30 @@ var DevApp = ({ options = {} }) => {
2055
2125
  }
2056
2126
  }, [state, devContext, handleSetupReady]);
2057
2127
  const handleQuit = async () => {
2058
- if (!devContext || !resolvedContext) return;
2128
+ if (!devContext || !resolvedContext || shuttingDown.current) return;
2129
+ shuttingDown.current = true;
2059
2130
  setState("stopping");
2060
2131
  console.log("[dev] Shutting down...");
2061
2132
  try {
2062
- if (useRestore && originalBundleUrl) {
2063
- console.log(`[dev] Restoring bundleUrl to ${originalBundleUrl}`);
2133
+ if (useRestore && devContext.originalBundleUrl) {
2134
+ console.log(`[dev] Restoring bundleUrl to ${devContext.originalBundleUrl}`);
2064
2135
  await updateExtension(resolvedContext.appId, resolvedContext.extensionId, {
2065
- bundleUrl: originalBundleUrl
2136
+ bundleUrl: devContext.originalBundleUrl
2066
2137
  });
2067
2138
  console.log("[dev] bundleUrl restored");
2139
+ } else if (useRestore && !devContext.originalBundleUrl) {
2140
+ console.warn("[dev] No cached original bundleUrl to restore");
2068
2141
  }
2069
2142
  console.log("[dev] Removing VITE_EXTENSION_BUNDLE_URL from .env");
2070
2143
  await removeProjectEnvKey(devContext.projectRoot, "VITE_EXTENSION_BUNDLE_URL");
2071
2144
  if (tunnelHandle) {
2072
- console.log("[dev] Stopping tunnel");
2145
+ console.log("[dev] Stopping Extension tunnel");
2073
2146
  tunnelHandle.stop();
2074
2147
  }
2148
+ if (previewTunnelHandle) {
2149
+ console.log("[dev] Stopping preview tunnel");
2150
+ previewTunnelHandle.stop();
2151
+ }
2075
2152
  if (devServerHandle) {
2076
2153
  console.log("[dev] Stopping dev server");
2077
2154
  devServerHandle.stop();
@@ -2105,11 +2182,15 @@ var DevApp = ({ options = {} }) => {
2105
2182
  extensionPort: options.extensionPort ? parseInt(options.extensionPort, 10) : devContext.extensionPort,
2106
2183
  previewPort: options.previewPort ? parseInt(options.previewPort, 10) : devContext.previewPort,
2107
2184
  tunnelUrl,
2185
+ previewTunnelUrl,
2108
2186
  bundleUrlUpdated,
2109
2187
  onQuit: handleQuit
2110
2188
  }
2111
2189
  );
2112
2190
  }
2191
+ if (state === "stopping") {
2192
+ return null;
2193
+ }
2113
2194
  return /* @__PURE__ */ jsx16(Box16, { children: /* @__PURE__ */ jsx16(Text16, { children: "Loading..." }) });
2114
2195
  };
2115
2196
 
@@ -2117,17 +2198,17 @@ var DevApp = ({ options = {} }) => {
2117
2198
  import { jsx as jsx17 } from "react/jsx-runtime";
2118
2199
  var require2 = createRequire(import.meta.url);
2119
2200
  var { version } = require2("../package.json");
2120
- program.name("stackable-app-extension").description("Stackable app Extension developer CLI").version(version);
2121
- program.command("create" /* CREATE */).description("Create a new Extension project").argument("[name]", "Extension project name").option("--extension-port <port>", "Extension dev server port (default: 5173)").option("--preview-port <port>", "Preview host dev server port").option("--skip-install", "Skip package manager install").option("--skip-git", "Skip git initialization").action((name, options) => {
2201
+ program.name("stackable-app-extension").description("Stackable Labs - App Extension developer CLI").version(version);
2202
+ program.command("create" /* CREATE */).description("Create a new Extension project").argument("[name]", "Extension project name").option("--extension-port <port>", "Extension dev server port (default: 5173)").option("--preview-port <port>", "Preview dev server port").option("--skip-install", "Skip package manager install").option("--skip-git", "Skip git initialization").action((name, options) => {
2122
2203
  render(/* @__PURE__ */ jsx17(App, { command: "create" /* CREATE */, initialName: name, options }));
2123
2204
  });
2124
- program.command("scaffold" /* SCAFFOLD */).description("Scaffold a local project from an existing Extension").option("--extension-port <port>", "Extension dev server port (default: 5173)").option("--preview-port <port>", "Preview host dev server port").option("--skip-install", "Skip package manager install").option("--skip-git", "Skip git initialization").action((options) => {
2205
+ program.command("scaffold" /* SCAFFOLD */).description("Scaffold a local project from an existing Extension").option("--extension-port <port>", "Extension dev server port (default: 5173)").option("--preview-port <port>", "Preview dev server port").option("--skip-install", "Skip package manager install").option("--skip-git", "Skip git initialization").action((options) => {
2125
2206
  render(/* @__PURE__ */ jsx17(App, { command: "scaffold" /* SCAFFOLD */, options }));
2126
2207
  });
2127
- program.command("update" /* UPDATE */).description("Update an existing Extension").argument("[extensionId]", "Extension ID to update").option("--app-id <id>", "Skip app selection").option("--name <name>", "New extension name").option("--targets <targets>", "Comma-separated target slots (validated against app)").option("--bundle-url <url>", "New bundle URL").option("--enabled <bool>", "Enable/disable extension").option("--set-version <version>", "Explicit version (skips auto-compute)").action((extensionId, options) => {
2208
+ program.command("update" /* UPDATE */).description("Update an existing Extension").argument("[extensionId]", "Extension ID to update").option("--app-id <id>", "Skip App selection").option("--name <name>", "New Extension name").option("--targets <targets>", "Comma-separated target slots (validated against app)").option("--bundle-url <url>", "New bundle URL").option("--enabled <bool>", "Enable/disable Extension").option("--set-version <version>", "Explicit version (skips auto-compute)").action((extensionId, options) => {
2128
2209
  render(/* @__PURE__ */ jsx17(App, { command: "update" /* UPDATE */, initialExtensionId: extensionId, options }));
2129
2210
  });
2130
- program.command("dev" /* DEV */).description("Start dev servers with a public tunnel").option("--dir <path>", "Project root (default: cwd)").option("--extension-port <port>", "Override extension port").option("--preview-port <port>", "Override preview port").option("--no-tunnel", "Skip tunnel, just run vite dev").option("--no-update", "Skip bundleUrl update on server").option("--no-restore", "On exit, keep tunnel URL as bundleUrl").action((options) => {
2131
- render(/* @__PURE__ */ jsx17(DevApp, { options }));
2211
+ program.command("dev" /* DEV */).description("Start dev servers with a public tunnel").option("--dir <path>", "Project root (default: cwd)").option("--extension-port <port>", "Override Extension port").option("--preview-port <port>", "Override Preview port").option("--no-tunnel", "Skip tunnel, just run vite dev").option("--no-update", "Skip bundleUrl update on server").option("--no-restore", "On exit, keep tunnel URL as bundleUrl").action((options) => {
2212
+ render(/* @__PURE__ */ jsx17(DevApp, { options }), { exitOnCtrlC: false });
2132
2213
  });
2133
2214
  program.parse(process.argv.filter((arg) => arg !== "--"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stackable-labs/cli-app-extension",
3
- "version": "1.7.2",
3
+ "version": "1.9.0",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "bin": {