@stackable-labs/cli-app-extension 1.0.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.
package/LICENSE ADDED
@@ -0,0 +1,29 @@
1
+ LICENSE
2
+
3
+ Copyright (c) 2026-present UNIQUELY PARTICULAR LLC, STACKABLE LABS, LLC, agnoStack, Inc., and Adam Grohs ("Author" herein)
4
+
5
+ Permission is hereby granted to use, copy, and modify this software
6
+ (the "Software") solely for the purpose of developing, testing,
7
+ or maintaining integration with Author's products and services.
8
+
9
+ The Software may not be used:
10
+ - as a standalone product or service,
11
+ - to integrate with any platform or service other than Author's,
12
+ - to build or enhance a competing product or service,
13
+ - or for any purpose unrelated to integrations with Author.
14
+
15
+ Redistribution of the Software, in whole or in part, is permitted
16
+ only as part of an application or service that integrates with Author
17
+ and does not expose the Software as a general-purpose library.
18
+
19
+ This Software is provided "AS IS", without warranty of any kind, express
20
+ or implied, including but not limited to the warranties of merchantability,
21
+ fitness for a particular purpose, and noninfringement.
22
+
23
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26
+ PUBLISHER, AUTHORS, ANY CONTRIBUTOR OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
27
+ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT
28
+ OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
29
+ USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,9 @@
1
+ # @stackable-labs/cli-app-extension
2
+
3
+ CLI for scaffolding Stackable extension projects from the local `extension-template` package.
4
+
5
+ ## Development
6
+
7
+ ```bash
8
+ pnpm --filter @stackable-labs/cli-app-extension build
9
+ ```
package/dist/index.js ADDED
@@ -0,0 +1,862 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.tsx
4
+ import { program } from "commander";
5
+ import { render } from "ink";
6
+
7
+ // src/App.tsx
8
+ import { Box as Box9, Text as Text9, useApp } from "ink";
9
+ import { useCallback, useState as useState6 } from "react";
10
+
11
+ // src/components/Confirm.tsx
12
+ import { Box, Text, useInput } from "ink";
13
+ import { jsx, jsxs } from "react/jsx-runtime";
14
+ function Confirm({ name, extensionId, extensionPort, previewPort, targets, outputDir, onConfirm, onCancel }) {
15
+ useInput((input, key) => {
16
+ if (input === "y" || key.return) {
17
+ onConfirm();
18
+ return;
19
+ }
20
+ if (input === "n" || key.escape) {
21
+ onCancel();
22
+ }
23
+ });
24
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", gap: 1, children: [
25
+ /* @__PURE__ */ jsx(Text, { bold: true, children: "Ready to scaffold" }),
26
+ /* @__PURE__ */ jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [
27
+ /* @__PURE__ */ jsxs(Box, { gap: 2, children: [
28
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: "Name" }),
29
+ /* @__PURE__ */ jsx(Text, { children: name })
30
+ ] }),
31
+ /* @__PURE__ */ jsxs(Box, { gap: 2, children: [
32
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: "ID " }),
33
+ /* @__PURE__ */ jsx(Text, { children: extensionId })
34
+ ] }),
35
+ /* @__PURE__ */ jsxs(Box, { gap: 2, children: [
36
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: "Dir " }),
37
+ /* @__PURE__ */ jsx(Text, { children: outputDir })
38
+ ] }),
39
+ /* @__PURE__ */ jsxs(Box, { gap: 2, children: [
40
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: "Extension port" }),
41
+ /* @__PURE__ */ jsx(Text, { children: extensionPort })
42
+ ] }),
43
+ /* @__PURE__ */ jsxs(Box, { gap: 2, children: [
44
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: "Preview port " }),
45
+ /* @__PURE__ */ jsx(Text, { children: previewPort })
46
+ ] }),
47
+ /* @__PURE__ */ jsxs(Box, { gap: 2, flexDirection: "column", children: [
48
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: "Targets" }),
49
+ targets.map((t) => /* @__PURE__ */ jsxs(Box, { marginLeft: 2, children: [
50
+ /* @__PURE__ */ jsx(Text, { color: "green", children: "\u2022 " }),
51
+ /* @__PURE__ */ jsx(Text, { children: t })
52
+ ] }, t))
53
+ ] })
54
+ ] }),
55
+ /* @__PURE__ */ jsxs(Text, { children: [
56
+ "Proceed? ",
57
+ /* @__PURE__ */ jsx(Text, { bold: true, color: "green", children: "Y" }),
58
+ "/",
59
+ /* @__PURE__ */ jsx(Text, { bold: true, color: "red", children: "n" })
60
+ ] })
61
+ ] });
62
+ }
63
+
64
+ // src/components/DirPrompt.tsx
65
+ import { Box as Box2, Text as Text2 } from "ink";
66
+ import TextInput from "ink-text-input";
67
+ import { useState } from "react";
68
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
69
+ function DirPrompt({ defaultDir, onSubmit }) {
70
+ const [value, setValue] = useState(defaultDir);
71
+ const handleSubmit = (val) => {
72
+ const trimmed = val.trim();
73
+ if (trimmed.length === 0) {
74
+ return;
75
+ }
76
+ onSubmit(trimmed);
77
+ };
78
+ return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", gap: 1, children: [
79
+ /* @__PURE__ */ jsx2(Text2, { bold: true, children: "Output directory:" }),
80
+ /* @__PURE__ */ jsxs2(Box2, { children: [
81
+ /* @__PURE__ */ jsx2(Text2, { color: "cyan", children: "\u2192 " }),
82
+ /* @__PURE__ */ jsx2(TextInput, { value, onChange: setValue, onSubmit: handleSubmit })
83
+ ] }),
84
+ /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: "(Press Enter to confirm)" })
85
+ ] });
86
+ }
87
+
88
+ // src/components/Done.tsx
89
+ import { Box as Box3, Text as Text3 } from "ink";
90
+ import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
91
+ function Done({ name, outputDir }) {
92
+ return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", gap: 1, children: [
93
+ /* @__PURE__ */ jsxs3(Box3, { gap: 1, children: [
94
+ /* @__PURE__ */ jsx3(Text3, { color: "green", bold: true, children: "\u2714" }),
95
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: "Extension scaffolded successfully!" })
96
+ ] }),
97
+ /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", marginLeft: 2, children: [
98
+ /* @__PURE__ */ jsxs3(Text3, { dimColor: true, children: [
99
+ "Created: ",
100
+ /* @__PURE__ */ jsx3(Text3, { color: "cyan", children: name })
101
+ ] }),
102
+ /* @__PURE__ */ jsxs3(Text3, { dimColor: true, children: [
103
+ "Location: ",
104
+ /* @__PURE__ */ jsx3(Text3, { color: "cyan", children: outputDir })
105
+ ] })
106
+ ] }),
107
+ /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", marginTop: 1, borderStyle: "round", borderColor: "green", paddingX: 2, paddingY: 1, children: [
108
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: "Next steps:" }),
109
+ /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", marginTop: 1, gap: 1, children: [
110
+ /* @__PURE__ */ jsxs3(Box3, { gap: 1, children: [
111
+ /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: "1." }),
112
+ /* @__PURE__ */ jsxs3(Text3, { children: [
113
+ /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: "cd " }),
114
+ /* @__PURE__ */ jsx3(Text3, { color: "cyan", children: outputDir })
115
+ ] })
116
+ ] }),
117
+ /* @__PURE__ */ jsxs3(Box3, { gap: 1, children: [
118
+ /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: "2." }),
119
+ /* @__PURE__ */ jsx3(Text3, { color: "cyan", children: "pnpm install" })
120
+ ] }),
121
+ /* @__PURE__ */ jsxs3(Box3, { gap: 1, children: [
122
+ /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: "3." }),
123
+ /* @__PURE__ */ jsx3(Text3, { color: "cyan", children: "pnpm dev" })
124
+ ] })
125
+ ] })
126
+ ] })
127
+ ] });
128
+ }
129
+
130
+ // src/components/IdPrompt.tsx
131
+ import { Box as Box4, Text as Text4 } from "ink";
132
+ import TextInput2 from "ink-text-input";
133
+ import { useEffect, useState as useState2 } from "react";
134
+ import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
135
+ function toKebabCase(value) {
136
+ return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
137
+ }
138
+ function IdPrompt({ extensionName, onSubmit }) {
139
+ const derived = toKebabCase(extensionName);
140
+ const [value, setValue] = useState2(derived);
141
+ const [error, setError] = useState2();
142
+ useEffect(() => {
143
+ setValue(toKebabCase(extensionName));
144
+ }, [extensionName]);
145
+ const handleSubmit = (val) => {
146
+ const trimmed = val.trim() || derived;
147
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(trimmed)) {
148
+ setError("Extension ID must be lowercase kebab-case (letters, numbers, hyphens only)");
149
+ return;
150
+ }
151
+ setError(void 0);
152
+ onSubmit(trimmed);
153
+ };
154
+ return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", gap: 1, children: [
155
+ /* @__PURE__ */ jsx4(Text4, { bold: true, children: "Extension ID" }),
156
+ /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "Used as the unique identifier in the manifest. Press Enter to accept the default." }),
157
+ /* @__PURE__ */ jsxs4(Box4, { children: [
158
+ /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "> " }),
159
+ /* @__PURE__ */ jsx4(TextInput2, { value, onChange: setValue, onSubmit: handleSubmit })
160
+ ] }),
161
+ error && /* @__PURE__ */ jsx4(Text4, { color: "red", children: error })
162
+ ] });
163
+ }
164
+
165
+ // src/components/NamePrompt.tsx
166
+ import { Box as Box5, Text as Text5 } from "ink";
167
+ import TextInput3 from "ink-text-input";
168
+ import { useState as useState3 } from "react";
169
+ import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
170
+ function NamePrompt({ initialValue = "", onSubmit }) {
171
+ const [value, setValue] = useState3(initialValue);
172
+ const [error, setError] = useState3();
173
+ const handleSubmit = (val) => {
174
+ const trimmed = val.trim();
175
+ if (trimmed.length === 0) {
176
+ setError("Extension name is required");
177
+ return;
178
+ }
179
+ setError(void 0);
180
+ onSubmit(trimmed);
181
+ };
182
+ return /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", gap: 1, children: [
183
+ /* @__PURE__ */ jsx5(Text5, { bold: true, children: "What is your extension name?" }),
184
+ /* @__PURE__ */ jsxs5(Box5, { children: [
185
+ /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: "> " }),
186
+ /* @__PURE__ */ jsx5(TextInput3, { value, onChange: setValue, onSubmit: handleSubmit })
187
+ ] }),
188
+ error && /* @__PURE__ */ jsx5(Text5, { color: "red", children: error })
189
+ ] });
190
+ }
191
+
192
+ // src/components/PortsPrompt.tsx
193
+ import { Box as Box6, Text as Text6 } from "ink";
194
+ import TextInput4 from "ink-text-input";
195
+ import { useState as useState4 } from "react";
196
+ import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
197
+ function PortsPrompt({ onSubmit }) {
198
+ const [extensionPort, setExtensionPort] = useState4("5173");
199
+ const [previewPort, setPreviewPort] = useState4("");
200
+ const [step, setStep] = useState4("extension");
201
+ const handleExtensionSubmit = (value) => {
202
+ const trimmed = value.trim();
203
+ const port = trimmed === "" ? 5173 : parseInt(trimmed, 10);
204
+ if (isNaN(port) || port < 1024 || port > 65535) {
205
+ return;
206
+ }
207
+ setExtensionPort(String(port));
208
+ setPreviewPort(String(port + 1));
209
+ setStep("preview");
210
+ };
211
+ const handlePreviewSubmit = (value) => {
212
+ const extPort = parseInt(extensionPort, 10);
213
+ const trimmed = value.trim();
214
+ const prevPort = trimmed === "" ? extPort + 1 : parseInt(trimmed, 10);
215
+ if (isNaN(prevPort) || prevPort < 1024 || prevPort > 65535) {
216
+ return;
217
+ }
218
+ onSubmit(extPort, prevPort);
219
+ };
220
+ if (step === "extension") {
221
+ return /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", gap: 1, children: [
222
+ /* @__PURE__ */ jsx6(Text6, { bold: true, children: "Extension dev server port:" }),
223
+ /* @__PURE__ */ jsxs6(Box6, { children: [
224
+ /* @__PURE__ */ jsx6(Text6, { color: "cyan", children: "\u2192 " }),
225
+ /* @__PURE__ */ jsx6(
226
+ TextInput4,
227
+ {
228
+ value: extensionPort,
229
+ onChange: setExtensionPort,
230
+ onSubmit: handleExtensionSubmit,
231
+ placeholder: "5173"
232
+ }
233
+ )
234
+ ] }),
235
+ /* @__PURE__ */ jsx6(Text6, { dimColor: true, children: "(Press Enter to use default: 5173)" })
236
+ ] });
237
+ }
238
+ return /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", gap: 1, children: [
239
+ /* @__PURE__ */ jsx6(Text6, { bold: true, children: "Preview host dev server port:" }),
240
+ /* @__PURE__ */ jsxs6(Box6, { children: [
241
+ /* @__PURE__ */ jsx6(Text6, { color: "cyan", children: "\u2192 " }),
242
+ /* @__PURE__ */ jsx6(
243
+ TextInput4,
244
+ {
245
+ value: previewPort,
246
+ onChange: setPreviewPort,
247
+ onSubmit: handlePreviewSubmit,
248
+ placeholder: String(parseInt(extensionPort, 10) + 1)
249
+ }
250
+ )
251
+ ] }),
252
+ /* @__PURE__ */ jsxs6(Text6, { dimColor: true, children: [
253
+ "(Press Enter to use: ",
254
+ parseInt(extensionPort, 10) + 1,
255
+ ")"
256
+ ] })
257
+ ] });
258
+ }
259
+
260
+ // src/components/ScaffoldProgress.tsx
261
+ import { Box as Box7, Text as Text7 } from "ink";
262
+ import Spinner from "ink-spinner";
263
+ import { jsx as jsx7, jsxs as jsxs7 } from "react/jsx-runtime";
264
+ function stepIcon(status) {
265
+ switch (status) {
266
+ case "running":
267
+ return /* @__PURE__ */ jsx7(Spinner, { type: "dots" });
268
+ case "done":
269
+ return /* @__PURE__ */ jsx7(Text7, { color: "green", children: "\u2714" });
270
+ case "error":
271
+ return /* @__PURE__ */ jsx7(Text7, { color: "red", children: "\u2716" });
272
+ default:
273
+ return /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: "\u25CB" });
274
+ }
275
+ }
276
+ function ScaffoldProgress({ steps }) {
277
+ return /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", gap: 1, children: [
278
+ /* @__PURE__ */ jsx7(Text7, { bold: true, children: "Scaffolding your extension\u2026" }),
279
+ /* @__PURE__ */ jsx7(Box7, { flexDirection: "column", children: steps.map((step) => /* @__PURE__ */ jsxs7(Box7, { gap: 2, children: [
280
+ stepIcon(step.status),
281
+ /* @__PURE__ */ jsx7(Text7, { dimColor: step.status === "pending", color: step.status === "running" ? "cyan" : void 0, children: step.label })
282
+ ] }, step.label)) })
283
+ ] });
284
+ }
285
+
286
+ // src/components/TargetSelect.tsx
287
+ import { Box as Box8, Text as Text8, useInput as useInput2 } from "ink";
288
+ import { useState as useState5 } from "react";
289
+
290
+ // src/constants.ts
291
+ var TARGET_OPTIONS = [
292
+ "slot.header",
293
+ "slot.content",
294
+ "slot.footer",
295
+ "slot.footer-links"
296
+ ];
297
+ var CAPABILITY_PERMISSION_MAP = {
298
+ "slot.header": ["context:read"],
299
+ "slot.content": ["context:read", "data:query", "actions:toast", "actions:invoke"],
300
+ "slot.footer": [],
301
+ "slot.footer-links": []
302
+ };
303
+ var DEFAULT_PERMISSION_FALLBACK = ["context:read"];
304
+ var TEMPLATE_SOURCE = "github:stackable-labs/templates/app-extension";
305
+
306
+ // src/components/TargetSelect.tsx
307
+ import { jsx as jsx8, jsxs as jsxs8 } from "react/jsx-runtime";
308
+ var TARGET_DESCRIPTIONS = {
309
+ "slot.header": "Renders content in the panel header area",
310
+ "slot.content": "Renders the main panel body (includes store + navigation state)",
311
+ "slot.footer": "Renders a footer bar at the bottom of the panel",
312
+ "slot.footer-links": "Renders a link row in the global footer"
313
+ };
314
+ function TargetSelect({ onSubmit }) {
315
+ const [cursor, setCursor] = useState5(0);
316
+ const [selected, setSelected] = useState5(/* @__PURE__ */ new Set(["slot.content"]));
317
+ const [error, setError] = useState5();
318
+ useInput2((input, key) => {
319
+ if (key.upArrow) {
320
+ setCursor((c) => (c - 1 + TARGET_OPTIONS.length) % TARGET_OPTIONS.length);
321
+ return;
322
+ }
323
+ if (key.downArrow) {
324
+ setCursor((c) => (c + 1) % TARGET_OPTIONS.length);
325
+ return;
326
+ }
327
+ if (input === " ") {
328
+ const target = TARGET_OPTIONS[cursor];
329
+ setSelected((prev) => {
330
+ const next = new Set(prev);
331
+ if (next.has(target)) {
332
+ next.delete(target);
333
+ } else {
334
+ next.add(target);
335
+ }
336
+ return next;
337
+ });
338
+ setError(void 0);
339
+ return;
340
+ }
341
+ if (key.return) {
342
+ if (selected.size === 0) {
343
+ setError("Select at least one target slot");
344
+ return;
345
+ }
346
+ onSubmit([...selected]);
347
+ }
348
+ });
349
+ return /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", gap: 1, children: [
350
+ /* @__PURE__ */ jsx8(Text8, { bold: true, children: "Select target slots" }),
351
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: "Space to toggle, Enter to confirm" }),
352
+ /* @__PURE__ */ jsx8(Box8, { flexDirection: "column", children: TARGET_OPTIONS.map((target, i) => {
353
+ const isSelected = selected.has(target);
354
+ const isCursor = i === cursor;
355
+ return /* @__PURE__ */ jsxs8(Box8, { gap: 1, children: [
356
+ /* @__PURE__ */ jsx8(Text8, { color: isCursor ? "cyan" : void 0, children: isCursor ? "\u276F" : " " }),
357
+ /* @__PURE__ */ jsx8(Text8, { color: isSelected ? "green" : void 0, children: isSelected ? "\u25C9" : "\u25CB" }),
358
+ /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
359
+ /* @__PURE__ */ jsx8(Text8, { bold: isSelected, children: target }),
360
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: TARGET_DESCRIPTIONS[target] })
361
+ ] })
362
+ ] }, target);
363
+ }) }),
364
+ error && /* @__PURE__ */ jsx8(Text8, { color: "red", children: error })
365
+ ] });
366
+ }
367
+
368
+ // src/lib/postScaffold.ts
369
+ import { execFile } from "child_process";
370
+ import { promisify } from "util";
371
+ import { installDependencies } from "nypm";
372
+ var execFileAsync = promisify(execFile);
373
+ async function postScaffold(options) {
374
+ if (!options.skipGit) {
375
+ await gitInit(options.outputDir);
376
+ }
377
+ if (!options.skipInstall) {
378
+ await installDependencies({ cwd: options.outputDir, silent: true });
379
+ }
380
+ }
381
+ async function gitInit(dir) {
382
+ try {
383
+ await execFileAsync("git", ["init"], { cwd: dir });
384
+ await execFileAsync("git", ["add", "-A"], { cwd: dir });
385
+ await execFileAsync("git", ["commit", "-m", "chore: initial scaffold"], { cwd: dir });
386
+ } catch {
387
+ }
388
+ }
389
+
390
+ // src/lib/scaffold.ts
391
+ import { downloadTemplate } from "giget";
392
+ import { readFile, readdir, rm, writeFile } from "fs/promises";
393
+ import { join } from "path";
394
+ async function scaffold(options) {
395
+ const { dir } = await downloadTemplate(TEMPLATE_SOURCE, {
396
+ dir: options.outputDir,
397
+ force: true
398
+ });
399
+ const selectedTargets = normalizeTargets(options.targets);
400
+ const derivedPermissions = derivePermissions(selectedTargets);
401
+ await replacePlaceholders(dir, {
402
+ "__EXTENSION_ID__": toKebabCase2(options.extensionId || options.name),
403
+ "__EXTENSION_DISPLAY_NAME__": options.name,
404
+ "replace-with-extension-name": toKebabCase2(options.name),
405
+ "replace-with-extension-package-name": `@agnostack/extensions-${toKebabCase2(options.extensionId || options.name)}`
406
+ });
407
+ await generateManifest(dir, options.name, selectedTargets, derivedPermissions);
408
+ await generateSurfaceFiles(dir, selectedTargets);
409
+ await rewriteExtensionIndex(dir, options.extensionId || options.name, selectedTargets);
410
+ await rewritePreviewApp(dir, selectedTargets, derivedPermissions);
411
+ await rewriteTurboJson(dir);
412
+ await writeEnvFile(dir, options.extensionPort, options.previewPort);
413
+ return options;
414
+ }
415
+ function normalizeTargets(targets) {
416
+ const valid = new Set(TARGET_OPTIONS);
417
+ const selected = targets.filter((target) => valid.has(target));
418
+ if (selected.length > 0) {
419
+ return Array.from(new Set(selected));
420
+ }
421
+ return ["slot.content"];
422
+ }
423
+ function derivePermissions(targets) {
424
+ const permissions = /* @__PURE__ */ new Set();
425
+ for (const target of targets) {
426
+ for (const permission of CAPABILITY_PERMISSION_MAP[target]) {
427
+ permissions.add(permission);
428
+ }
429
+ }
430
+ if (permissions.size === 0) {
431
+ for (const fallback of DEFAULT_PERMISSION_FALLBACK) {
432
+ permissions.add(fallback);
433
+ }
434
+ }
435
+ return [...permissions];
436
+ }
437
+ function toKebabCase2(value) {
438
+ return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
439
+ }
440
+ async function replacePlaceholders(rootDir, replacements) {
441
+ const files = await walkFiles(rootDir);
442
+ for (const filePath of files) {
443
+ if (!isTextFile(filePath)) {
444
+ continue;
445
+ }
446
+ let content = await readFile(filePath, "utf8");
447
+ let changed = false;
448
+ for (const [needle, value] of Object.entries(replacements)) {
449
+ if (content.includes(needle)) {
450
+ content = content.split(needle).join(value);
451
+ changed = true;
452
+ }
453
+ }
454
+ if (changed) {
455
+ await writeFile(filePath, content);
456
+ }
457
+ }
458
+ }
459
+ async function generateManifest(rootDir, extensionName, targets, permissions) {
460
+ const manifestPath = join(rootDir, "packages/extension/public/manifest.json");
461
+ const raw = await readFile(manifestPath, "utf8");
462
+ const manifest = JSON.parse(raw);
463
+ manifest.name = extensionName;
464
+ manifest.targets = targets;
465
+ manifest.permissions = permissions;
466
+ await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}
467
+ `);
468
+ }
469
+ async function generateSurfaceFiles(rootDir, targets) {
470
+ const surfaceDir = join(rootDir, "packages/extension/src/surfaces");
471
+ const wantsHeader = targets.includes("slot.header");
472
+ const wantsContent = targets.includes("slot.content");
473
+ const wantsFooter = targets.includes("slot.footer") || targets.includes("slot.footer-links");
474
+ await upsertOrRemove(
475
+ join(surfaceDir, "Header.tsx"),
476
+ wantsHeader,
477
+ `import { ui, Surface } from '@stackable-labs/sdk-extension-react'
478
+
479
+ export function Header() {
480
+ return (
481
+ <Surface id="slot.header">
482
+ <ui.Text>Header content goes here</ui.Text>
483
+ </Surface>
484
+ )
485
+ }
486
+ `
487
+ );
488
+ await upsertOrRemove(
489
+ join(surfaceDir, "Content.tsx"),
490
+ wantsContent,
491
+ `import { ui, useStore, useContextData, Surface } from '@stackable-labs/sdk-extension-react'
492
+ import { appStore } from '../store'
493
+
494
+ export function Content() {
495
+ const viewState = useStore(appStore, (s) => s.viewState)
496
+ const { loading } = useContextData()
497
+
498
+ if (loading) {
499
+ return (
500
+ <Surface id="slot.content">
501
+ <ui.Stack direction="column" gap="2" className="animate-pulse">
502
+ <ui.Card className="h-24" />
503
+ <ui.Card className="h-32" />
504
+ </ui.Stack>
505
+ </Surface>
506
+ )
507
+ }
508
+
509
+ return (
510
+ <Surface id="slot.content">
511
+ {viewState.type === 'menu' && (
512
+ <ui.Menu>
513
+ {/* Add ui.MenuItem entries here */}
514
+ </ui.Menu>
515
+ )}
516
+ </Surface>
517
+ )
518
+ }
519
+ `
520
+ );
521
+ await upsertOrRemove(
522
+ join(rootDir, "packages/extension/src/store.ts"),
523
+ wantsContent,
524
+ `import { createStore } from '@stackable-labs/sdk-extension-react'
525
+
526
+ export type ViewState = { type: 'menu' }
527
+
528
+ export interface AppState {
529
+ viewState: ViewState
530
+ }
531
+
532
+ export const appStore = createStore<AppState>({
533
+ viewState: { type: 'menu' },
534
+ })
535
+ `
536
+ );
537
+ await upsertOrRemove(join(surfaceDir, "Footer.tsx"), wantsFooter, buildFooterSurface(targets));
538
+ }
539
+ function buildFooterSurface(targets) {
540
+ const blocks = [];
541
+ if (targets.includes("slot.footer")) {
542
+ blocks.push(
543
+ ` <Surface id="slot.footer">
544
+ <ui.Text className="text-xs">Powered by My Extension</ui.Text>
545
+ </Surface>`
546
+ );
547
+ }
548
+ if (targets.includes("slot.footer-links")) {
549
+ blocks.push(
550
+ ` <Surface id="slot.footer-links">
551
+ <ui.FooterLink href="https://example.com">My Extension</ui.FooterLink>
552
+ </Surface>`
553
+ );
554
+ }
555
+ return `import { ui, Surface } from '@stackable-labs/sdk-extension-react'
556
+
557
+ export function Footer() {
558
+ return (
559
+ <>
560
+ ${blocks.join("\n")}
561
+ </>
562
+ )
563
+ }
564
+ `;
565
+ }
566
+ async function rewriteExtensionIndex(rootDir, extensionId, targets) {
567
+ const indexPath = join(rootDir, "packages/extension/src/index.tsx");
568
+ const imports = ["import { createExtension } from '@stackable-labs/sdk-extension-react'"];
569
+ const components = [];
570
+ if (targets.includes("slot.header")) {
571
+ imports.push("import { Header } from './surfaces/Header'");
572
+ components.push(" <Header />");
573
+ }
574
+ if (targets.includes("slot.content")) {
575
+ imports.push("import { Content } from './surfaces/Content'");
576
+ components.push(" <Content />");
577
+ }
578
+ if (targets.includes("slot.footer") || targets.includes("slot.footer-links")) {
579
+ imports.push("import { Footer } from './surfaces/Footer'");
580
+ components.push(" <Footer />");
581
+ }
582
+ const content = `${imports.join("\n")}
583
+
584
+ createExtension(
585
+ () => (
586
+ <>
587
+ ${components.join("\n")}
588
+ </>
589
+ ),
590
+ { extensionId: '${toKebabCase2(extensionId)}' },
591
+ )
592
+ `;
593
+ await writeFile(indexPath, content);
594
+ }
595
+ async function rewritePreviewApp(rootDir, targets, permissions) {
596
+ const appPath = join(rootDir, "packages/preview/src/App.tsx");
597
+ const includeDataQuery = permissions.includes("data:query");
598
+ const includeToast = permissions.includes("actions:toast");
599
+ const includeInvoke = permissions.includes("actions:invoke");
600
+ const includeContextRead = permissions.includes("context:read");
601
+ const typeImports = [
602
+ "ExtensionRegistryEntry",
603
+ "Permission",
604
+ includeInvoke ? "ActionInvokePayload" : "",
605
+ includeDataQuery ? "ApiRequest" : "",
606
+ includeToast ? "ToastPayload" : ""
607
+ ].filter(Boolean);
608
+ const handlers = [];
609
+ if (includeDataQuery) {
610
+ handlers.push(` 'data.query': async (_payload: ApiRequest) => {
611
+ return mockData
612
+ },`);
613
+ }
614
+ if (includeToast) {
615
+ handlers.push(` 'actions.toast': async (payload: ToastPayload) => {
616
+ console.log('[Preview] toast:', payload)
617
+ },`);
618
+ }
619
+ if (includeInvoke) {
620
+ handlers.push(` 'actions.invoke': async (payload: ActionInvokePayload) => {
621
+ console.log('[Preview] action invoke:', payload)
622
+ return {}
623
+ },`);
624
+ }
625
+ if (includeContextRead) {
626
+ handlers.push(" 'context.read': async () => mockContext,");
627
+ }
628
+ const appContent = `import { ExtensionProvider, ExtensionSlot } from '@stackable-labs/sdk-extension-host'
629
+ import type { CapabilityHandlers } from '@stackable-labs/sdk-extension-host'
630
+ import { hostComponents } from '@stackable-labs/embeddables/components'
631
+ import type {
632
+ ${typeImports.join(",\n ")}
633
+ } from '@stackable-labs/sdk-extension-contracts'
634
+ import manifestRaw from '../../extension/public/manifest.json'
635
+ import mockData from './mockData.json'
636
+
637
+ const manifest = {
638
+ ...manifestRaw,
639
+ permissions: manifestRaw.permissions as Permission[],
640
+ }
641
+
642
+ const extensions: ExtensionRegistryEntry[] = [
643
+ {
644
+ id: manifest.name.toLowerCase().replace(/\\s+/g, '-'),
645
+ manifest,
646
+ bundleUrl: \`http://localhost:\${import.meta.env.VITE_EXTENSION_PORT || '5173'}\`,
647
+ enabled: true,
648
+ },
649
+ ]
650
+
651
+ const mockContext = {
652
+ customerId: 'cust_preview_123',
653
+ customerEmail: 'preview@example.com',
654
+ }
655
+
656
+ const capabilityHandlers: CapabilityHandlers = {
657
+ ${handlers.join("\n")}
658
+ }
659
+
660
+ export default function App() {
661
+ return (
662
+ <div style={{ maxWidth: 400, margin: '0 auto', padding: 16 }}>
663
+ <ExtensionProvider
664
+ extensions={extensions}
665
+ components={hostComponents()}
666
+ capabilityHandlers={capabilityHandlers}
667
+ >
668
+ {manifest.targets.map((target) => (
669
+ <div key={target} style={{ marginBottom: 8 }}>
670
+ <div style={{ fontSize: 10, color: '#888' }}>{target}</div>
671
+ <ExtensionSlot target={target} context={mockContext} />
672
+ </div>
673
+ ))}
674
+ </ExtensionProvider>
675
+ </div>
676
+ )
677
+ }
678
+ `;
679
+ await writeFile(appPath, appContent);
680
+ }
681
+ async function rewriteTurboJson(rootDir) {
682
+ const turboPath = join(rootDir, "turbo.json");
683
+ const raw = await readFile(turboPath, "utf8");
684
+ const turbo = JSON.parse(raw);
685
+ delete turbo["extends"];
686
+ turbo["globalEnv"] = ["VITE_EXTENSION_PORT", "VITE_PREVIEW_PORT"];
687
+ await writeFile(turboPath, `${JSON.stringify(turbo, null, 2)}
688
+ `);
689
+ }
690
+ async function walkFiles(rootDir) {
691
+ const entries = await readdir(rootDir, { withFileTypes: true });
692
+ const files = [];
693
+ for (const entry of entries) {
694
+ if (entry.name === ".git" || entry.name === "node_modules" || entry.name === "dist") {
695
+ continue;
696
+ }
697
+ const fullPath = join(rootDir, entry.name);
698
+ if (entry.isDirectory()) {
699
+ files.push(...await walkFiles(fullPath));
700
+ continue;
701
+ }
702
+ files.push(fullPath);
703
+ }
704
+ return files;
705
+ }
706
+ function isTextFile(filePath) {
707
+ return /\.(ts|tsx|js|jsx|json|md|html|yml|yaml|env|gitignore|nvmrc)$/i.test(filePath);
708
+ }
709
+ async function writeEnvFile(dir, extensionPort, previewPort) {
710
+ const envPath = join(dir, ".env");
711
+ const content = `VITE_EXTENSION_PORT=${extensionPort}
712
+ VITE_PREVIEW_PORT=${previewPort}
713
+ `;
714
+ await writeFile(envPath, content);
715
+ }
716
+ async function upsertOrRemove(filePath, shouldExist, content) {
717
+ if (shouldExist) {
718
+ await writeFile(filePath, content);
719
+ return;
720
+ }
721
+ await rm(filePath, { force: true });
722
+ }
723
+
724
+ // src/App.tsx
725
+ import { join as join2 } from "path";
726
+ import { jsx as jsx9, jsxs as jsxs9 } from "react/jsx-runtime";
727
+ var INITIAL_STEPS = [
728
+ { label: "Fetching template", status: "pending" },
729
+ { label: "Generating files", status: "pending" },
730
+ { label: "Installing dependencies", status: "pending" }
731
+ ];
732
+ function toKebabCase3(value) {
733
+ return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
734
+ }
735
+ function App({ initialName, options }) {
736
+ const { exit } = useApp();
737
+ const [step, setStep] = useState6(initialName ? "id" : "name");
738
+ const [name, setName] = useState6(initialName ?? "");
739
+ const [extensionId, setExtensionId] = useState6(options?.id ?? "");
740
+ const [extensionPort, setExtensionPort] = useState6(
741
+ options?.extensionPort ? parseInt(options.extensionPort, 10) : 5173
742
+ );
743
+ const [previewPort, setPreviewPort] = useState6(
744
+ options?.previewPort ? parseInt(options.previewPort, 10) : 5174
745
+ );
746
+ const [targets, setTargets] = useState6([]);
747
+ const [outputDir, setOutputDir] = useState6("");
748
+ const [progressSteps, setProgressSteps] = useState6(INITIAL_STEPS);
749
+ const [errorMessage, setErrorMessage] = useState6();
750
+ const updateStep = useCallback((index, status) => {
751
+ setProgressSteps(
752
+ (prev) => prev.map((s, i) => i === index ? { ...s, status } : s)
753
+ );
754
+ }, []);
755
+ const handleName = (value) => {
756
+ setName(value);
757
+ setExtensionId(toKebabCase3(value));
758
+ setStep("id");
759
+ };
760
+ const handleId = (value) => {
761
+ setExtensionId(value);
762
+ setStep("targets");
763
+ };
764
+ const handleTargets = (value) => {
765
+ setTargets(value);
766
+ setStep(options?.extensionPort || options?.previewPort ? "dir" : "ports");
767
+ };
768
+ const handlePorts = (extPort, prevPort) => {
769
+ setExtensionPort(extPort);
770
+ setPreviewPort(prevPort);
771
+ setStep("dir");
772
+ };
773
+ const handleDir = (dir) => {
774
+ setOutputDir(dir);
775
+ setStep("confirm");
776
+ };
777
+ const handleConfirm = async () => {
778
+ setStep("scaffolding");
779
+ setProgressSteps([
780
+ { label: "Fetching template", status: "running" },
781
+ { label: "Generating files", status: "pending" },
782
+ { label: "Installing dependencies", status: "pending" }
783
+ ]);
784
+ try {
785
+ updateStep(0, "running");
786
+ await scaffold({
787
+ name,
788
+ extensionId,
789
+ targets,
790
+ outputDir,
791
+ extensionPort,
792
+ previewPort
793
+ });
794
+ updateStep(0, "done");
795
+ updateStep(1, "running");
796
+ await new Promise((r) => setTimeout(r, 200));
797
+ updateStep(1, "done");
798
+ if (!options?.skipInstall) {
799
+ updateStep(2, "running");
800
+ await postScaffold({ outputDir, skipInstall: false, skipGit: options?.skipGit });
801
+ updateStep(2, "done");
802
+ } else {
803
+ updateStep(2, "done");
804
+ }
805
+ setStep("done");
806
+ } catch (err) {
807
+ const message = err instanceof Error ? err.message : String(err);
808
+ setErrorMessage(message);
809
+ setStep("error");
810
+ }
811
+ };
812
+ const handleCancel = () => {
813
+ exit();
814
+ };
815
+ if (step === "name") {
816
+ return /* @__PURE__ */ jsx9(NamePrompt, { initialValue: name, onSubmit: handleName });
817
+ }
818
+ if (step === "id") {
819
+ return /* @__PURE__ */ jsx9(IdPrompt, { extensionName: name, onSubmit: handleId });
820
+ }
821
+ if (step === "targets") {
822
+ return /* @__PURE__ */ jsx9(TargetSelect, { onSubmit: handleTargets });
823
+ }
824
+ if (step === "ports") {
825
+ return /* @__PURE__ */ jsx9(PortsPrompt, { onSubmit: handlePorts });
826
+ }
827
+ if (step === "dir") {
828
+ return /* @__PURE__ */ jsx9(DirPrompt, { defaultDir: join2(process.cwd(), toKebabCase3(name)), onSubmit: handleDir });
829
+ }
830
+ if (step === "confirm") {
831
+ return /* @__PURE__ */ jsx9(
832
+ Confirm,
833
+ {
834
+ name,
835
+ extensionId,
836
+ extensionPort,
837
+ previewPort,
838
+ targets,
839
+ outputDir,
840
+ onConfirm: handleConfirm,
841
+ onCancel: handleCancel
842
+ }
843
+ );
844
+ }
845
+ if (step === "scaffolding") {
846
+ return /* @__PURE__ */ jsx9(ScaffoldProgress, { steps: progressSteps });
847
+ }
848
+ if (step === "done") {
849
+ return /* @__PURE__ */ jsx9(Done, { name, outputDir });
850
+ }
851
+ return /* @__PURE__ */ jsxs9(Box9, { flexDirection: "column", gap: 1, children: [
852
+ /* @__PURE__ */ jsx9(Text9, { color: "red", bold: true, children: "Scaffold failed" }),
853
+ errorMessage && /* @__PURE__ */ jsx9(Text9, { color: "red", children: errorMessage })
854
+ ] });
855
+ }
856
+
857
+ // src/index.tsx
858
+ import { jsx as jsx10 } from "react/jsx-runtime";
859
+ program.name("create-extension").description("Scaffold a new Stackable extension project").argument("[name]", "Extension project name").option("--id <id>", "Extension ID").option("--targets <targets>", "Comma-separated target slots").option("--extension-port <port>", "Extension dev server port (default: 5173)").option("--preview-port <port>", "Preview host dev server port (default: extension port + 1)").option("--skip-install", "Skip package manager install").option("--skip-git", "Skip git initialization").action((name, options) => {
860
+ render(/* @__PURE__ */ jsx10(App, { initialName: name, options }));
861
+ });
862
+ program.parse();
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@stackable-labs/cli-app-extension",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "private": false,
6
+ "bin": {
7
+ "create-extension": "./dist/index.js",
8
+ "cli-app-extension": "./dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist/",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "dependencies": {
16
+ "commander": "^12.1.0",
17
+ "giget": "^3.1.2",
18
+ "ink": "^5.0.1",
19
+ "ink-select-input": "^6.0.0",
20
+ "ink-text-input": "^6.0.0",
21
+ "ink-spinner": "^5.0.0",
22
+ "nypm": "^0.4.1",
23
+ "react": "^18.3.1"
24
+ },
25
+ "description": "CLI for scaffolding Stackable extension projects.",
26
+ "license": "SEE LICENSE IN LICENSE",
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "homepage": "https://www.npmjs.com/package/@stackable-labs/cli-app-extension"
31
+ }