@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.
- package/README.md +19 -3
- package/dist/index.js +362 -18
- 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
|
|
116
|
-
|
|
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(
|
|
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,
|
|
536
|
+
async function cpGet(apiUrl, path7) {
|
|
524
537
|
const token = await getValidAccessToken();
|
|
525
|
-
const res = await fetch(`${apiUrl}${
|
|
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,
|
|
549
|
+
async function cpPostJson(apiUrl, path7, body) {
|
|
537
550
|
const token = await getValidAccessToken();
|
|
538
|
-
const res = await fetch(`${apiUrl}${
|
|
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,
|
|
565
|
+
async function cpDelete(apiUrl, path7) {
|
|
553
566
|
const token = await getValidAccessToken();
|
|
554
|
-
const res = await fetch(`${apiUrl}${
|
|
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,
|
|
575
|
+
async function cpPutJson(apiUrl, path7, body) {
|
|
563
576
|
const token = await getValidAccessToken();
|
|
564
|
-
const res = await fetch(`${apiUrl}${
|
|
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,
|
|
591
|
+
async function cpPatchJson(apiUrl, path7, body) {
|
|
579
592
|
const token = await getValidAccessToken();
|
|
580
|
-
const res = await fetch(`${apiUrl}${
|
|
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,
|
|
607
|
+
async function cpPutBytes(apiUrl, path7, body, contentType) {
|
|
595
608
|
const token = await getValidAccessToken();
|
|
596
|
-
const res = await fetch(`${apiUrl}${
|
|
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
|
-
|
|
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