@groundfloorcloud/cli 0.1.1 → 0.1.2

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 +19 -3
  2. package/dist/index.js +362 -18
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -6,6 +6,10 @@ secrets, files, apps, and Dataplane from your terminal.
6
6
  `gf login` opens a browser, you sign in, and the session is cached and
7
7
  refreshed automatically.
8
8
 
9
+ Agents in Cursor use [`@groundfloorcloud/mcp`](../mcp/README.md) against the
10
+ same `gf login` session to inspect the workspace and docs, then run `gf`
11
+ commands to take action.
12
+
9
13
  ## Install
10
14
 
11
15
  ```bash
@@ -105,17 +109,28 @@ Runtime is auto-detected when omitted (`package.json` → node,
105
109
  Product **Apps** are packaging (Shell Module Federation remotes, or a
106
110
  standalone wrapper around a service). That is separate from `gf deploy`.
107
111
 
112
+ **Always start from the official starter-kit.** Do not scaffold `App.tsx` or
113
+ put `BrowserRouter` in the federated export — that crashes the Shell.
114
+
108
115
  ```bash
116
+ gf apps init --slug my-federated-app
117
+ cd my-federated-app
109
118
  gf apps create --name "My Federated App" --slug my-federated-app \
110
119
  --manifest ./groundfloor.manifest.json
120
+ npm install
111
121
  npm run release
112
122
  gf apps publish --path release.zip
113
123
  ```
114
124
 
115
- `gf apps publish` uploads `release.zip` (or `remoteEntry.js`), finalizes the
116
- release, and by default PATCHes the Portal manifest from
125
+ `gf apps init` downloads the public ZIP from the Customer Portal
126
+ (`/downloads/shell-starter-kit.zip` on the current cell) and stamps
127
+ `APP_ID` / `groundfloor.manifest.json` `appId` to the slug.
128
+
129
+ `gf apps publish` uploads `release.zip` (or `remoteEntry.js`), flattens a
130
+ Vite `dist/assets/` layout so chunks sit next to `remoteEntry.js`, finalizes
131
+ the release, and by default PATCHes the Portal manifest from
117
132
  `groundfloor.manifest.json` next to the bundle. Pass an app id/slug, or set
118
- `appId` in that manifest.
133
+ `appId` in that manifest. Prefer `npm run release` over zipping `dist/` by hand.
119
134
 
120
135
  Wrap an existing **service** coderunner as a product:
121
136
 
@@ -164,6 +179,7 @@ gf apps get <id|slug>
164
179
  | `gf domains ls\|add\|rm\|verify -c <id\|slug>` | Custom domains for a service. |
165
180
  | `gf secrets ls\|get\|set\|rm` | Workspace secrets. |
166
181
  | `gf files ls\|upload\|download\|rm` | Workspace files. |
182
+ | `gf apps init --slug <slug>` | Download the official Shell starter-kit and stamp APP_ID. |
167
183
  | `gf apps ls\|get\|create\|publish` | Product apps (Shell publish / standalone wrap). |
168
184
  | `gf dataplane status\|provision` | Dataplane setup for the workspace. |
169
185
 
package/dist/index.js CHANGED
@@ -68,19 +68,26 @@ var CELLS = {
68
68
  production: {
69
69
  apiUrl: "https://platform.groundfloor.cloud",
70
70
  issuer: "https://auth.groundfloor.cloud/realms/groundfloor",
71
- clientId: "groundfloor-cli"
71
+ clientId: "groundfloor-cli",
72
+ consoleUrl: "https://console.groundfloor.cloud"
72
73
  },
73
74
  stage: {
74
75
  apiUrl: "https://platform.stage.groundfloor.cloud",
75
76
  issuer: "https://auth.stage.groundfloor.cloud/realms/groundfloor_pico_stage-realm",
76
- clientId: "groundfloor-cli"
77
+ clientId: "groundfloor-cli",
78
+ consoleUrl: "https://console.stage.groundfloor.cloud"
77
79
  },
78
80
  dev: {
79
81
  apiUrl: "https://platform.dev.groundfloor.cloud",
80
82
  issuer: "https://auth.dev.groundfloor.cloud/realms/groundfloor_dev",
81
- clientId: "groundfloor-cli"
83
+ clientId: "groundfloor-cli",
84
+ consoleUrl: "https://console.dev.groundfloor.cloud"
82
85
  }
83
86
  };
87
+ var STARTER_KIT_ZIP_PATH = "/downloads/shell-starter-kit.zip";
88
+ function starterKitZipUrl(cell = "production") {
89
+ return `${CELLS[cell].consoleUrl}${STARTER_KIT_ZIP_PATH}`;
90
+ }
84
91
  var CELL_ALIASES = {
85
92
  production: "production",
86
93
  prod: "production",
@@ -384,10 +391,13 @@ async function refreshTokens(opts) {
384
391
  var EXPIRY_SKEW_MS = 3e4;
385
392
  var NotLoggedInError = class extends Error {
386
393
  constructor() {
387
- super("Not logged in. Run `gf login` first.");
394
+ super(
395
+ "Not logged in. Set GROUNDFLOOR_TOKEN, or run `gf login` first."
396
+ );
388
397
  this.name = "NotLoggedInError";
389
398
  }
390
399
  };
400
+ var accessTokenProvider = null;
391
401
  function decodeJwt(token) {
392
402
  const parts = token.split(".");
393
403
  if (parts.length < 2) return null;
@@ -414,6 +424,9 @@ function isFresh(session) {
414
424
  return Date.now() < session.expiresAt - EXPIRY_SKEW_MS;
415
425
  }
416
426
  async function getValidAccessToken() {
427
+ if (accessTokenProvider) {
428
+ return accessTokenProvider();
429
+ }
417
430
  const session = await readAuth();
418
431
  if (!session || !session.accessToken) {
419
432
  throw new NotLoggedInError();
@@ -520,9 +533,9 @@ async function parseError(res) {
520
533
  }
521
534
  return `${res.status} ${detail}`;
522
535
  }
523
- async function cpGet(apiUrl, path6) {
536
+ async function cpGet(apiUrl, path7) {
524
537
  const token = await getValidAccessToken();
525
- const res = await fetch(`${apiUrl}${path6}`, {
538
+ const res = await fetch(`${apiUrl}${path7}`, {
526
539
  headers: {
527
540
  Authorization: `Bearer ${token}`,
528
541
  Accept: "application/json"
@@ -533,9 +546,9 @@ async function cpGet(apiUrl, path6) {
533
546
  }
534
547
  return await res.json();
535
548
  }
536
- async function cpPostJson(apiUrl, path6, body) {
549
+ async function cpPostJson(apiUrl, path7, body) {
537
550
  const token = await getValidAccessToken();
538
- const res = await fetch(`${apiUrl}${path6}`, {
551
+ const res = await fetch(`${apiUrl}${path7}`, {
539
552
  method: "POST",
540
553
  headers: {
541
554
  Authorization: `Bearer ${token}`,
@@ -549,9 +562,9 @@ async function cpPostJson(apiUrl, path6, body) {
549
562
  }
550
563
  return await res.json();
551
564
  }
552
- async function cpDelete(apiUrl, path6) {
565
+ async function cpDelete(apiUrl, path7) {
553
566
  const token = await getValidAccessToken();
554
- const res = await fetch(`${apiUrl}${path6}`, {
567
+ const res = await fetch(`${apiUrl}${path7}`, {
555
568
  method: "DELETE",
556
569
  headers: { Authorization: `Bearer ${token}`, Accept: "application/json" }
557
570
  });
@@ -559,9 +572,9 @@ async function cpDelete(apiUrl, path6) {
559
572
  throw new CpError(res.status, await parseError(res));
560
573
  }
561
574
  }
562
- async function cpPutJson(apiUrl, path6, body) {
575
+ async function cpPutJson(apiUrl, path7, body) {
563
576
  const token = await getValidAccessToken();
564
- const res = await fetch(`${apiUrl}${path6}`, {
577
+ const res = await fetch(`${apiUrl}${path7}`, {
565
578
  method: "PUT",
566
579
  headers: {
567
580
  Authorization: `Bearer ${token}`,
@@ -575,9 +588,9 @@ async function cpPutJson(apiUrl, path6, body) {
575
588
  }
576
589
  return await res.json();
577
590
  }
578
- async function cpPatchJson(apiUrl, path6, body) {
591
+ async function cpPatchJson(apiUrl, path7, body) {
579
592
  const token = await getValidAccessToken();
580
- const res = await fetch(`${apiUrl}${path6}`, {
593
+ const res = await fetch(`${apiUrl}${path7}`, {
581
594
  method: "PATCH",
582
595
  headers: {
583
596
  Authorization: `Bearer ${token}`,
@@ -591,9 +604,9 @@ async function cpPatchJson(apiUrl, path6, body) {
591
604
  }
592
605
  return await res.json();
593
606
  }
594
- async function cpPutBytes(apiUrl, path6, body, contentType) {
607
+ async function cpPutBytes(apiUrl, path7, body, contentType) {
595
608
  const token = await getValidAccessToken();
596
- const res = await fetch(`${apiUrl}${path6}`, {
609
+ const res = await fetch(`${apiUrl}${path7}`, {
597
610
  method: "PUT",
598
611
  headers: {
599
612
  Authorization: `Bearer ${token}`,
@@ -1686,6 +1699,96 @@ async function filesRmCommand(fileId, opts) {
1686
1699
  // src/commands/apps.ts
1687
1700
  import { promises as fs5 } from "fs";
1688
1701
  import path5 from "path";
1702
+
1703
+ // src/release-zip.ts
1704
+ import AdmZip2 from "adm-zip";
1705
+ var REMOTE_ENTRY = "remoteEntry.js";
1706
+ var FLATTEN_SUFFIXES = [".js", ".mjs", ".cjs", ".css", ".map", ".wasm"];
1707
+ function normalizeZipEntryName(filename) {
1708
+ return filename.replace(/\\/g, "/").replace(/^\/+/, "");
1709
+ }
1710
+ function parentDir(rel) {
1711
+ const i = rel.lastIndexOf("/");
1712
+ return i === -1 ? "" : rel.slice(0, i);
1713
+ }
1714
+ function basename(rel) {
1715
+ const i = rel.lastIndexOf("/");
1716
+ return i === -1 ? rel : rel.slice(i + 1);
1717
+ }
1718
+ function chooseRemoteEntry(relpaths) {
1719
+ const entries = relpaths.filter(
1720
+ (p) => p === REMOTE_ENTRY || p.endsWith(`/${REMOTE_ENTRY}`)
1721
+ );
1722
+ if (entries.length === 0) return void 0;
1723
+ if (entries.includes(REMOTE_ENTRY)) return REMOTE_ENTRY;
1724
+ return [...entries].sort((a, b) => {
1725
+ const score = (p) => {
1726
+ const parts = p.split("/");
1727
+ const inAssets = parts.length >= 2 && parts[parts.length - 2] === "assets";
1728
+ return `${inAssets ? 0 : 1}:${parts.length}:${p}`;
1729
+ };
1730
+ return score(a).localeCompare(score(b));
1731
+ })[0];
1732
+ }
1733
+ function flattenFederatedBundlePlan(relpaths) {
1734
+ const paths = relpaths.map(normalizeZipEntryName).filter((name) => name && !name.endsWith("/") && !name.split("/").includes(".."));
1735
+ const pathset = new Set(paths);
1736
+ const entry = chooseRemoteEntry([...pathset]);
1737
+ if (!entry) return {};
1738
+ const promoteDirs = /* @__PURE__ */ new Set();
1739
+ const entryDir = parentDir(entry);
1740
+ if (entryDir) promoteDirs.add(entryDir);
1741
+ for (const path7 of pathset) {
1742
+ const parent = parentDir(path7);
1743
+ if (parent === "assets" || parent.endsWith("/assets") && parent.split("/").length - 1 < 2) {
1744
+ promoteDirs.add(parent);
1745
+ }
1746
+ }
1747
+ const destToSrc = {};
1748
+ for (const src of paths) {
1749
+ const parent = parentDir(src);
1750
+ if (!promoteDirs.has(parent)) continue;
1751
+ const dest = basename(src);
1752
+ if (dest === "release.zip") continue;
1753
+ const lower = dest.toLowerCase();
1754
+ if (dest !== REMOTE_ENTRY && !FLATTEN_SUFFIXES.some((ext) => lower.endsWith(ext))) {
1755
+ continue;
1756
+ }
1757
+ if (pathset.has(dest)) continue;
1758
+ const prev = destToSrc[dest];
1759
+ if (prev === void 0 || src.split("/").length < prev.split("/").length) {
1760
+ destToSrc[dest] = src;
1761
+ }
1762
+ }
1763
+ return destToSrc;
1764
+ }
1765
+ function flattenFederatedReleaseZip(bytes) {
1766
+ const zip = new AdmZip2(bytes);
1767
+ const rels = [];
1768
+ const byNorm = /* @__PURE__ */ new Map();
1769
+ for (const entry of zip.getEntries()) {
1770
+ if (entry.isDirectory) continue;
1771
+ const name = normalizeZipEntryName(entry.entryName);
1772
+ rels.push(name);
1773
+ byNorm.set(name, entry);
1774
+ }
1775
+ const plan = flattenFederatedBundlePlan(rels);
1776
+ const keys = Object.keys(plan);
1777
+ if (keys.length === 0) return bytes;
1778
+ for (const dest of keys) {
1779
+ const src = plan[dest];
1780
+ const source = byNorm.get(src);
1781
+ if (!source) continue;
1782
+ zip.addFile(dest, source.getData());
1783
+ }
1784
+ return zip.toBuffer();
1785
+ }
1786
+ function federatedReleaseZipHint() {
1787
+ return "Run `npm run release` in a kit from `gf apps init --slug <portal-slug>` (do not zip Vite dist/ or dist/assets/ by hand).";
1788
+ }
1789
+
1790
+ // src/commands/apps.ts
1791
+ import AdmZip3 from "adm-zip";
1689
1792
  var MAX_RELEASE_BYTES = 32 * 1024 * 1024;
1690
1793
  function slugify2(input) {
1691
1794
  const slug = input.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
@@ -1716,6 +1819,33 @@ async function findSiblingManifest(bundlePath) {
1716
1819
  }
1717
1820
  return { path: candidate, manifest: await readJsonObject(candidate) };
1718
1821
  }
1822
+ function zipHasRemoteEntry(bytes) {
1823
+ const zip = new AdmZip3(bytes);
1824
+ return zip.getEntries().some((entry) => {
1825
+ if (entry.isDirectory) return false;
1826
+ const name = normalizeZipEntryName(entry.entryName);
1827
+ return name === REMOTE_ENTRY || name.endsWith(`/${REMOTE_ENTRY}`);
1828
+ });
1829
+ }
1830
+ function prepareReleaseZip(bytes) {
1831
+ if (!zipHasRemoteEntry(bytes)) {
1832
+ throw new Error(
1833
+ `Zip has no remoteEntry.js. ${federatedReleaseZipHint()}`
1834
+ );
1835
+ }
1836
+ const flattened = flattenFederatedReleaseZip(bytes);
1837
+ const plan = flattenFederatedBundlePlan(
1838
+ new AdmZip3(bytes).getEntries().filter((e) => !e.isDirectory).map((e) => normalizeZipEntryName(e.entryName))
1839
+ );
1840
+ if (Object.keys(plan).length > 0) {
1841
+ process.stderr.write(
1842
+ `Note: flattened Vite dist/assets layout so chunks sit next to remoteEntry.js.
1843
+ Prefer: ${federatedReleaseZipHint()}
1844
+ `
1845
+ );
1846
+ }
1847
+ return flattened;
1848
+ }
1719
1849
  async function isDirectory(filePath) {
1720
1850
  try {
1721
1851
  return (await fs5.stat(filePath)).isDirectory();
@@ -1826,7 +1956,12 @@ async function appsCreateCommand(opts) {
1826
1956
  `);
1827
1957
  if (kind === "shell_federated") {
1828
1958
  process.stdout.write(
1829
- "Next: npm run release && gf apps publish --path release.zip\n"
1959
+ `Next:
1960
+ gf apps init --slug ${slug}
1961
+ cd ${slug} && npm install && npm run release
1962
+ gf apps publish --path release.zip
1963
+ Do not scaffold src/App.tsx from scratch \u2014 BrowserRouter in App.tsx crashes the Shell.
1964
+ `
1830
1965
  );
1831
1966
  }
1832
1967
  }
@@ -1865,6 +2000,9 @@ async function appsPublishCommand(appRef, opts) {
1865
2000
  }
1866
2001
  const ext = path5.extname(bundlePath).toLowerCase();
1867
2002
  const isZip = ext === ".zip";
2003
+ if (isZip) {
2004
+ bytes = prepareReleaseZip(bytes);
2005
+ }
1868
2006
  const bundle = isZip ? "zip" : "remote_entry";
1869
2007
  const contentType = isZip ? "application/zip" : "application/javascript";
1870
2008
  if (opts.syncManifest !== false && sibling) {
@@ -1900,6 +2038,192 @@ async function appsPublishCommand(appRef, opts) {
1900
2038
  );
1901
2039
  }
1902
2040
 
2041
+ // src/commands/apps-init.ts
2042
+ import { promises as fs6 } from "fs";
2043
+ import os3 from "os";
2044
+ import path6 from "path";
2045
+ import AdmZip4 from "adm-zip";
2046
+ var MAX_KIT_BYTES = 8 * 1024 * 1024;
2047
+ function slugify3(input) {
2048
+ const slug = input.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
2049
+ return slug || "app";
2050
+ }
2051
+ function titleFromSlug(slug) {
2052
+ return slug.split("-").filter(Boolean).map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ") || slug;
2053
+ }
2054
+ async function pathExists(filePath) {
2055
+ try {
2056
+ await fs6.access(filePath);
2057
+ return true;
2058
+ } catch {
2059
+ return false;
2060
+ }
2061
+ }
2062
+ async function isEmptyDir(dir) {
2063
+ try {
2064
+ const entries = await fs6.readdir(dir);
2065
+ return entries.length === 0;
2066
+ } catch (err) {
2067
+ if (err.code === "ENOENT") return true;
2068
+ throw err;
2069
+ }
2070
+ }
2071
+ async function findKitRoot(extracted) {
2072
+ const candidates = [
2073
+ path6.join(extracted, "shell-starter-kit"),
2074
+ extracted
2075
+ ];
2076
+ const entries = await fs6.readdir(extracted, { withFileTypes: true });
2077
+ for (const entry of entries) {
2078
+ if (entry.isDirectory()) {
2079
+ candidates.push(path6.join(extracted, entry.name));
2080
+ }
2081
+ }
2082
+ for (const candidate of candidates) {
2083
+ if (await pathExists(path6.join(candidate, "groundfloor.manifest.json"))) {
2084
+ return candidate;
2085
+ }
2086
+ }
2087
+ throw new Error(
2088
+ "Starter-kit ZIP did not contain groundfloor.manifest.json. Re-download with `gf apps init`."
2089
+ );
2090
+ }
2091
+ function stampAppIdentity(source, slug) {
2092
+ if (!/export const APP_ID\s*=/.test(source)) {
2093
+ throw new Error("src/appIdentity.ts does not export APP_ID");
2094
+ }
2095
+ const safe = slug.replace(/['"]/g, "");
2096
+ return source.replace(
2097
+ /export const APP_ID\s*=\s*['"][^'"]*['"]/,
2098
+ `export const APP_ID = '${safe}'`
2099
+ );
2100
+ }
2101
+ function stampManifest(raw, slug, name) {
2102
+ const parsed = JSON.parse(raw);
2103
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
2104
+ throw new Error("groundfloor.manifest.json must be a JSON object");
2105
+ }
2106
+ const manifest = parsed;
2107
+ manifest.appId = slug;
2108
+ manifest.name = name;
2109
+ return `${JSON.stringify(manifest, null, 2)}
2110
+ `;
2111
+ }
2112
+ async function downloadKit(url) {
2113
+ let res;
2114
+ try {
2115
+ res = await fetch(url, {
2116
+ redirect: "follow",
2117
+ signal: AbortSignal.timeout(6e4),
2118
+ headers: { "User-Agent": "groundfloor-cli/0.1.1" }
2119
+ });
2120
+ } catch (err) {
2121
+ const message = err instanceof Error ? err.message : String(err);
2122
+ throw new Error(`Could not download starter-kit from ${url}: ${message}`);
2123
+ }
2124
+ if (!res.ok) {
2125
+ throw new Error(
2126
+ `Starter-kit download failed (${res.status}) from ${url}`
2127
+ );
2128
+ }
2129
+ const bytes = Buffer.from(await res.arrayBuffer());
2130
+ if (bytes.byteLength === 0) {
2131
+ throw new Error(`Starter-kit ZIP is empty: ${url}`);
2132
+ }
2133
+ if (bytes.byteLength > MAX_KIT_BYTES) {
2134
+ throw new Error(
2135
+ `Starter-kit ZIP is too large (${bytes.byteLength} bytes) from ${url}`
2136
+ );
2137
+ }
2138
+ if (bytes[0] !== 80 || bytes[1] !== 75) {
2139
+ const head = bytes.subarray(0, 80).toString("utf8");
2140
+ throw new Error(
2141
+ `Expected a ZIP from ${url}, got ${res.headers.get("content-type") ?? "unknown"} (${head.trim()}). Do not scaffold a federated App.tsx from memory.`
2142
+ );
2143
+ }
2144
+ return bytes;
2145
+ }
2146
+ function assertSafeZipEntries(zip) {
2147
+ for (const entry of zip.getEntries()) {
2148
+ const name = entry.entryName.replace(/\\/g, "/");
2149
+ if (name.startsWith("/") || name.includes("..")) {
2150
+ throw new Error(`Refusing starter-kit ZIP with unsafe path: ${name}`);
2151
+ }
2152
+ }
2153
+ }
2154
+ async function appsInitCommand(opts) {
2155
+ const rawSlug = opts.slug?.trim() ?? "";
2156
+ if (!rawSlug) {
2157
+ throw new Error("Pass --slug <portal-slug>.");
2158
+ }
2159
+ const slug = slugify3(rawSlug);
2160
+ if (slug !== rawSlug) {
2161
+ process.stderr.write(`Note: slug normalized to ${slug}
2162
+ `);
2163
+ }
2164
+ const name = opts.name?.trim() || titleFromSlug(slug);
2165
+ const dest = path6.resolve(opts.dir?.trim() || path6.join(process.cwd(), slug));
2166
+ const cfg = await resolveConfig();
2167
+ const cell = cfg.environment ?? "production";
2168
+ const kitUrl = opts.kitUrl?.trim() || starterKitZipUrl(cell);
2169
+ if (!await isEmptyDir(dest) && !opts.force) {
2170
+ throw new Error(
2171
+ `Destination ${dest} is not empty. Pass --force to overwrite, or choose another --dir.`
2172
+ );
2173
+ }
2174
+ if (opts.force && await pathExists(dest)) {
2175
+ await fs6.rm(dest, { recursive: true, force: true });
2176
+ }
2177
+ const bytes = await downloadKit(kitUrl);
2178
+ const zip = new AdmZip4(bytes);
2179
+ assertSafeZipEntries(zip);
2180
+ const tmp = await fs6.mkdtemp(path6.join(os3.tmpdir(), "gf-starter-kit-"));
2181
+ try {
2182
+ zip.extractAllTo(tmp, true);
2183
+ const kitRoot = await findKitRoot(tmp);
2184
+ await fs6.mkdir(dest, { recursive: true });
2185
+ await fs6.cp(kitRoot, dest, { recursive: true, force: true });
2186
+ } finally {
2187
+ await fs6.rm(tmp, { recursive: true, force: true });
2188
+ }
2189
+ const identityPath = path6.join(dest, "src", "appIdentity.ts");
2190
+ const manifestPath = path6.join(dest, "groundfloor.manifest.json");
2191
+ const identity = await fs6.readFile(identityPath, "utf8");
2192
+ await fs6.writeFile(identityPath, stampAppIdentity(identity, slug), "utf8");
2193
+ const manifest = await fs6.readFile(manifestPath, "utf8");
2194
+ await fs6.writeFile(manifestPath, stampManifest(manifest, slug, name), "utf8");
2195
+ const result = {
2196
+ dir: dest,
2197
+ slug,
2198
+ name,
2199
+ kitUrl,
2200
+ next: [
2201
+ `cd ${dest}`,
2202
+ "npm install",
2203
+ `gf apps create --name ${JSON.stringify(name)} --slug ${slug} --kind shell_federated --manifest ./groundfloor.manifest.json`,
2204
+ "npm run release",
2205
+ "gf apps publish --path release.zip"
2206
+ ]
2207
+ };
2208
+ if (opts.json) {
2209
+ process.stdout.write(`${JSON.stringify(result, null, 2)}
2210
+ `);
2211
+ return;
2212
+ }
2213
+ process.stdout.write(`Starter-kit ready in ${dest}
2214
+ `);
2215
+ process.stdout.write(`Stamped APP_ID / manifest appId = ${slug}
2216
+ `);
2217
+ process.stdout.write(
2218
+ "Do not wrap src/App.tsx in BrowserRouter (DevShell already has one).\n"
2219
+ );
2220
+ process.stdout.write("Next:\n");
2221
+ for (const step of result.next) {
2222
+ process.stdout.write(` ${step}
2223
+ `);
2224
+ }
2225
+ }
2226
+
1903
2227
  // src/commands/dataplane.ts
1904
2228
  async function dataplaneStatusCommand(opts) {
1905
2229
  const cfg = await requireWorkspace(opts.workspace);
@@ -2011,10 +2335,30 @@ files.command("download <fileId>").description("Download a file").option("-w, --
2011
2335
  );
2012
2336
  files.command("rm <fileId>").alias("delete").description("Delete a file").option("-w, --workspace <id>", "Workspace id override").action((fileId, opts) => run(() => filesRmCommand(fileId, opts)));
2013
2337
  var apps = program.command("apps").description(
2014
- "Product apps (Shell federated / standalone) \u2014 create, list, publish"
2338
+ "Product apps (Shell federated / standalone) \u2014 init, create, list, publish"
2015
2339
  );
2016
2340
  apps.command("ls").alias("list").description("List apps").option("-w, --workspace <id>", "Workspace id override").option("--json", "Output raw JSON").action((opts) => run(() => appsListCommand(opts)));
2017
2341
  apps.command("get <appId>").description("Show one app (id or slug)").option("-w, --workspace <id>", "Workspace id override").option("--json", "Output raw JSON").action((appId, opts) => run(() => appsGetCommand(appId, opts)));
2342
+ apps.command("init").description(
2343
+ "Download the official Shell starter-kit and stamp APP_ID / manifest appId"
2344
+ ).requiredOption(
2345
+ "--slug <slug>",
2346
+ "Portal app slug (written to APP_ID and groundfloor.manifest.json appId)"
2347
+ ).option("-n, --name <name>", "Display name (default: title-cased slug)").option(
2348
+ "-d, --dir <path>",
2349
+ "Destination directory (default: ./<slug>)"
2350
+ ).option("--kit-url <url>", "Override starter-kit ZIP URL").option("--force", "Overwrite an existing destination directory").option("--json", "Output raw JSON").argument("[dir]", "Destination directory (same as --dir)").action(
2351
+ (dir, opts) => run(
2352
+ () => appsInitCommand({
2353
+ slug: opts.slug,
2354
+ name: opts.name,
2355
+ dir: opts.dir || dir,
2356
+ kitUrl: opts.kitUrl,
2357
+ force: opts.force,
2358
+ json: opts.json
2359
+ })
2360
+ )
2361
+ );
2018
2362
  apps.command("create").description("Register a Shell federated or standalone product app").option("-w, --workspace <id>", "Workspace id override").option("-n, --name <name>", "Display name").option("--slug <slug>", "URL-safe slug (unique in the workspace)").option(
2019
2363
  "--kind <kind>",
2020
2364
  "shell_federated (default) or standalone",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groundfloorcloud/cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Groundfloor gf CLI — sign in, deploy coderunners, publish Shell apps, and manage secrets, files, and Dataplane.",
5
5
  "type": "module",
6
6
  "bin": {