@stackable-labs/cli-app-extension 2.21.1 → 2.23.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 +35 -7
  2. package/dist/index.js +247 -136
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -167,28 +167,56 @@ The `dev` command:
167
167
 
168
168
  ### Host App Override
169
169
 
170
- The dashboard displays a query param like:
170
+ The dashboard displays one or two copyable query params you can append to your host app URL to override the extension's `bundleUrl` for that browser session only. The `@stackable-labs/embeddables` SDK detects the param and loads from your tunnel instead of the production bundle. No DB changes, no shared state — each developer gets isolated overrides.
171
+
172
+ The CLI emits one of two formats depending on whether the dev session token request succeeded:
173
+
174
+ **Blob format (default — when authenticated):** the second half of `<extensionId>:<value>` is a `base64url`-encoded JSON `{url, token}` blob. The token is short-lived, signed, and verified by the host. Required for `_stackable_staging` mode.
171
175
 
172
176
  ```
173
- ?_stackable_dev=ext-123%3Ahttps%3A%2F%2Fabc.trycloudflare.com
177
+ ?_stackable_dev=ext-123:eyJ1cmwiOiJodHRwczovL2FiYy50cnljbG91ZGZsYXJlLmNvbSIsInRva2VuIjoiZXlKaGJHY2lPaUpJVXp...
174
178
  ```
175
179
 
176
- Append this to your deployed host app URL to override the extension's `bundleUrl` in your browser session only. The `@stackable-labs/embeddables` SDK detects this param and loads from your tunnel instead of the production bundle. No DB changes, no shared state — each developer gets isolated overrides.
180
+ The dashboard also surfaces the staging variant when applicable:
181
+
182
+ ```
183
+ ?_stackable_staging=ext-123:eyJ1cmwiOiJodHRwczovL2FiYy50cnljbG91ZGZsYXJlLmNvbSIsInRva2VuIjoiZXlKaGJHY2lPaUpJVXp...
184
+ ```
185
+
186
+ The `_stackable_staging` variant signals the host to apply staging-environment-specific handling and **always requires the token** — there is no legacy fallback. If you don't see a Staging Param in the dashboard, the dev session token request failed; check `auth status` and re-run.
187
+
188
+ **Legacy format (fallback — when no token):** if `requestDevSessionToken` fails (auth misconfigured, network error, etc.), the dashboard falls back to a token-less `<extensionId>:<tunnelUrl>` form for `_stackable_dev` only. The host's parser accepts this format too but cannot apply staging-mode overrides without the token.
189
+
190
+ ```
191
+ ?_stackable_dev=ext-123:https://abc.trycloudflare.com
192
+ ```
177
193
 
178
194
  #### Multiple Extensions
179
195
 
180
- To override multiple extensions at once, use either syntax:
196
+ To override multiple extensions at once, use either syntax. Both blob entries and legacy entries can be mixed in the same comma-joined string — the parser tries each entry independently.
197
+
198
+ **Comma-separated (single param) — preferred:**
199
+ ```
200
+ ?_stackable_dev=ext-123:eyJ1cmwi...,ext-456:eyJ1cmwi...
201
+ ```
181
202
 
182
- **Comma-separated (single param):**
203
+ Mixed blob + legacy in one string also works (e.g. when one extension authenticated and another didn't):
183
204
  ```
184
- ?_stackable_dev=ext-123:https://abc.trycloudflare.com,ext-456:https://def.trycloudflare.com
205
+ ?_stackable_dev=ext-123:eyJ1cmwi...,ext-456:https://def.trycloudflare.com
185
206
  ```
186
207
 
187
208
  **Repeated params:**
188
209
  ```
189
- ?_stackable_dev=ext-123:https://abc.trycloudflare.com&_stackable_dev=ext-456:https://def.trycloudflare.com
210
+ ?_stackable_dev=ext-123:eyJ1cmwi...&_stackable_dev=ext-456:eyJ1cmwi...
190
211
  ```
191
212
 
213
+ The combined staging variant works the same way (blob-only, since staging requires the token):
214
+ ```
215
+ ?_stackable_staging=ext-123:eyJ1cmwi...,ext-456:eyJ1cmwi...
216
+ ```
217
+
218
+ The `stackable-extensions` repo's `pnpm preview:all` orchestrator (which spawns one `cli-app-extension dev --headless` per extension under `--only` / `--except` filters) emits both combined lines automatically in its summary footer — `?_stackable_dev=...` always, `?_stackable_staging=...` only when at least one session got a token.
219
+
192
220
  > **Note:** The `dev` command never updates the `bundleUrl` in the database. It is purely local.
193
221
 
194
222
  ## Development Workflow
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@ import { createRequire } from 'module';
3
3
  import { program, InvalidArgumentError } from 'commander';
4
4
  import { render, useApp, Box, Text, useInput, useFocus, useFocusManager } from 'ink';
5
5
  import { SURFACE_TARGET, TEMPLATE_FLAVORS } from '@stackable-labs/sdk-extension-contracts';
6
- import { unlink, readFile, writeFile, mkdir, rm, readdir, rmdir } from 'fs/promises';
6
+ import { unlink, readFile, mkdir, writeFile, rm, readdir, rmdir } from 'fs/promises';
7
7
  import { join, dirname, relative } from 'path';
8
8
  import Spinner5 from 'ink-spinner';
9
9
  import { useState, useCallback, useEffect, useRef, useMemo } from 'react';
@@ -2947,6 +2947,157 @@ var startTunnel = async (port) => {
2947
2947
  }
2948
2948
  throw new Error(`Tunnel failed to return a valid URL after ${MAX_RETRIES} attempts`);
2949
2949
  };
2950
+
2951
+ // src/lib/devParams.ts
2952
+ var toBase64Url = (input) => btoa(input).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
2953
+ var buildDevParams = ({ extensionId, tunnelUrl, devSessionToken }) => {
2954
+ if (!tunnelUrl) {
2955
+ return {
2956
+ devEntry: null,
2957
+ devParam: null,
2958
+ stagingEntry: null,
2959
+ stagingParam: null,
2960
+ paramBlob: null
2961
+ };
2962
+ }
2963
+ if (devSessionToken) {
2964
+ const paramBlob = toBase64Url(JSON.stringify({ url: tunnelUrl, token: devSessionToken }));
2965
+ const extensionEntry = `${extensionId}:${paramBlob}`;
2966
+ return {
2967
+ paramBlob,
2968
+ devEntry: extensionEntry,
2969
+ stagingEntry: extensionEntry,
2970
+ devParam: `?_stackable_dev=${extensionEntry}`,
2971
+ stagingParam: `?_stackable_staging=${extensionEntry}`
2972
+ };
2973
+ }
2974
+ const devEntry = `${extensionId}:${tunnelUrl}`;
2975
+ return {
2976
+ paramBlob: null,
2977
+ devEntry,
2978
+ stagingEntry: null,
2979
+ stagingParam: null,
2980
+ devParam: `?_stackable_dev=${devEntry}`
2981
+ };
2982
+ };
2983
+
2984
+ // src/lib/runDev.ts
2985
+ var runDev = async ({ token, devContext, options, resolved }) => {
2986
+ console.log(`[dev] Saving context: appId=${resolved.appId} extensionId=${resolved.extensionId}`);
2987
+ await writeDevContext(devContext.projectRoot, {
2988
+ ...devContext,
2989
+ appId: resolved.appId,
2990
+ extensionId: resolved.extensionId,
2991
+ appName: resolved.appName || null
2992
+ });
2993
+ const manifestWarnings = [];
2994
+ if (devContext.manifest) {
2995
+ try {
2996
+ const extensions = await fetchExtensions(token, resolved.appId);
2997
+ const registryExt = extensions[resolved.extensionId];
2998
+ if (registryExt) {
2999
+ const localPerms = new Set(devContext.manifest.permissions);
3000
+ const registryPerms = new Set(registryExt.manifest.permissions);
3001
+ const missingPerms = [...localPerms].filter((p) => !registryPerms.has(p));
3002
+ const extraPerms = [...registryPerms].filter((p) => !localPerms.has(p));
3003
+ const localDomains = new Set(devContext.manifest.allowedDomains);
3004
+ const registryDomains = new Set(registryExt.manifest.allowedDomains);
3005
+ const missingDomains = [...localDomains].filter((d) => !registryDomains.has(d));
3006
+ const extraDomains = [...registryDomains].filter((d) => !localDomains.has(d));
3007
+ if (missingPerms.length > 0) {
3008
+ manifestWarnings.push(`Permissions in local but not registry: ${missingPerms.join(", ")}`);
3009
+ }
3010
+ if (extraPerms.length > 0) {
3011
+ manifestWarnings.push(`Permissions in registry but not local: ${extraPerms.join(", ")}`);
3012
+ }
3013
+ if (missingDomains.length > 0) {
3014
+ manifestWarnings.push(`Domains in local but not registry: ${missingDomains.join(", ")}`);
3015
+ }
3016
+ if (extraDomains.length > 0) {
3017
+ manifestWarnings.push(`Domains in registry but not local: ${extraDomains.join(", ")}`);
3018
+ }
3019
+ }
3020
+ } catch {
3021
+ }
3022
+ }
3023
+ const useTunnel = options.tunnel !== false;
3024
+ const extensionPort = options.extensionPort ? parseInt(options.extensionPort, 10) : devContext.extensionPort;
3025
+ const previewPort = options.previewPort ? parseInt(options.previewPort, 10) : devContext.previewPort;
3026
+ await patchViteAllowedHosts(devContext.projectRoot);
3027
+ let tunnelHandle = null;
3028
+ let previewTunnelHandle = null;
3029
+ let previewTunnelUrl = null;
3030
+ let tunnelUrl = null;
3031
+ if (useTunnel) {
3032
+ try {
3033
+ console.log(`[dev] Starting extension tunnel on port ${extensionPort}...`);
3034
+ const t = await startTunnel(extensionPort);
3035
+ console.log(`[dev] Extension tunnel ready: ${t.url}`);
3036
+ tunnelHandle = t;
3037
+ tunnelUrl = t.url;
3038
+ console.log(`[dev] Starting preview tunnel on port ${previewPort}...`);
3039
+ const pt = await startTunnel(previewPort);
3040
+ console.log(`[dev] Preview tunnel ready: ${pt.url}`);
3041
+ previewTunnelHandle = pt;
3042
+ previewTunnelUrl = pt.url;
3043
+ console.log(`[dev] Writing VITE_EXTENSION_BUNDLE_URL=${t.url} to .env`);
3044
+ await patchProjectEnv(devContext.projectRoot, "VITE_EXTENSION_BUNDLE_URL", t.url);
3045
+ } catch (err) {
3046
+ console.error("[dev] Failed to start tunnel:", err);
3047
+ }
3048
+ } else {
3049
+ console.log("[dev] Tunnel disabled (--no-tunnel)");
3050
+ }
3051
+ let devSessionToken = null;
3052
+ try {
3053
+ console.log("[dev] Requesting dev session token...");
3054
+ devSessionToken = await requestDevSessionToken(token, resolved.appId, resolved.extensionId);
3055
+ console.log("[dev] Dev session token received");
3056
+ } catch (err) {
3057
+ console.warn("[dev] Failed to obtain dev session token \u2014 dev/staging mode URLs will not include auth token:", err);
3058
+ }
3059
+ console.log(`[dev] Starting dev server in ${devContext.projectRoot}`);
3060
+ const devServerHandle = startDevServer(devContext.projectRoot);
3061
+ const params = buildDevParams({
3062
+ extensionId: resolved.extensionId,
3063
+ tunnelUrl,
3064
+ devSessionToken
3065
+ });
3066
+ console.log("[dev] Ready");
3067
+ return {
3068
+ devContext,
3069
+ extensionPort,
3070
+ previewPort,
3071
+ tunnelUrl,
3072
+ previewTunnelUrl,
3073
+ tunnelHandle,
3074
+ previewTunnelHandle,
3075
+ devServerHandle,
3076
+ devSessionToken,
3077
+ manifestWarnings,
3078
+ params
3079
+ };
3080
+ };
3081
+ var cleanupDev = async (result) => {
3082
+ console.log("[dev] Shutting down...");
3083
+ try {
3084
+ console.log("[dev] Removing VITE_EXTENSION_BUNDLE_URL from .env");
3085
+ await removeProjectEnvKey(result.devContext.projectRoot, "VITE_EXTENSION_BUNDLE_URL");
3086
+ if (result.tunnelHandle) {
3087
+ console.log("[dev] Stopping Extension tunnel");
3088
+ result.tunnelHandle.stop();
3089
+ }
3090
+ if (result.previewTunnelHandle) {
3091
+ console.log("[dev] Stopping preview tunnel");
3092
+ result.previewTunnelHandle.stop();
3093
+ }
3094
+ console.log("[dev] Stopping dev server");
3095
+ result.devServerHandle.stop();
3096
+ } catch (err) {
3097
+ console.error("[dev] Error during cleanup:", err);
3098
+ }
3099
+ console.log("[dev] Done");
3100
+ };
2950
3101
  var DevSetup = ({ initialContext, token, onReady }) => {
2951
3102
  const [step, setStep] = useState("app");
2952
3103
  const [selectedApp, setSelectedApp] = useState(null);
@@ -3043,7 +3194,6 @@ var CopyableField = ({
3043
3194
  /* @__PURE__ */ jsx(Box, { paddingLeft: 2, children: expanded ? /* @__PURE__ */ jsx(Text, { wrap: "wrap", children: value }) : children || /* @__PURE__ */ jsx(Text, { children: labelColor ? /* @__PURE__ */ jsx(Text, { color: labelColor, children: truncated }) : truncated }) })
3044
3195
  ] });
3045
3196
  };
3046
- var toBase64Url = (input) => btoa(input).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
3047
3197
  var DevDashboard = ({
3048
3198
  previewTunnelUrl,
3049
3199
  tunnelUrl,
@@ -3068,12 +3218,7 @@ var DevDashboard = ({
3068
3218
  process.off("SIGINT", handler);
3069
3219
  };
3070
3220
  }, [onQuit]);
3071
- const devParamBlob = useMemo(() => {
3072
- if (!tunnelUrl || !devSessionToken) {
3073
- return "";
3074
- }
3075
- return toBase64Url(JSON.stringify({ url: tunnelUrl, token: devSessionToken }));
3076
- }, [tunnelUrl, devSessionToken]);
3221
+ const params = useMemo(() => buildDevParams({ extensionId, tunnelUrl, devSessionToken: devSessionToken ?? null }), [extensionId, tunnelUrl, devSessionToken]);
3077
3222
  useInput((input, key) => {
3078
3223
  if (input === "q" || key.ctrl && input === "c") {
3079
3224
  onQuit();
@@ -3087,7 +3232,7 @@ var DevDashboard = ({
3087
3232
  title: "dev",
3088
3233
  hint: `Live development ${tunnelUrl ? "with" : "w/o"} Tunnel \u2014 ${appName ? `${appName} ` : ""}(${appId})`,
3089
3234
  footer: /* @__PURE__ */ jsxs(Box, { flexDirection: "column", gap: 1, children: [
3090
- devParamBlob && /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
3235
+ params.paramBlob && /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
3091
3236
  "Press ",
3092
3237
  /* @__PURE__ */ jsx(Text, { bold: true, color: "yellow", children: "[d]" }),
3093
3238
  " to copy Dev Param, ",
@@ -3142,12 +3287,12 @@ var DevDashboard = ({
3142
3287
  ] })
3143
3288
  ] })
3144
3289
  ] }),
3145
- tunnelUrl && devSessionToken && /* @__PURE__ */ jsxs(Fragment, { children: [
3290
+ params.devParam && params.stagingParam && params.paramBlob && /* @__PURE__ */ jsxs(Fragment, { children: [
3146
3291
  /* @__PURE__ */ jsx(
3147
3292
  CopyableField,
3148
3293
  {
3149
3294
  label: "Dev Param (no encryption):",
3150
- value: `?_stackable_dev=${extensionId}:${devParamBlob}`,
3295
+ value: params.devParam,
3151
3296
  shortcutKey: "d",
3152
3297
  children: /* @__PURE__ */ jsxs(Text, { children: [
3153
3298
  "?",
@@ -3155,7 +3300,7 @@ var DevDashboard = ({
3155
3300
  "=",
3156
3301
  /* @__PURE__ */ jsx(Text, { color: "cyan", children: extensionId }),
3157
3302
  ":",
3158
- devParamBlob.slice(0, 20),
3303
+ params.paramBlob.slice(0, 20),
3159
3304
  "..."
3160
3305
  ] })
3161
3306
  }
@@ -3164,7 +3309,7 @@ var DevDashboard = ({
3164
3309
  CopyableField,
3165
3310
  {
3166
3311
  label: "Staging Param (with encryption):",
3167
- value: `?_stackable_staging=${extensionId}:${devParamBlob}`,
3312
+ value: params.stagingParam,
3168
3313
  shortcutKey: "s",
3169
3314
  children: /* @__PURE__ */ jsxs(Text, { children: [
3170
3315
  "?",
@@ -3172,13 +3317,13 @@ var DevDashboard = ({
3172
3317
  "=",
3173
3318
  /* @__PURE__ */ jsx(Text, { color: "cyan", children: extensionId }),
3174
3319
  ":",
3175
- devParamBlob.slice(0, 20),
3320
+ params.paramBlob.slice(0, 20),
3176
3321
  "..."
3177
3322
  ] })
3178
3323
  }
3179
3324
  )
3180
3325
  ] }),
3181
- tunnelUrl && !devSessionToken && /* @__PURE__ */ jsxs(Box, { gap: 2, children: [
3326
+ params.devParam && !params.paramBlob && /* @__PURE__ */ jsxs(Box, { gap: 2, children: [
3182
3327
  /* @__PURE__ */ jsx(Text, { dimColor: true, children: "Host Dev - Query Param:" }),
3183
3328
  /* @__PURE__ */ jsxs(Text, { children: [
3184
3329
  "?",
@@ -3206,15 +3351,8 @@ var DevApp = ({ token, userId, orgId, options = {} }) => {
3206
3351
  const [state, setState] = useState("setup");
3207
3352
  const [devContext, setDevContext] = useState(null);
3208
3353
  const [resolvedContext, setResolvedContext] = useState(null);
3209
- const [tunnelUrl, setTunnelUrl] = useState(null);
3210
- const [previewTunnelUrl, setPreviewTunnelUrl] = useState(null);
3211
- const [tunnelHandle, setTunnelHandle] = useState(null);
3212
- const [previewTunnelHandle, setPreviewTunnelHandle] = useState(null);
3213
- const [devServerHandle, setDevServerHandle] = useState(null);
3214
- const [manifestWarnings, setManifestWarnings] = useState([]);
3215
- const [devSessionToken, setDevSessionToken] = useState(null);
3354
+ const [runResult, setRunResult] = useState(null);
3216
3355
  const shuttingDown = useRef(false);
3217
- const useTunnel = options.tunnel !== false;
3218
3356
  useEffect(() => {
3219
3357
  const projectRoot = options.dir || process.cwd();
3220
3358
  console.log(`[dev] Reading context from ${projectRoot}`);
@@ -3228,83 +3366,10 @@ var DevApp = ({ token, userId, orgId, options = {} }) => {
3228
3366
  return;
3229
3367
  }
3230
3368
  setResolvedContext(resolved);
3231
- console.log(`[dev] Saving context: appId=${resolved.appId} extensionId=${resolved.extensionId}`);
3232
- await writeDevContext(devContext.projectRoot, {
3233
- ...devContext,
3234
- appId: resolved.appId,
3235
- extensionId: resolved.extensionId,
3236
- appName: resolved.appName || null
3237
- });
3238
- if (devContext.manifest) {
3239
- try {
3240
- const extensions = await fetchExtensions(token, resolved.appId);
3241
- const registryExt = extensions[resolved.extensionId];
3242
- if (registryExt) {
3243
- const localPerms = new Set(devContext.manifest.permissions);
3244
- const registryPerms = new Set(registryExt.manifest.permissions);
3245
- const missingPerms = [...localPerms].filter((p) => !registryPerms.has(p));
3246
- const extraPerms = [...registryPerms].filter((p) => !localPerms.has(p));
3247
- const localDomains = new Set(devContext.manifest.allowedDomains);
3248
- const registryDomains = new Set(registryExt.manifest.allowedDomains);
3249
- const missingDomains = [...localDomains].filter((d) => !registryDomains.has(d));
3250
- const extraDomains = [...registryDomains].filter((d) => !localDomains.has(d));
3251
- const warnings = [];
3252
- if (missingPerms.length > 0) {
3253
- warnings.push(`Permissions in local but not registry: ${missingPerms.join(", ")}`);
3254
- }
3255
- if (extraPerms.length > 0) {
3256
- warnings.push(`Permissions in registry but not local: ${extraPerms.join(", ")}`);
3257
- }
3258
- if (missingDomains.length > 0) {
3259
- warnings.push(`Domains in local but not registry: ${missingDomains.join(", ")}`);
3260
- }
3261
- if (extraDomains.length > 0) {
3262
- warnings.push(`Domains in registry but not local: ${extraDomains.join(", ")}`);
3263
- }
3264
- if (warnings.length > 0) {
3265
- setManifestWarnings(warnings);
3266
- }
3267
- }
3268
- } catch {
3269
- }
3270
- }
3271
- const extensionPort = options.extensionPort ? parseInt(options.extensionPort, 10) : devContext.extensionPort;
3272
- const previewPort = options.previewPort ? parseInt(options.previewPort, 10) : devContext.previewPort;
3273
- await patchViteAllowedHosts(devContext.projectRoot);
3274
- if (useTunnel) {
3275
- try {
3276
- console.log(`[dev] Starting extension tunnel on port ${extensionPort}...`);
3277
- const tunnelResult = await startTunnel(extensionPort);
3278
- console.log(`[dev] Extension tunnel ready: ${tunnelResult.url}`);
3279
- setTunnelHandle(tunnelResult);
3280
- setTunnelUrl(tunnelResult.url);
3281
- console.log(`[dev] Starting preview tunnel on port ${previewPort}...`);
3282
- const previewTunnelResult = await startTunnel(previewPort);
3283
- console.log(`[dev] Preview tunnel ready: ${previewTunnelResult.url}`);
3284
- setPreviewTunnelHandle(previewTunnelResult);
3285
- setPreviewTunnelUrl(previewTunnelResult.url);
3286
- console.log(`[dev] Writing VITE_EXTENSION_BUNDLE_URL=${tunnelResult.url} to .env`);
3287
- await patchProjectEnv(devContext.projectRoot, "VITE_EXTENSION_BUNDLE_URL", tunnelResult.url);
3288
- } catch (err) {
3289
- console.error("[dev] Failed to start tunnel:", err);
3290
- }
3291
- } else {
3292
- console.log("[dev] Tunnel disabled (--no-tunnel)");
3293
- }
3294
- try {
3295
- console.log("[dev] Requesting dev session token...");
3296
- const sessionToken = await requestDevSessionToken(token, resolved.appId, resolved.extensionId);
3297
- console.log("[dev] Dev session token received");
3298
- setDevSessionToken(sessionToken);
3299
- } catch (err) {
3300
- console.warn("[dev] Failed to obtain dev session token \u2014 dev/staging mode URLs will not include auth token:", err);
3301
- }
3302
- console.log(`[dev] Starting dev server in ${devContext.projectRoot}`);
3303
- const serverHandle = startDevServer(devContext.projectRoot);
3304
- setDevServerHandle(serverHandle);
3305
- console.log("[dev] Ready");
3369
+ const result = await runDev({ token, devContext, options, resolved });
3370
+ setRunResult(result);
3306
3371
  setState("running");
3307
- }, [devContext, token, options.extensionPort, options.previewPort, useTunnel]);
3372
+ }, [devContext, token, options]);
3308
3373
  useEffect(() => {
3309
3374
  if (state === "setup" && devContext && devContext.appId && devContext.extensionId) {
3310
3375
  handleSetupReady({
@@ -3315,31 +3380,12 @@ var DevApp = ({ token, userId, orgId, options = {} }) => {
3315
3380
  }
3316
3381
  }, [state, devContext, handleSetupReady]);
3317
3382
  const handleQuit = async () => {
3318
- if (!devContext || !resolvedContext || shuttingDown.current) {
3383
+ if (!runResult || shuttingDown.current) {
3319
3384
  return;
3320
3385
  }
3321
3386
  shuttingDown.current = true;
3322
3387
  setState("stopping");
3323
- console.log("[dev] Shutting down...");
3324
- try {
3325
- console.log("[dev] Removing VITE_EXTENSION_BUNDLE_URL from .env");
3326
- await removeProjectEnvKey(devContext.projectRoot, "VITE_EXTENSION_BUNDLE_URL");
3327
- if (tunnelHandle) {
3328
- console.log("[dev] Stopping Extension tunnel");
3329
- tunnelHandle.stop();
3330
- }
3331
- if (previewTunnelHandle) {
3332
- console.log("[dev] Stopping preview tunnel");
3333
- previewTunnelHandle.stop();
3334
- }
3335
- if (devServerHandle) {
3336
- console.log("[dev] Stopping dev server");
3337
- devServerHandle.stop();
3338
- }
3339
- } catch (err) {
3340
- console.error("[dev] Error during cleanup:", err);
3341
- }
3342
- console.log("[dev] Done");
3388
+ await cleanupDev(runResult);
3343
3389
  process.exit(0);
3344
3390
  };
3345
3391
  useInput((input, key) => {
@@ -3364,22 +3410,22 @@ var DevApp = ({ token, userId, orgId, options = {} }) => {
3364
3410
  }
3365
3411
  return null;
3366
3412
  }
3367
- if (state === "running" && devContext && resolvedContext) {
3413
+ if (state === "running" && devContext && resolvedContext && runResult) {
3368
3414
  return /* @__PURE__ */ jsx(
3369
3415
  DevDashboard,
3370
3416
  {
3371
- previewTunnelUrl,
3372
- tunnelUrl,
3417
+ previewTunnelUrl: runResult.previewTunnelUrl,
3418
+ tunnelUrl: runResult.tunnelUrl,
3373
3419
  orgId,
3374
3420
  userId,
3375
3421
  appId: resolvedContext.appId,
3376
3422
  appName: resolvedContext.appName,
3377
3423
  extensionName: devContext.extensionName,
3378
3424
  extensionId: resolvedContext.extensionId,
3379
- extensionPort: options.extensionPort ? parseInt(options.extensionPort, 10) : devContext.extensionPort,
3380
- previewPort: options.previewPort ? parseInt(options.previewPort, 10) : devContext.previewPort,
3381
- manifestWarnings,
3382
- devSessionToken,
3425
+ extensionPort: runResult.extensionPort,
3426
+ previewPort: runResult.previewPort,
3427
+ manifestWarnings: runResult.manifestWarnings,
3428
+ devSessionToken: runResult.devSessionToken,
3383
3429
  onQuit: handleQuit
3384
3430
  }
3385
3431
  );
@@ -3960,8 +4006,7 @@ var ensureToken = async () => {
3960
4006
  } catch (err) {
3961
4007
  const message = err instanceof Error ? err.message : String(err);
3962
4008
  const isExpired = message.toLowerCase().includes("expired");
3963
- let recoveredState = null;
3964
- let flowError = null;
4009
+ const ref = { state: null, error: null };
3965
4010
  const instance = render(
3966
4011
  /* @__PURE__ */ jsx(
3967
4012
  AuthLogin,
@@ -3970,25 +4015,86 @@ var ensureToken = async () => {
3970
4015
  adminApiBaseUrl: getAdminApiBaseUrl(),
3971
4016
  reason: isExpired ? AUTH_LOGIN_REASON.EXPIRED : AUTH_LOGIN_REASON.NOT_LOGGED_IN,
3972
4017
  onSuccess: (authState) => {
3973
- recoveredState = authState;
4018
+ ref.state = authState;
3974
4019
  },
3975
4020
  onError: (err2) => {
3976
- flowError = err2;
4021
+ ref.error = err2;
3977
4022
  }
3978
4023
  }
3979
4024
  )
3980
4025
  );
3981
4026
  await instance.waitUntilExit();
3982
- if (flowError || !recoveredState) {
4027
+ if (ref.error || !ref.state) {
3983
4028
  return null;
3984
4029
  }
3985
4030
  return {
3986
- token: recoveredState.token,
3987
- userId: recoveredState.userId,
3988
- orgId: recoveredState.orgId
4031
+ token: ref.state.token,
4032
+ userId: ref.state.userId,
4033
+ orgId: ref.state.orgId
3989
4034
  };
3990
4035
  }
3991
4036
  };
4037
+ var ensureTokenOrFail = async () => {
4038
+ try {
4039
+ const token = await getToken();
4040
+ const state = await readAuthState();
4041
+ if (!state) {
4042
+ throw new Error("not authenticated");
4043
+ }
4044
+ return { token, userId: state.userId, orgId: state.orgId };
4045
+ } catch (err) {
4046
+ const message = err instanceof Error ? err.message : String(err);
4047
+ console.error(`[dev] Authentication required: ${message}`);
4048
+ console.error("[dev] Run `stackable-app-extension auth login` interactively first, then re-run with --headless.");
4049
+ process.exit(1);
4050
+ }
4051
+ };
4052
+ var runHeadlessDev = async (options) => {
4053
+ const auth2 = await ensureTokenOrFail();
4054
+ const projectRoot = options.dir || process.cwd();
4055
+ console.log(`[dev] Reading context from ${projectRoot}`);
4056
+ const devContext = await readDevContext(projectRoot);
4057
+ console.log(
4058
+ `[dev] Context loaded: extension="${devContext.extensionName}" appId=${devContext.appId ?? "(missing)"} extensionId=${devContext.extensionId ?? "(missing)"}`
4059
+ );
4060
+ if (!devContext.appId || !devContext.extensionId) {
4061
+ console.error("[dev] No appId/extensionId in .env.stackable.");
4062
+ console.error("[dev] Run `pnpm preview` interactively once to configure this extension, then re-run with --headless.");
4063
+ process.exit(1);
4064
+ }
4065
+ const result = await runDev({
4066
+ token: auth2.token,
4067
+ devContext,
4068
+ options,
4069
+ resolved: {
4070
+ appId: devContext.appId,
4071
+ extensionId: devContext.extensionId,
4072
+ appName: devContext.appName || void 0
4073
+ }
4074
+ });
4075
+ if (result.params.devEntry) {
4076
+ console.log(`[dev] dev-entry: ${result.params.devEntry}`);
4077
+ }
4078
+ if (result.params.devParam) {
4079
+ console.log(`[dev] dev-param: ${result.params.devParam}`);
4080
+ }
4081
+ if (result.params.stagingEntry) {
4082
+ console.log(`[dev] staging-entry: ${result.params.stagingEntry}`);
4083
+ }
4084
+ if (result.params.stagingParam) {
4085
+ console.log(`[dev] staging-param: ${result.params.stagingParam}`);
4086
+ }
4087
+ for (const warning of result.manifestWarnings) {
4088
+ console.warn(`[dev] manifest warning: ${warning}`);
4089
+ }
4090
+ console.log("[dev] Press Ctrl+C to stop");
4091
+ await new Promise((resolve) => {
4092
+ process.once("SIGTERM", () => resolve());
4093
+ process.once("SIGINT", () => resolve());
4094
+ });
4095
+ await cleanupDev(result);
4096
+ process.exit(0);
4097
+ };
3992
4098
  program.name("stackable-app-extension").description("Stackable Labs - App Extension developer CLI").version(version);
3993
4099
  program.command("create" /* CREATE */).description("Create a new Extension project").argument("[name]", "Extension project name").option("--app-id <id>", "Skip App selection").option("--project-id <id>", "Studio project ID (fetches files/manifest from Studio)").option("--template <flavor>", `Template flavor: ${TEMPLATE_FLAVORS.join(", ")}`, parseTemplateFlavor).option("--extension-port <port>", "Extension dev server port (default: 6543)").option("--preview-port <port>", "Preview dev server port").option("--skip-install", "Skip package manager install").option("--skip-git", "Skip git initialization").action(async (name, options) => {
3994
4100
  const auth2 = await ensureToken();
@@ -4050,7 +4156,12 @@ program.command("update" /* UPDATE */).description("Update an existing Extension
4050
4156
  )
4051
4157
  );
4052
4158
  });
4053
- 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").action(async (options) => {
4159
+ 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("--headless", "Skip interactive dashboard (auto-enabled when stdin is not a TTY)").action(async (options) => {
4160
+ const headless = options.headless || !process.stdin.isTTY;
4161
+ if (headless) {
4162
+ await runHeadlessDev(options);
4163
+ return;
4164
+ }
4054
4165
  const auth2 = await ensureToken();
4055
4166
  if (!auth2) {
4056
4167
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stackable-labs/cli-app-extension",
3
- "version": "2.21.1",
3
+ "version": "2.23.0",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "bin": {