@groundfloorcloud/cli 0.1.2 → 0.1.4

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 +36 -9
  2. package/dist/index.js +118 -14
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -54,15 +54,28 @@ curl -H "Authorization: Bearer $(gf token)" \
54
54
  create if needed, upload a version, wait for the build, deploy, poll until
55
55
  complete, and print IDs and the URL.
56
56
 
57
- Do not create an App just to run a function, job, schedule, or service.
58
- Use `gf deploy`. Bind `--app-id` only when the coderunner should be a helper
59
- 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.** |
60
72
 
61
73
  ```bash
62
74
  gf deploy
63
75
  gf deploy --git https://github.com/me/my-fn.git --ref main
64
76
  gf deploy --name my-fn --runtime python --cpu 250m --memory 256Mi \
65
77
  -e API_KEY=secret
78
+ gf deploy --workload-type service
66
79
  gf deploy --app-id <app-uuid>
67
80
  ```
68
81
 
@@ -75,6 +88,19 @@ How the target is chosen:
75
88
 
76
89
  Runtime is auto-detected when omitted (`package.json` → node,
77
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.
78
104
 
79
105
  ### `groundfloor.json`
80
106
 
@@ -98,7 +124,7 @@ Runtime is auto-detected when omitted (`package.json` → node,
98
124
  | `--git <url>` / `--ref <ref>` | Clone and deploy a remote repo. |
99
125
  | `--subdir <dir>` | Package a subdirectory of the source. |
100
126
  | `--runtime <r>` | `python` \| `node` \| `dotnet-script`. |
101
- | `--workload-type <t>` | `function` \| `service` \| `job` \| `schedule`. |
127
+ | `--workload-type <t>` | `function` \| `service` (Deployment) \| `job` \| `schedule`. Service requires a Dockerfile. |
102
128
  | `--cpu` / `--memory` | Resource requests, e.g. `250m` / `256Mi`. |
103
129
  | `-e, --env KEY=VALUE` | Deployment env var (repeatable). |
104
130
  | `-m, --message <text>` | Version description. |
@@ -126,11 +152,12 @@ gf apps publish --path release.zip
126
152
  (`/downloads/shell-starter-kit.zip` on the current cell) and stamps
127
153
  `APP_ID` / `groundfloor.manifest.json` `appId` to the slug.
128
154
 
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
132
- `groundfloor.manifest.json` next to the bundle. Pass an app id/slug, or set
133
- `appId` in that manifest. Prefer `npm run release` over zipping `dist/` by hand.
155
+ `gf apps publish` uploads **only** `release.zip` from `npm run release`.
156
+ It flattens a Vite `dist/assets/` layout when needed, then **rejects** a zip
157
+ that is only `remoteEntry.js` (missing `__federation_*.js` / CSS) or whose
158
+ federation `name` does not match `federationRemoteName(slug)`. A lone
159
+ `.js` file is also rejected. Pass an app id/slug, or set `appId` in
160
+ `groundfloor.manifest.json` next to the zip.
134
161
 
135
162
  Wrap an existing **service** coderunner as a product:
136
163
 
package/dist/index.js CHANGED
@@ -1239,6 +1239,33 @@ async function deployCommand(opts) {
1239
1239
  `Invalid workload type "${workloadType}" (expected: ${[...WORKLOAD_TYPES].join(", ")}).`
1240
1240
  );
1241
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
+ }
1242
1269
  let coderunnerId = opts.coderunner;
1243
1270
  if (!coderunnerId) {
1244
1271
  const name = opts.name ?? manifest.name ?? (opts.git ? nameFromGitUrl(opts.git) : path3.basename(path3.resolve(opts.path ?? process.cwd())));
@@ -1783,8 +1810,80 @@ function flattenFederatedReleaseZip(bytes) {
1783
1810
  }
1784
1811
  return zip.toBuffer();
1785
1812
  }
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).";
1813
+ function federatedReleaseZipHint(slug) {
1814
+ return `Run \`npm run release\` in a kit from \`gf apps init\`, then publish that zip (do not zip Vite dist/ or upload only remoteEntry.js). ${initThenPublishHint(slug)}`;
1815
+ }
1816
+ function federationRemoteName(appId) {
1817
+ const sanitized = appId.replace(/[^a-zA-Z0-9_$]/g, "_");
1818
+ return sanitized.length > 0 ? sanitized : "federatedApp";
1819
+ }
1820
+ function initThenPublishHint(slug) {
1821
+ const s = (slug ?? "").trim() || "<portal-slug>";
1822
+ return `gf apps init --slug ${s} && cd ${s} && npm install && npm run release && gf apps publish --path release.zip`;
1823
+ }
1824
+ function extractReferencedSiblings(remoteEntrySource) {
1825
+ const names = /* @__PURE__ */ new Set();
1826
+ for (const match of remoteEntrySource.matchAll(
1827
+ /["']\.\/([^"'/]+\.(?:js|mjs|cjs|css))["']/g
1828
+ )) {
1829
+ if (match[1] !== REMOTE_ENTRY) names.add(match[1]);
1830
+ }
1831
+ for (const match of remoteEntrySource.matchAll(
1832
+ /["']([A-Za-z0-9._-]+\.css)["']/g
1833
+ )) {
1834
+ names.add(match[1]);
1835
+ }
1836
+ return [...names];
1837
+ }
1838
+ function extractFederationScope(remoteEntrySource) {
1839
+ return remoteEntrySource.match(/css__([A-Za-z0-9_-]+)__/)?.[1];
1840
+ }
1841
+ function zipRootNames(bytes) {
1842
+ const zip = new AdmZip2(bytes);
1843
+ return zip.getEntries().filter((entry) => !entry.isDirectory).map((entry) => normalizeZipEntryName(entry.entryName));
1844
+ }
1845
+ function readZipFile(bytes, rel) {
1846
+ const zip = new AdmZip2(bytes);
1847
+ const entry = zip.getEntries().find((item) => {
1848
+ if (item.isDirectory) return false;
1849
+ return normalizeZipEntryName(item.entryName) === rel;
1850
+ });
1851
+ return entry ? entry.getData().toString("utf8") : void 0;
1852
+ }
1853
+ function assertFederatedReleaseZip(bytes, slug) {
1854
+ const hint = initThenPublishHint(slug);
1855
+ const names = zipRootNames(bytes);
1856
+ const present = new Set(names.map((name) => basename(name)));
1857
+ const entryRel = chooseRemoteEntry(names);
1858
+ if (!entryRel) {
1859
+ throw new Error(`Zip has no remoteEntry.js. ${hint}`);
1860
+ }
1861
+ const source = readZipFile(bytes, entryRel);
1862
+ if (!source) {
1863
+ throw new Error(`Zip has no remoteEntry.js. ${hint}`);
1864
+ }
1865
+ const siblings = extractReferencedSiblings(source);
1866
+ const missing = siblings.filter((name) => !present.has(name));
1867
+ const jsOrCssAtRoot = names.filter((name) => {
1868
+ if (name.includes("/")) return false;
1869
+ const lower = name.toLowerCase();
1870
+ return lower.endsWith(".js") || lower.endsWith(".mjs") || lower.endsWith(".css");
1871
+ });
1872
+ if (missing.length > 0 || siblings.length === 0 && jsOrCssAtRoot.length < 2) {
1873
+ const detail = missing.length > 0 ? `Missing next to remoteEntry.js: ${missing.join(", ")}.` : "Zip is only remoteEntry.js (Vite chunks were never packed).";
1874
+ throw new Error(
1875
+ `${detail} Do not zip dist/ or upload a lone remoteEntry.js. ${hint}`
1876
+ );
1877
+ }
1878
+ const scope = extractFederationScope(source);
1879
+ if (slug && scope) {
1880
+ const expected = federationRemoteName(slug);
1881
+ if (scope !== expected) {
1882
+ throw new Error(
1883
+ `Federation name is "${scope}" but Shell looks up "${expected}" (from slug ${slug}). Use the starter-kit vite.config (federationRemoteName). ${hint}`
1884
+ );
1885
+ }
1886
+ }
1788
1887
  }
1789
1888
 
1790
1889
  // src/commands/apps.ts
@@ -1827,10 +1926,10 @@ function zipHasRemoteEntry(bytes) {
1827
1926
  return name === REMOTE_ENTRY || name.endsWith(`/${REMOTE_ENTRY}`);
1828
1927
  });
1829
1928
  }
1830
- function prepareReleaseZip(bytes) {
1929
+ function prepareReleaseZip(bytes, slug) {
1831
1930
  if (!zipHasRemoteEntry(bytes)) {
1832
1931
  throw new Error(
1833
- `Zip has no remoteEntry.js. ${federatedReleaseZipHint()}`
1932
+ `Zip has no remoteEntry.js. ${initThenPublishHint(slug)}`
1834
1933
  );
1835
1934
  }
1836
1935
  const flattened = flattenFederatedReleaseZip(bytes);
@@ -1840,10 +1939,11 @@ function prepareReleaseZip(bytes) {
1840
1939
  if (Object.keys(plan).length > 0) {
1841
1940
  process.stderr.write(
1842
1941
  `Note: flattened Vite dist/assets layout so chunks sit next to remoteEntry.js.
1843
- Prefer: ${federatedReleaseZipHint()}
1942
+ Prefer: ${federatedReleaseZipHint(slug)}
1844
1943
  `
1845
1944
  );
1846
1945
  }
1946
+ assertFederatedReleaseZip(flattened, slug);
1847
1947
  return flattened;
1848
1948
  }
1849
1949
  async function isDirectory(filePath) {
@@ -1942,7 +2042,8 @@ async function appsCreateCommand(opts) {
1942
2042
  } catch (err) {
1943
2043
  if (err instanceof CpError && err.status === 409) {
1944
2044
  throw new Error(
1945
- `App slug ${slug} already exists. Use \`gf apps publish ${slug}\` to ship a build.`
2045
+ `App slug ${slug} already exists. Init the kit if you do not have it, then publish:
2046
+ ${initThenPublishHint(slug)}`
1946
2047
  );
1947
2048
  }
1948
2049
  throw err;
@@ -1974,7 +2075,7 @@ async function appsPublishCommand(appRef, opts) {
1974
2075
  bytes = await fs5.readFile(bundlePath);
1975
2076
  } catch {
1976
2077
  throw new Error(
1977
- `No bundle at ${bundlePath}. Run \`npm run release\` in the starter-kit, then pass --path release.zip.`
2078
+ `No bundle at ${bundlePath}. ${initThenPublishHint()}`
1978
2079
  );
1979
2080
  }
1980
2081
  if (bytes.byteLength === 0) {
@@ -2000,11 +2101,14 @@ async function appsPublishCommand(appRef, opts) {
2000
2101
  }
2001
2102
  const ext = path5.extname(bundlePath).toLowerCase();
2002
2103
  const isZip = ext === ".zip";
2003
- if (isZip) {
2004
- bytes = prepareReleaseZip(bytes);
2104
+ if (!isZip) {
2105
+ throw new Error(
2106
+ `Publish release.zip from \`npm run release\`, not a lone ${path5.basename(bundlePath)}. ` + initThenPublishHint(app.slug)
2107
+ );
2005
2108
  }
2006
- const bundle = isZip ? "zip" : "remote_entry";
2007
- const contentType = isZip ? "application/zip" : "application/javascript";
2109
+ bytes = prepareReleaseZip(bytes, app.slug);
2110
+ const bundle = "zip";
2111
+ const contentType = "application/zip";
2008
2112
  if (opts.syncManifest !== false && sibling) {
2009
2113
  await patchApp(cfg.apiUrl, cfg.workspaceId, app.id, {
2010
2114
  manifest: sibling.manifest
@@ -2274,7 +2378,7 @@ function run(fn) {
2274
2378
  var program = new Command();
2275
2379
  program.name("gf").description(
2276
2380
  "Groundfloor CLI \u2014 sign in, deploy coderunners, publish Shell apps, and manage workspace resources."
2277
- ).version("0.1.1");
2381
+ ).version("0.1.4");
2278
2382
  program.command("login").description(
2279
2383
  "Log in via the browser (production by default; --dev / --stage for other cells)"
2280
2384
  ).option("--dev", "Sign in to Groundfloor dev").option("--stage", "Sign in to Groundfloor stage").option(
@@ -2371,10 +2475,10 @@ apps.command("create").description("Register a Shell federated or standalone pro
2371
2475
  "Path to groundfloor.manifest.json (Shell apps)"
2372
2476
  ).option("--json", "Output raw JSON").action((opts) => run(() => appsCreateCommand(opts)));
2373
2477
  apps.command("publish [appId]").description(
2374
- "Upload release.zip (or remoteEntry.js) and publish a Shell app"
2478
+ "Upload release.zip from npm run release (not a lone remoteEntry.js)"
2375
2479
  ).option("-w, --workspace <id>", "Workspace id override").option("--app <id|slug>", "App id or slug (else manifest appId)").option(
2376
2480
  "-p, --path <file>",
2377
- "release.zip, remoteEntry.js, or a folder containing release.zip",
2481
+ "release.zip from npm run release (or a folder containing it)",
2378
2482
  "release.zip"
2379
2483
  ).option("-m, --label <text>", "Optional release label").option(
2380
2484
  "--no-sync-manifest",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groundfloorcloud/cli",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
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": {