@groundfloorcloud/cli 0.1.1 → 0.1.3

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 +49 -7
  2. package/dist/index.js +389 -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
@@ -50,15 +54,28 @@ curl -H "Authorization: Bearer $(gf token)" \
50
54
  create if needed, upload a version, wait for the build, deploy, poll until
51
55
  complete, and print IDs and the URL.
52
56
 
53
- Do not create an App just to run a function, job, schedule, or service.
54
- Use `gf deploy`. Bind `--app-id` only when the coderunner should be a helper
55
- of an existing product App.
57
+ Do not create an App just to run a function, job, schedule, or service
58
+ (Deployment). Use `gf deploy`. Bind `--app-id` only when the coderunner should
59
+ be a helper of an existing product App.
60
+
61
+ **Before the first create:** enable workspace Authentication (Administer →
62
+ Authentication, mode `groundfloor` or `external`) until the realm is active.
63
+
64
+ ### Workload types
65
+
66
+ | `--workload-type` | Also called | Package notes |
67
+ |-------------------|-------------|---------------|
68
+ | `function` | Function | HTTP on `PORT`/8080. Node needs `scripts.start`. |
69
+ | `job` | Background job | Run-to-completion. Then `gf coderunner run`. |
70
+ | `schedule` | Scheduled job | Cron. No public URL. |
71
+ | `service` | **Deployment** | Long-running HTTP. **Root `Dockerfile` is required.** |
56
72
 
57
73
  ```bash
58
74
  gf deploy
59
75
  gf deploy --git https://github.com/me/my-fn.git --ref main
60
76
  gf deploy --name my-fn --runtime python --cpu 250m --memory 256Mi \
61
77
  -e API_KEY=secret
78
+ gf deploy --workload-type service
62
79
  gf deploy --app-id <app-uuid>
63
80
  ```
64
81
 
@@ -71,6 +88,19 @@ How the target is chosen:
71
88
 
72
89
  Runtime is auto-detected when omitted (`package.json` → node,
73
90
  `requirements.txt` / `pyproject.toml` → python), defaulting to python.
91
+ Node images run `npm start` — add `"scripts": { "start": "node index.js" }`.
92
+
93
+ ### Local with live data
94
+
95
+ ```bash
96
+ eval "$(gf env)"
97
+ export PORT=8080
98
+ npm start
99
+ curl -sS -H "Authorization: Bearer $(gf token)" \
100
+ "$CONTROLPLANE_URL/v1/workspaces/$GROUNDFLOOR_WORKSPACE_ID/vault/collections"
101
+ ```
102
+
103
+ Then `gf deploy` the same tree. Do not zip `.env` files.
74
104
 
75
105
  ### `groundfloor.json`
76
106
 
@@ -94,7 +124,7 @@ Runtime is auto-detected when omitted (`package.json` → node,
94
124
  | `--git <url>` / `--ref <ref>` | Clone and deploy a remote repo. |
95
125
  | `--subdir <dir>` | Package a subdirectory of the source. |
96
126
  | `--runtime <r>` | `python` \| `node` \| `dotnet-script`. |
97
- | `--workload-type <t>` | `function` \| `service` \| `job` \| `schedule`. |
127
+ | `--workload-type <t>` | `function` \| `service` (Deployment) \| `job` \| `schedule`. Service requires a Dockerfile. |
98
128
  | `--cpu` / `--memory` | Resource requests, e.g. `250m` / `256Mi`. |
99
129
  | `-e, --env KEY=VALUE` | Deployment env var (repeatable). |
100
130
  | `-m, --message <text>` | Version description. |
@@ -105,17 +135,28 @@ Runtime is auto-detected when omitted (`package.json` → node,
105
135
  Product **Apps** are packaging (Shell Module Federation remotes, or a
106
136
  standalone wrapper around a service). That is separate from `gf deploy`.
107
137
 
138
+ **Always start from the official starter-kit.** Do not scaffold `App.tsx` or
139
+ put `BrowserRouter` in the federated export — that crashes the Shell.
140
+
108
141
  ```bash
142
+ gf apps init --slug my-federated-app
143
+ cd my-federated-app
109
144
  gf apps create --name "My Federated App" --slug my-federated-app \
110
145
  --manifest ./groundfloor.manifest.json
146
+ npm install
111
147
  npm run release
112
148
  gf apps publish --path release.zip
113
149
  ```
114
150
 
115
- `gf apps publish` uploads `release.zip` (or `remoteEntry.js`), finalizes the
116
- release, and by default PATCHes the Portal manifest from
151
+ `gf apps init` downloads the public ZIP from the Customer Portal
152
+ (`/downloads/shell-starter-kit.zip` on the current cell) and stamps
153
+ `APP_ID` / `groundfloor.manifest.json` `appId` to the slug.
154
+
155
+ `gf apps publish` uploads `release.zip` (or `remoteEntry.js`), flattens a
156
+ Vite `dist/assets/` layout so chunks sit next to `remoteEntry.js`, finalizes
157
+ the release, and by default PATCHes the Portal manifest from
117
158
  `groundfloor.manifest.json` next to the bundle. Pass an app id/slug, or set
118
- `appId` in that manifest.
159
+ `appId` in that manifest. Prefer `npm run release` over zipping `dist/` by hand.
119
160
 
120
161
  Wrap an existing **service** coderunner as a product:
121
162
 
@@ -164,6 +205,7 @@ gf apps get <id|slug>
164
205
  | `gf domains ls\|add\|rm\|verify -c <id\|slug>` | Custom domains for a service. |
165
206
  | `gf secrets ls\|get\|set\|rm` | Workspace secrets. |
166
207
  | `gf files ls\|upload\|download\|rm` | Workspace files. |
208
+ | `gf apps init --slug <slug>` | Download the official Shell starter-kit and stamp APP_ID. |
167
209
  | `gf apps ls\|get\|create\|publish` | Product apps (Shell publish / standalone wrap). |
168
210
  | `gf dataplane status\|provision` | Dataplane setup for the workspace. |
169
211
 
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}`,
@@ -1226,6 +1239,33 @@ async function deployCommand(opts) {
1226
1239
  `Invalid workload type "${workloadType}" (expected: ${[...WORKLOAD_TYPES].join(", ")}).`
1227
1240
  );
1228
1241
  }
1242
+ if (workloadType === "service") {
1243
+ try {
1244
+ await fs3.access(path3.join(pkg.sourceDir, "Dockerfile"));
1245
+ } catch {
1246
+ throw new Error(
1247
+ 'workload type "service" (also called Deployment) requires a Dockerfile at the project root.'
1248
+ );
1249
+ }
1250
+ }
1251
+ if (runtime === "node") {
1252
+ try {
1253
+ const raw = await fs3.readFile(
1254
+ path3.join(pkg.sourceDir, "package.json"),
1255
+ "utf8"
1256
+ );
1257
+ const npm = JSON.parse(raw);
1258
+ if (!npm.scripts?.start?.trim()) {
1259
+ throw new Error(
1260
+ 'Node Coderunner packages require package.json "scripts.start" (runtime CMD is npm start).'
1261
+ );
1262
+ }
1263
+ } catch (err) {
1264
+ if (err instanceof Error && err.message.includes("scripts.start")) {
1265
+ throw err;
1266
+ }
1267
+ }
1268
+ }
1229
1269
  let coderunnerId = opts.coderunner;
1230
1270
  if (!coderunnerId) {
1231
1271
  const name = opts.name ?? manifest.name ?? (opts.git ? nameFromGitUrl(opts.git) : path3.basename(path3.resolve(opts.path ?? process.cwd())));
@@ -1686,6 +1726,96 @@ async function filesRmCommand(fileId, opts) {
1686
1726
  // src/commands/apps.ts
1687
1727
  import { promises as fs5 } from "fs";
1688
1728
  import path5 from "path";
1729
+
1730
+ // src/release-zip.ts
1731
+ import AdmZip2 from "adm-zip";
1732
+ var REMOTE_ENTRY = "remoteEntry.js";
1733
+ var FLATTEN_SUFFIXES = [".js", ".mjs", ".cjs", ".css", ".map", ".wasm"];
1734
+ function normalizeZipEntryName(filename) {
1735
+ return filename.replace(/\\/g, "/").replace(/^\/+/, "");
1736
+ }
1737
+ function parentDir(rel) {
1738
+ const i = rel.lastIndexOf("/");
1739
+ return i === -1 ? "" : rel.slice(0, i);
1740
+ }
1741
+ function basename(rel) {
1742
+ const i = rel.lastIndexOf("/");
1743
+ return i === -1 ? rel : rel.slice(i + 1);
1744
+ }
1745
+ function chooseRemoteEntry(relpaths) {
1746
+ const entries = relpaths.filter(
1747
+ (p) => p === REMOTE_ENTRY || p.endsWith(`/${REMOTE_ENTRY}`)
1748
+ );
1749
+ if (entries.length === 0) return void 0;
1750
+ if (entries.includes(REMOTE_ENTRY)) return REMOTE_ENTRY;
1751
+ return [...entries].sort((a, b) => {
1752
+ const score = (p) => {
1753
+ const parts = p.split("/");
1754
+ const inAssets = parts.length >= 2 && parts[parts.length - 2] === "assets";
1755
+ return `${inAssets ? 0 : 1}:${parts.length}:${p}`;
1756
+ };
1757
+ return score(a).localeCompare(score(b));
1758
+ })[0];
1759
+ }
1760
+ function flattenFederatedBundlePlan(relpaths) {
1761
+ const paths = relpaths.map(normalizeZipEntryName).filter((name) => name && !name.endsWith("/") && !name.split("/").includes(".."));
1762
+ const pathset = new Set(paths);
1763
+ const entry = chooseRemoteEntry([...pathset]);
1764
+ if (!entry) return {};
1765
+ const promoteDirs = /* @__PURE__ */ new Set();
1766
+ const entryDir = parentDir(entry);
1767
+ if (entryDir) promoteDirs.add(entryDir);
1768
+ for (const path7 of pathset) {
1769
+ const parent = parentDir(path7);
1770
+ if (parent === "assets" || parent.endsWith("/assets") && parent.split("/").length - 1 < 2) {
1771
+ promoteDirs.add(parent);
1772
+ }
1773
+ }
1774
+ const destToSrc = {};
1775
+ for (const src of paths) {
1776
+ const parent = parentDir(src);
1777
+ if (!promoteDirs.has(parent)) continue;
1778
+ const dest = basename(src);
1779
+ if (dest === "release.zip") continue;
1780
+ const lower = dest.toLowerCase();
1781
+ if (dest !== REMOTE_ENTRY && !FLATTEN_SUFFIXES.some((ext) => lower.endsWith(ext))) {
1782
+ continue;
1783
+ }
1784
+ if (pathset.has(dest)) continue;
1785
+ const prev = destToSrc[dest];
1786
+ if (prev === void 0 || src.split("/").length < prev.split("/").length) {
1787
+ destToSrc[dest] = src;
1788
+ }
1789
+ }
1790
+ return destToSrc;
1791
+ }
1792
+ function flattenFederatedReleaseZip(bytes) {
1793
+ const zip = new AdmZip2(bytes);
1794
+ const rels = [];
1795
+ const byNorm = /* @__PURE__ */ new Map();
1796
+ for (const entry of zip.getEntries()) {
1797
+ if (entry.isDirectory) continue;
1798
+ const name = normalizeZipEntryName(entry.entryName);
1799
+ rels.push(name);
1800
+ byNorm.set(name, entry);
1801
+ }
1802
+ const plan = flattenFederatedBundlePlan(rels);
1803
+ const keys = Object.keys(plan);
1804
+ if (keys.length === 0) return bytes;
1805
+ for (const dest of keys) {
1806
+ const src = plan[dest];
1807
+ const source = byNorm.get(src);
1808
+ if (!source) continue;
1809
+ zip.addFile(dest, source.getData());
1810
+ }
1811
+ return zip.toBuffer();
1812
+ }
1813
+ function federatedReleaseZipHint() {
1814
+ 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).";
1815
+ }
1816
+
1817
+ // src/commands/apps.ts
1818
+ import AdmZip3 from "adm-zip";
1689
1819
  var MAX_RELEASE_BYTES = 32 * 1024 * 1024;
1690
1820
  function slugify2(input) {
1691
1821
  const slug = input.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
@@ -1716,6 +1846,33 @@ async function findSiblingManifest(bundlePath) {
1716
1846
  }
1717
1847
  return { path: candidate, manifest: await readJsonObject(candidate) };
1718
1848
  }
1849
+ function zipHasRemoteEntry(bytes) {
1850
+ const zip = new AdmZip3(bytes);
1851
+ return zip.getEntries().some((entry) => {
1852
+ if (entry.isDirectory) return false;
1853
+ const name = normalizeZipEntryName(entry.entryName);
1854
+ return name === REMOTE_ENTRY || name.endsWith(`/${REMOTE_ENTRY}`);
1855
+ });
1856
+ }
1857
+ function prepareReleaseZip(bytes) {
1858
+ if (!zipHasRemoteEntry(bytes)) {
1859
+ throw new Error(
1860
+ `Zip has no remoteEntry.js. ${federatedReleaseZipHint()}`
1861
+ );
1862
+ }
1863
+ const flattened = flattenFederatedReleaseZip(bytes);
1864
+ const plan = flattenFederatedBundlePlan(
1865
+ new AdmZip3(bytes).getEntries().filter((e) => !e.isDirectory).map((e) => normalizeZipEntryName(e.entryName))
1866
+ );
1867
+ if (Object.keys(plan).length > 0) {
1868
+ process.stderr.write(
1869
+ `Note: flattened Vite dist/assets layout so chunks sit next to remoteEntry.js.
1870
+ Prefer: ${federatedReleaseZipHint()}
1871
+ `
1872
+ );
1873
+ }
1874
+ return flattened;
1875
+ }
1719
1876
  async function isDirectory(filePath) {
1720
1877
  try {
1721
1878
  return (await fs5.stat(filePath)).isDirectory();
@@ -1826,7 +1983,12 @@ async function appsCreateCommand(opts) {
1826
1983
  `);
1827
1984
  if (kind === "shell_federated") {
1828
1985
  process.stdout.write(
1829
- "Next: npm run release && gf apps publish --path release.zip\n"
1986
+ `Next:
1987
+ gf apps init --slug ${slug}
1988
+ cd ${slug} && npm install && npm run release
1989
+ gf apps publish --path release.zip
1990
+ Do not scaffold src/App.tsx from scratch \u2014 BrowserRouter in App.tsx crashes the Shell.
1991
+ `
1830
1992
  );
1831
1993
  }
1832
1994
  }
@@ -1865,6 +2027,9 @@ async function appsPublishCommand(appRef, opts) {
1865
2027
  }
1866
2028
  const ext = path5.extname(bundlePath).toLowerCase();
1867
2029
  const isZip = ext === ".zip";
2030
+ if (isZip) {
2031
+ bytes = prepareReleaseZip(bytes);
2032
+ }
1868
2033
  const bundle = isZip ? "zip" : "remote_entry";
1869
2034
  const contentType = isZip ? "application/zip" : "application/javascript";
1870
2035
  if (opts.syncManifest !== false && sibling) {
@@ -1900,6 +2065,192 @@ async function appsPublishCommand(appRef, opts) {
1900
2065
  );
1901
2066
  }
1902
2067
 
2068
+ // src/commands/apps-init.ts
2069
+ import { promises as fs6 } from "fs";
2070
+ import os3 from "os";
2071
+ import path6 from "path";
2072
+ import AdmZip4 from "adm-zip";
2073
+ var MAX_KIT_BYTES = 8 * 1024 * 1024;
2074
+ function slugify3(input) {
2075
+ const slug = input.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
2076
+ return slug || "app";
2077
+ }
2078
+ function titleFromSlug(slug) {
2079
+ return slug.split("-").filter(Boolean).map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ") || slug;
2080
+ }
2081
+ async function pathExists(filePath) {
2082
+ try {
2083
+ await fs6.access(filePath);
2084
+ return true;
2085
+ } catch {
2086
+ return false;
2087
+ }
2088
+ }
2089
+ async function isEmptyDir(dir) {
2090
+ try {
2091
+ const entries = await fs6.readdir(dir);
2092
+ return entries.length === 0;
2093
+ } catch (err) {
2094
+ if (err.code === "ENOENT") return true;
2095
+ throw err;
2096
+ }
2097
+ }
2098
+ async function findKitRoot(extracted) {
2099
+ const candidates = [
2100
+ path6.join(extracted, "shell-starter-kit"),
2101
+ extracted
2102
+ ];
2103
+ const entries = await fs6.readdir(extracted, { withFileTypes: true });
2104
+ for (const entry of entries) {
2105
+ if (entry.isDirectory()) {
2106
+ candidates.push(path6.join(extracted, entry.name));
2107
+ }
2108
+ }
2109
+ for (const candidate of candidates) {
2110
+ if (await pathExists(path6.join(candidate, "groundfloor.manifest.json"))) {
2111
+ return candidate;
2112
+ }
2113
+ }
2114
+ throw new Error(
2115
+ "Starter-kit ZIP did not contain groundfloor.manifest.json. Re-download with `gf apps init`."
2116
+ );
2117
+ }
2118
+ function stampAppIdentity(source, slug) {
2119
+ if (!/export const APP_ID\s*=/.test(source)) {
2120
+ throw new Error("src/appIdentity.ts does not export APP_ID");
2121
+ }
2122
+ const safe = slug.replace(/['"]/g, "");
2123
+ return source.replace(
2124
+ /export const APP_ID\s*=\s*['"][^'"]*['"]/,
2125
+ `export const APP_ID = '${safe}'`
2126
+ );
2127
+ }
2128
+ function stampManifest(raw, slug, name) {
2129
+ const parsed = JSON.parse(raw);
2130
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
2131
+ throw new Error("groundfloor.manifest.json must be a JSON object");
2132
+ }
2133
+ const manifest = parsed;
2134
+ manifest.appId = slug;
2135
+ manifest.name = name;
2136
+ return `${JSON.stringify(manifest, null, 2)}
2137
+ `;
2138
+ }
2139
+ async function downloadKit(url) {
2140
+ let res;
2141
+ try {
2142
+ res = await fetch(url, {
2143
+ redirect: "follow",
2144
+ signal: AbortSignal.timeout(6e4),
2145
+ headers: { "User-Agent": "groundfloor-cli/0.1.1" }
2146
+ });
2147
+ } catch (err) {
2148
+ const message = err instanceof Error ? err.message : String(err);
2149
+ throw new Error(`Could not download starter-kit from ${url}: ${message}`);
2150
+ }
2151
+ if (!res.ok) {
2152
+ throw new Error(
2153
+ `Starter-kit download failed (${res.status}) from ${url}`
2154
+ );
2155
+ }
2156
+ const bytes = Buffer.from(await res.arrayBuffer());
2157
+ if (bytes.byteLength === 0) {
2158
+ throw new Error(`Starter-kit ZIP is empty: ${url}`);
2159
+ }
2160
+ if (bytes.byteLength > MAX_KIT_BYTES) {
2161
+ throw new Error(
2162
+ `Starter-kit ZIP is too large (${bytes.byteLength} bytes) from ${url}`
2163
+ );
2164
+ }
2165
+ if (bytes[0] !== 80 || bytes[1] !== 75) {
2166
+ const head = bytes.subarray(0, 80).toString("utf8");
2167
+ throw new Error(
2168
+ `Expected a ZIP from ${url}, got ${res.headers.get("content-type") ?? "unknown"} (${head.trim()}). Do not scaffold a federated App.tsx from memory.`
2169
+ );
2170
+ }
2171
+ return bytes;
2172
+ }
2173
+ function assertSafeZipEntries(zip) {
2174
+ for (const entry of zip.getEntries()) {
2175
+ const name = entry.entryName.replace(/\\/g, "/");
2176
+ if (name.startsWith("/") || name.includes("..")) {
2177
+ throw new Error(`Refusing starter-kit ZIP with unsafe path: ${name}`);
2178
+ }
2179
+ }
2180
+ }
2181
+ async function appsInitCommand(opts) {
2182
+ const rawSlug = opts.slug?.trim() ?? "";
2183
+ if (!rawSlug) {
2184
+ throw new Error("Pass --slug <portal-slug>.");
2185
+ }
2186
+ const slug = slugify3(rawSlug);
2187
+ if (slug !== rawSlug) {
2188
+ process.stderr.write(`Note: slug normalized to ${slug}
2189
+ `);
2190
+ }
2191
+ const name = opts.name?.trim() || titleFromSlug(slug);
2192
+ const dest = path6.resolve(opts.dir?.trim() || path6.join(process.cwd(), slug));
2193
+ const cfg = await resolveConfig();
2194
+ const cell = cfg.environment ?? "production";
2195
+ const kitUrl = opts.kitUrl?.trim() || starterKitZipUrl(cell);
2196
+ if (!await isEmptyDir(dest) && !opts.force) {
2197
+ throw new Error(
2198
+ `Destination ${dest} is not empty. Pass --force to overwrite, or choose another --dir.`
2199
+ );
2200
+ }
2201
+ if (opts.force && await pathExists(dest)) {
2202
+ await fs6.rm(dest, { recursive: true, force: true });
2203
+ }
2204
+ const bytes = await downloadKit(kitUrl);
2205
+ const zip = new AdmZip4(bytes);
2206
+ assertSafeZipEntries(zip);
2207
+ const tmp = await fs6.mkdtemp(path6.join(os3.tmpdir(), "gf-starter-kit-"));
2208
+ try {
2209
+ zip.extractAllTo(tmp, true);
2210
+ const kitRoot = await findKitRoot(tmp);
2211
+ await fs6.mkdir(dest, { recursive: true });
2212
+ await fs6.cp(kitRoot, dest, { recursive: true, force: true });
2213
+ } finally {
2214
+ await fs6.rm(tmp, { recursive: true, force: true });
2215
+ }
2216
+ const identityPath = path6.join(dest, "src", "appIdentity.ts");
2217
+ const manifestPath = path6.join(dest, "groundfloor.manifest.json");
2218
+ const identity = await fs6.readFile(identityPath, "utf8");
2219
+ await fs6.writeFile(identityPath, stampAppIdentity(identity, slug), "utf8");
2220
+ const manifest = await fs6.readFile(manifestPath, "utf8");
2221
+ await fs6.writeFile(manifestPath, stampManifest(manifest, slug, name), "utf8");
2222
+ const result = {
2223
+ dir: dest,
2224
+ slug,
2225
+ name,
2226
+ kitUrl,
2227
+ next: [
2228
+ `cd ${dest}`,
2229
+ "npm install",
2230
+ `gf apps create --name ${JSON.stringify(name)} --slug ${slug} --kind shell_federated --manifest ./groundfloor.manifest.json`,
2231
+ "npm run release",
2232
+ "gf apps publish --path release.zip"
2233
+ ]
2234
+ };
2235
+ if (opts.json) {
2236
+ process.stdout.write(`${JSON.stringify(result, null, 2)}
2237
+ `);
2238
+ return;
2239
+ }
2240
+ process.stdout.write(`Starter-kit ready in ${dest}
2241
+ `);
2242
+ process.stdout.write(`Stamped APP_ID / manifest appId = ${slug}
2243
+ `);
2244
+ process.stdout.write(
2245
+ "Do not wrap src/App.tsx in BrowserRouter (DevShell already has one).\n"
2246
+ );
2247
+ process.stdout.write("Next:\n");
2248
+ for (const step of result.next) {
2249
+ process.stdout.write(` ${step}
2250
+ `);
2251
+ }
2252
+ }
2253
+
1903
2254
  // src/commands/dataplane.ts
1904
2255
  async function dataplaneStatusCommand(opts) {
1905
2256
  const cfg = await requireWorkspace(opts.workspace);
@@ -2011,10 +2362,30 @@ files.command("download <fileId>").description("Download a file").option("-w, --
2011
2362
  );
2012
2363
  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
2364
  var apps = program.command("apps").description(
2014
- "Product apps (Shell federated / standalone) \u2014 create, list, publish"
2365
+ "Product apps (Shell federated / standalone) \u2014 init, create, list, publish"
2015
2366
  );
2016
2367
  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
2368
  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)));
2369
+ apps.command("init").description(
2370
+ "Download the official Shell starter-kit and stamp APP_ID / manifest appId"
2371
+ ).requiredOption(
2372
+ "--slug <slug>",
2373
+ "Portal app slug (written to APP_ID and groundfloor.manifest.json appId)"
2374
+ ).option("-n, --name <name>", "Display name (default: title-cased slug)").option(
2375
+ "-d, --dir <path>",
2376
+ "Destination directory (default: ./<slug>)"
2377
+ ).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(
2378
+ (dir, opts) => run(
2379
+ () => appsInitCommand({
2380
+ slug: opts.slug,
2381
+ name: opts.name,
2382
+ dir: opts.dir || dir,
2383
+ kitUrl: opts.kitUrl,
2384
+ force: opts.force,
2385
+ json: opts.json
2386
+ })
2387
+ )
2388
+ );
2018
2389
  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
2390
  "--kind <kind>",
2020
2391
  "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.3",
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": {