@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.
- package/README.md +49 -7
- package/dist/index.js +389 -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
|
|
@@ -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
|
|
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
|
|
116
|
-
|
|
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(
|
|
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}`,
|
|
@@ -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
|
-
|
|
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