@groundfloorcloud/cli 0.1.0 → 0.1.1

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 +121 -114
  2. package/dist/index.js +745 -29
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -1,15 +1,10 @@
1
1
  # `@groundfloorcloud/cli` (`gf`)
2
2
 
3
- A small, Vercel/`gh`-style CLI to log a developer into the Groundfloor
4
- Control Plane and reuse that session while building locally (in Cursor,
5
- scripts, or any app). It works like `vercel login`: run `gf login` once, the
6
- browser opens, you sign in, and the session is cached and auto-refreshed.
3
+ Sign in to Groundfloor, manage workspaces, and work with Coderunner,
4
+ secrets, files, apps, and Dataplane from your terminal.
7
5
 
8
- The Control Plane never issues its own login tokens it validates identity
9
- provider Bearer JWTs. So `gf` obtains tokens from Groundfloor's managed IdP
10
- (OAuth 2.0 Authorization Code + PKCE) and hands you a valid
11
- `Authorization: Bearer ...` token for CP and Coderunner APIs. Your Portal user
12
- is created just-in-time on the first call.
6
+ `gf login` opens a browser, you sign in, and the session is cached and
7
+ refreshed automatically.
13
8
 
14
9
  ## Install
15
10
 
@@ -18,85 +13,66 @@ npm i -g @groundfloorcloud/cli
18
13
  gf login
19
14
  ```
20
15
 
21
- Or without a global install:
16
+ Without a global install:
22
17
 
23
18
  ```bash
24
19
  npx @groundfloorcloud/cli login
25
20
  ```
26
21
 
27
- ### From this monorepo
28
-
29
- ```bash
30
- npm install
31
- npm run build --workspace=@groundfloorcloud/cli
32
- npm link --workspace=@groundfloorcloud/cli # then: gf --help
33
- ```
22
+ Requires Node.js 20 or later. `gf login` signs in to production
23
+ (`https://platform.groundfloor.cloud`). Use `--dev` or `--stage` for
24
+ other cells; later commands reuse that session.
34
25
 
35
26
  ## Quick start
36
27
 
37
28
  ```bash
38
- gf login # browser sign-in, caches ~/.groundfloor/auth.json
39
- gf whoami # shows identity and verifies CP accepts the token
40
- gf workspaces # list your workspaces
41
- gf workspaces use <uuid> # set a default workspace
42
- gf coderunner ls # list coderunners in the default workspace
29
+ gf login # production
30
+ gf login --stage
31
+ gf login --dev
32
+ gf whoami
33
+ gf workspaces
34
+ gf workspaces use <uuid>
35
+ gf coderunner ls
36
+ gf deploy
43
37
  ```
44
38
 
45
- Use the session from any local app or shell:
39
+ Use the session from a shell or script:
46
40
 
47
41
  ```bash
48
- eval "$(gf env)" # exports GROUNDFLOOR_TOKEN, CONTROLPLANE_URL, ...
42
+ eval "$(gf env)"
49
43
  curl -H "Authorization: Bearer $(gf token)" \
50
- "$CONTROLPLANE_URL/v1/workspaces" | jq .
44
+ "$CONTROLPLANE_URL/v1/workspaces"
51
45
  ```
52
46
 
53
- In Cursor, run `gf login` once, then `gf token` / `gf env` whenever you (or an
54
- agent) need a fresh token to call CP APIs during development.
55
-
56
- ## Deploying a coderunner (D-061)
47
+ ## Deploy workloads
57
48
 
58
- **Coderunner is the deploy path.** Do not create an App to run functions/jobs/services —
59
- `POST …/apps` with `app_kind=coderunner` is rejected. Use `gf deploy`, Control Plane
60
- `/coderunners` APIs, or the `@groundfloor/coderunner-mcp` MCP server.
49
+ `gf deploy` packages your code and runs the full Coderunner lifecycle:
50
+ create if needed, upload a version, wait for the build, deploy, poll until
51
+ complete, and print IDs and the URL.
61
52
 
62
- `gf deploy` packages your code and runs the full lifecycle: create the
63
- coderunner if it does not exist, upload a new version, wait for the build,
64
- deploy, **poll deployment until Completed**, and print IDs/URL.
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.
65
56
 
66
57
  ```bash
67
- # Deploy the current folder (zips the working tree, honoring .gitignore)
68
58
  gf deploy
69
-
70
- # Deploy a remote git/bitbucket repo (upload description includes git_sha=…)
71
59
  gf deploy --git https://github.com/me/my-fn.git --ref main
72
-
73
- # Override target + resources + env
74
60
  gf deploy --name my-fn --runtime python --cpu 250m --memory 256Mi \
75
- -e API_KEY=secret -e STAGE=dev
76
-
77
- # Bind a newly created coderunner as a helper of an App (optional)
61
+ -e API_KEY=secret
78
62
  gf deploy --app-id <app-uuid>
79
63
  ```
80
64
 
81
- **Server push-to-deploy:** connect a repo on the coderunner **Git source** card
82
- (or `PUT …/coderunners/{id}/git-source`). Point GitHub/Gitea at
83
- `POST /v1/webhooks/git/{source_id}` with the returned webhook secret. Private
84
- repos use a workspace Secret named in `secret_key`. App creation is not required.
85
-
86
65
  How the target is chosen:
87
66
 
88
- - `--coderunner <id>` deploys into that exact coderunner.
67
+ - `--coderunner <id>` deploys into that coderunner.
89
68
  - Otherwise the name comes from `--name`, then `groundfloor.json`, then the
90
- folder/repo name; its slug is matched against existing coderunners. A match is
91
- reused; otherwise a new coderunner is created (optionally with `--app-id`).
92
-
93
- Runtime is auto-detected when not given (`package.json` -> node,
94
- `requirements.txt`/`pyproject.toml` -> python), defaulting to python.
69
+ folder or repo name. A matching slug is reused; otherwise a new coderunner
70
+ is created.
95
71
 
96
- ### Optional `groundfloor.json`
72
+ Runtime is auto-detected when omitted (`package.json` → node,
73
+ `requirements.txt` / `pyproject.toml` → python), defaulting to python.
97
74
 
98
- Drop a `groundfloor.json` in your project root to set defaults (CLI flags still
99
- win):
75
+ ### `groundfloor.json`
100
76
 
101
77
  ```json
102
78
  {
@@ -109,13 +85,13 @@ win):
109
85
  }
110
86
  ```
111
87
 
112
- ### Useful flags
88
+ ### Deploy flags
113
89
 
114
90
  | Flag | Description |
115
91
  |------|-------------|
116
- | `-w, --workspace <id>` | Workspace override (else the default from `gf workspaces use`). |
117
- | `-p, --path <dir>` | Local directory to package (default: cwd). |
118
- | `--git <url>` / `--ref <ref>` | Clone and deploy a remote repo at a branch/tag/commit. |
92
+ | `-w, --workspace <id>` | Workspace override (else `gf workspaces use`). |
93
+ | `-p, --path <dir>` | Directory to package (default: cwd). |
94
+ | `--git <url>` / `--ref <ref>` | Clone and deploy a remote repo. |
119
95
  | `--subdir <dir>` | Package a subdirectory of the source. |
120
96
  | `--runtime <r>` | `python` \| `node` \| `dotnet-script`. |
121
97
  | `--workload-type <t>` | `function` \| `service` \| `job` \| `schedule`. |
@@ -124,36 +100,88 @@ win):
124
100
  | `-m, --message <text>` | Version description. |
125
101
  | `--skip-deploy` | Upload a new version but do not deploy it. |
126
102
 
103
+ ## Publish a Shell app
104
+
105
+ Product **Apps** are packaging (Shell Module Federation remotes, or a
106
+ standalone wrapper around a service). That is separate from `gf deploy`.
107
+
108
+ ```bash
109
+ gf apps create --name "My Federated App" --slug my-federated-app \
110
+ --manifest ./groundfloor.manifest.json
111
+ npm run release
112
+ gf apps publish --path release.zip
113
+ ```
114
+
115
+ `gf apps publish` uploads `release.zip` (or `remoteEntry.js`), finalizes the
116
+ release, and by default PATCHes the Portal manifest from
117
+ `groundfloor.manifest.json` next to the bundle. Pass an app id/slug, or set
118
+ `appId` in that manifest.
119
+
120
+ Wrap an existing **service** coderunner as a product:
121
+
122
+ ```bash
123
+ gf apps create --kind standalone --name "My Product" --slug my-product \
124
+ --primary-coderunner <coderunner-id>
125
+ ```
126
+
127
+ Do not create `app_kind=coderunner`. Functions, jobs, schedules, and services
128
+ use `gf deploy`.
129
+
130
+ ## Secrets, files, Dataplane
131
+
132
+ ```bash
133
+ gf dataplane status
134
+ gf dataplane provision
135
+
136
+ gf secrets ls
137
+ gf secrets set DATABASE_URL 'postgres://…'
138
+ gf secrets get DATABASE_URL
139
+ gf secrets rm DATABASE_URL
140
+
141
+ gf files ls
142
+ gf files upload ./report.pdf
143
+ gf files download <file-id> -o ./report.pdf
144
+ gf files rm <file-id>
145
+
146
+ gf apps ls
147
+ gf apps get <id|slug>
148
+ ```
149
+
127
150
  ## Commands
128
151
 
129
152
  | Command | Description |
130
153
  |---------|-------------|
131
- | `gf login [--device]` | Browser PKCE login (or device-code flow with `--device`). |
154
+ | `gf login [--dev\|--stage] [--device]` | Browser sign-in (production default). |
132
155
  | `gf logout` | Clear the cached session. |
133
- | `gf whoami` | Show identity and verify the CP session end-to-end. |
134
- | `gf token` | Print a fresh (auto-refreshed) access token to stdout. |
135
- | `gf env` | Print `export ...` lines for `eval "$(gf env)"`. |
156
+ | `gf whoami` | Show identity and verify the Control Plane session. |
157
+ | `gf token` | Print a fresh access token. |
158
+ | `gf env` | Print `export …` lines for `eval "$(gf env)"`. |
136
159
  | `gf workspaces` (`ws`) | List workspaces; `gf workspaces use <id>` sets the default. |
137
- | `gf coderunner ls` (`cr ls`) | List coderunners in the default (or `--workspace`) workspace. |
138
- | `gf coderunner status -c <id\|slug>` | Status + current deployment JSON. |
139
- | `gf coderunner run -c <id\|slug> [--payload '{}']` | Smoke invoke a deployed function/job. |
140
- | `gf deploy` | Package code (local folder or `--git` repo) and deploy it as a coderunner. |
141
-
142
- ### MCP (agents)
143
-
144
- See [`packages/coderunner-mcp/README.md`](../coderunner-mcp/README.md) and
145
- [`docs/plans/apps-and-coderunner.md`](../../docs/plans/apps-and-coderunner.md) Phase 1.
160
+ | `gf coderunner ls` (`cr ls`) | List coderunners. |
161
+ | `gf coderunner status -c <id\|slug>` | Status and current deployment. |
162
+ | `gf coderunner run -c <id\|slug> [--payload '{}']` | Invoke a deployed function or job. |
163
+ | `gf deploy` | Package code and deploy as a coderunner. |
164
+ | `gf domains ls\|add\|rm\|verify -c <id\|slug>` | Custom domains for a service. |
165
+ | `gf secrets ls\|get\|set\|rm` | Workspace secrets. |
166
+ | `gf files ls\|upload\|download\|rm` | Workspace files. |
167
+ | `gf apps ls\|get\|create\|publish` | Product apps (Shell publish / standalone wrap). |
168
+ | `gf dataplane status\|provision` | Dataplane setup for the workspace. |
146
169
 
147
170
  ## Configuration
148
171
 
149
- Settings resolve with precedence: command flags > environment > config file >
150
- built-in dev defaults.
172
+ Settings resolve as: command flags > environment > `~/.groundfloor/config.json`
173
+ > Groundfloor production.
174
+
175
+ `gf login` with no cell flag **always** uses production, even if a previous
176
+ `--dev` / `--stage` session is still in `config.json`. Pass `--dev`, `--stage`,
177
+ or `--env <production|stage|dev>` to pick another cell. `--api-url` / `--issuer`
178
+ still override the hosts for that login.
151
179
 
152
- | Setting | Flag | Env | Default |
153
- |---------|------|-----|---------|
154
- | IdP issuer | `--issuer` | `GROUNDFLOOR_ISSUER` | `https://auth.dev.groundfloor.cloud/realms/groundfloor_dev` |
180
+ | Setting | Flag | Env | Production default |
181
+ |---------|------|-----|--------------------|
182
+ | Control Plane URL | `--api-url` | `GROUNDFLOOR_API_URL` | `https://platform.groundfloor.cloud` |
183
+ | IdP issuer | `--issuer` | `GROUNDFLOOR_ISSUER` | Groundfloor managed IdP |
155
184
  | OIDC client id | `--client-id` | `GROUNDFLOOR_CLIENT_ID` | `groundfloor-cli` |
156
- | Control Plane URL | `--api-url` | `GROUNDFLOOR_API_URL` | `http://localhost:8088` |
157
185
  | Default workspace | (via `gf workspaces use`) | `GROUNDFLOOR_WORKSPACE_ID` | (none) |
158
186
 
159
187
  State is stored under `~/.groundfloor/`:
@@ -161,41 +189,20 @@ State is stored under `~/.groundfloor/`:
161
189
  - `auth.json` — cached tokens (chmod `600`).
162
190
  - `config.json` — non-secret defaults (issuer, client id, api url, workspace).
163
191
 
164
- `gf login --issuer ... --client-id ... --api-url ...` persists those values so
165
- later commands match the login.
166
-
167
- ## Identity provider setup (one-time)
168
-
169
- `gf` needs a **public** OIDC client (default id `groundfloor-cli`) with:
170
-
171
- - Standard Flow enabled, PKCE method `S256`.
172
- - Valid redirect URIs covering the loopback callback. The CLI redirects to
173
- `http://127.0.0.1:<port>/callback`, so configure **both**:
174
- - `http://127.0.0.1/*`
175
- - `http://localhost/*`
176
- - Optionally, OAuth 2.0 Device Authorization Grant enabled for `gf login --device`.
177
-
178
- Then add the client id to the Control Plane `KEYCLOAK_AUDIENCE`
179
- (see `.env.example`) so its tokens verify. The CLI user still needs workspace
180
- membership in the Portal for workspace-scoped routes.
181
-
182
- ### Redirect URI mismatch ("Invalid parameter: redirect_uri")
183
-
184
- This means the client's Valid Redirect URIs do not match the loopback URL the
185
- CLI sends. Options:
192
+ ### Redirect URI mismatch
186
193
 
187
- - Ensure the client lists `http://127.0.0.1/*` (the CLI defaults to `127.0.0.1`).
188
- - If your IdP only matches `localhost`, run `gf login --redirect-host localhost`.
189
- - If wildcard/port matching is restrictive, pin an exact URI: register
190
- `http://127.0.0.1:8976/callback` on the client and run
191
- `gf login --redirect-port 8976`.
194
+ `gf login` uses a loopback callback (`http://127.0.0.1:<port>/callback`). If
195
+ sign-in fails with an invalid redirect URI:
192
196
 
193
- ## Scope
197
+ - Prefer `127.0.0.1` (the CLI default).
198
+ - If your identity provider only allows `localhost`, run
199
+ `gf login --redirect-host localhost`.
200
+ - To pin a port: `gf login --redirect-port 8976`.
194
201
 
195
- - Phase 1: authenticated developer session (`gf login` + token reuse).
196
- - Phase 2: `gf deploy` for local folders and remote git/bitbucket repos. The CLI
197
- packages the code into a ZIP and drives the Control Plane create -> upload ->
198
- build -> deploy lifecycle (upstream Code Runner is ZIP-based).
202
+ ## More
199
203
 
200
- Server-side push-to-deploy (Control Plane pulling from git via webhooks) is a
201
- possible future enhancement and is not part of this CLI.
204
+ - [Customer Portal](https://app.groundfloor.cloud)
205
+ - [Developers](https://docs.groundfloor.cloud/docs/customer-portal/developers)
206
+ - [Deploy with agents](https://docs.groundfloor.cloud/docs/customer-portal/agents)
207
+ - [Coderunner](https://docs.groundfloor.cloud/docs/customer-portal/coderunner)
208
+ - [API authentication](https://docs.groundfloor.cloud/docs/api/authentication)
package/dist/index.js CHANGED
@@ -64,27 +64,88 @@ async function updateConfig(patch) {
64
64
  }
65
65
 
66
66
  // src/auth/config.ts
67
- var DEFAULTS = {
68
- issuer: "https://auth.dev.groundfloor.cloud/realms/groundfloor_dev",
69
- clientId: "groundfloor-cli",
70
- apiUrl: "http://localhost:8088"
67
+ var CELLS = {
68
+ production: {
69
+ apiUrl: "https://platform.groundfloor.cloud",
70
+ issuer: "https://auth.groundfloor.cloud/realms/groundfloor",
71
+ clientId: "groundfloor-cli"
72
+ },
73
+ stage: {
74
+ apiUrl: "https://platform.stage.groundfloor.cloud",
75
+ issuer: "https://auth.stage.groundfloor.cloud/realms/groundfloor_pico_stage-realm",
76
+ clientId: "groundfloor-cli"
77
+ },
78
+ dev: {
79
+ apiUrl: "https://platform.dev.groundfloor.cloud",
80
+ issuer: "https://auth.dev.groundfloor.cloud/realms/groundfloor_dev",
81
+ clientId: "groundfloor-cli"
82
+ }
83
+ };
84
+ var CELL_ALIASES = {
85
+ production: "production",
86
+ prod: "production",
87
+ stage: "stage",
88
+ staging: "stage",
89
+ dev: "dev",
90
+ development: "dev"
71
91
  };
92
+ function parseCell(raw) {
93
+ const cell = CELL_ALIASES[raw.trim().toLowerCase()];
94
+ if (!cell) {
95
+ throw new Error(
96
+ `Unknown cell "${raw}". Use production, stage, or dev.`
97
+ );
98
+ }
99
+ return cell;
100
+ }
101
+ function cellFromLoginOpts(opts) {
102
+ const selected = [];
103
+ if (opts.dev) selected.push("dev");
104
+ if (opts.stage) selected.push("stage");
105
+ if (opts.env) selected.push(parseCell(opts.env));
106
+ const unique = [...new Set(selected)];
107
+ if (unique.length > 1) {
108
+ throw new Error("Use only one of --dev, --stage, or --env.");
109
+ }
110
+ return unique[0] ?? "production";
111
+ }
112
+ function inferCell(apiUrl, issuer) {
113
+ const api = apiUrl.replace(/\/+$/, "");
114
+ const iss = (issuer ?? "").replace(/\/+$/, "");
115
+ for (const [name, hosts] of Object.entries(CELLS)) {
116
+ if (api === hosts.apiUrl || iss === hosts.issuer) return name;
117
+ }
118
+ return void 0;
119
+ }
72
120
  function trimTrailingSlash(value) {
73
121
  return value.replace(/\/+$/, "");
74
122
  }
75
123
  async function resolveConfig(overrides = {}) {
76
124
  const file = await readConfig();
77
- const issuer = overrides.issuer ?? process.env.GROUNDFLOOR_ISSUER ?? file.issuer ?? DEFAULTS.issuer;
78
- const clientId = overrides.clientId ?? process.env.GROUNDFLOOR_CLIENT_ID ?? file.clientId ?? DEFAULTS.clientId;
79
- const apiUrl = overrides.apiUrl ?? process.env.GROUNDFLOOR_API_URL ?? file.apiUrl ?? DEFAULTS.apiUrl;
125
+ const production = CELLS.production;
126
+ const issuer = overrides.issuer ?? process.env.GROUNDFLOOR_ISSUER ?? file.issuer ?? production.issuer;
127
+ const clientId = overrides.clientId ?? process.env.GROUNDFLOOR_CLIENT_ID ?? file.clientId ?? production.clientId;
128
+ const apiUrl = overrides.apiUrl ?? process.env.GROUNDFLOOR_API_URL ?? file.apiUrl ?? production.apiUrl;
80
129
  const workspaceId = overrides.workspaceId ?? process.env.GROUNDFLOOR_WORKSPACE_ID ?? file.workspaceId;
130
+ const environment = overrides.environment ?? inferCell(apiUrl, issuer) ?? file.environment;
81
131
  return {
82
132
  issuer: trimTrailingSlash(issuer),
83
133
  clientId,
84
134
  apiUrl: trimTrailingSlash(apiUrl),
85
- workspaceId: workspaceId || void 0
135
+ workspaceId: workspaceId || void 0,
136
+ environment
86
137
  };
87
138
  }
139
+ async function requireWorkspace(workspace) {
140
+ const cfg = await resolveConfig({ workspaceId: workspace });
141
+ if (!cfg.workspaceId) {
142
+ throw new Error(
143
+ "No workspace selected. Run `gf workspaces use <id>` or pass --workspace <id>."
144
+ );
145
+ }
146
+ return { ...cfg, workspaceId: cfg.workspaceId };
147
+ }
148
+ var defaults = CELLS.production;
88
149
 
89
150
  // src/auth/oauth.ts
90
151
  import crypto from "crypto";
@@ -382,13 +443,16 @@ async function getValidAccessToken() {
382
443
 
383
444
  // src/commands/login.ts
384
445
  async function loginCommand(opts) {
446
+ const cell = cellFromLoginOpts(opts);
447
+ const hosts = CELLS[cell];
385
448
  const cfg = await resolveConfig({
386
- issuer: opts.issuer,
387
- clientId: opts.clientId,
388
- apiUrl: opts.apiUrl
449
+ issuer: opts.issuer ?? hosts.issuer,
450
+ clientId: opts.clientId ?? hosts.clientId,
451
+ apiUrl: opts.apiUrl ?? hosts.apiUrl,
452
+ environment: cell
389
453
  });
390
454
  process.stderr.write(
391
- `Signing in to ${cfg.issuer}
455
+ `Signing in to Groundfloor ${cell}
392
456
  client: ${cfg.clientId}
393
457
  api: ${cfg.apiUrl}
394
458
  `
@@ -416,7 +480,8 @@ async function loginCommand(opts) {
416
480
  await updateConfig({
417
481
  issuer: cfg.issuer,
418
482
  clientId: cfg.clientId,
419
- apiUrl: cfg.apiUrl
483
+ apiUrl: cfg.apiUrl,
484
+ environment: cell
420
485
  });
421
486
  const claims = decodeJwt(tokens.access_token);
422
487
  const who = claims?.email || claims?.preferred_username || claims?.sub || "unknown user";
@@ -455,9 +520,9 @@ async function parseError(res) {
455
520
  }
456
521
  return `${res.status} ${detail}`;
457
522
  }
458
- async function cpGet(apiUrl, path4) {
523
+ async function cpGet(apiUrl, path6) {
459
524
  const token = await getValidAccessToken();
460
- const res = await fetch(`${apiUrl}${path4}`, {
525
+ const res = await fetch(`${apiUrl}${path6}`, {
461
526
  headers: {
462
527
  Authorization: `Bearer ${token}`,
463
528
  Accept: "application/json"
@@ -468,9 +533,9 @@ async function cpGet(apiUrl, path4) {
468
533
  }
469
534
  return await res.json();
470
535
  }
471
- async function cpPostJson(apiUrl, path4, body) {
536
+ async function cpPostJson(apiUrl, path6, body) {
472
537
  const token = await getValidAccessToken();
473
- const res = await fetch(`${apiUrl}${path4}`, {
538
+ const res = await fetch(`${apiUrl}${path6}`, {
474
539
  method: "POST",
475
540
  headers: {
476
541
  Authorization: `Bearer ${token}`,
@@ -484,9 +549,9 @@ async function cpPostJson(apiUrl, path4, body) {
484
549
  }
485
550
  return await res.json();
486
551
  }
487
- async function cpDelete(apiUrl, path4) {
552
+ async function cpDelete(apiUrl, path6) {
488
553
  const token = await getValidAccessToken();
489
- const res = await fetch(`${apiUrl}${path4}`, {
554
+ const res = await fetch(`${apiUrl}${path6}`, {
490
555
  method: "DELETE",
491
556
  headers: { Authorization: `Bearer ${token}`, Accept: "application/json" }
492
557
  });
@@ -494,6 +559,61 @@ async function cpDelete(apiUrl, path4) {
494
559
  throw new CpError(res.status, await parseError(res));
495
560
  }
496
561
  }
562
+ async function cpPutJson(apiUrl, path6, body) {
563
+ const token = await getValidAccessToken();
564
+ const res = await fetch(`${apiUrl}${path6}`, {
565
+ method: "PUT",
566
+ headers: {
567
+ Authorization: `Bearer ${token}`,
568
+ Accept: "application/json",
569
+ "Content-Type": "application/json"
570
+ },
571
+ body: JSON.stringify(body)
572
+ });
573
+ if (!res.ok) {
574
+ throw new CpError(res.status, await parseError(res));
575
+ }
576
+ return await res.json();
577
+ }
578
+ async function cpPatchJson(apiUrl, path6, body) {
579
+ const token = await getValidAccessToken();
580
+ const res = await fetch(`${apiUrl}${path6}`, {
581
+ method: "PATCH",
582
+ headers: {
583
+ Authorization: `Bearer ${token}`,
584
+ Accept: "application/json",
585
+ "Content-Type": "application/json"
586
+ },
587
+ body: JSON.stringify(body)
588
+ });
589
+ if (!res.ok) {
590
+ throw new CpError(res.status, await parseError(res));
591
+ }
592
+ return await res.json();
593
+ }
594
+ async function cpPutBytes(apiUrl, path6, body, contentType) {
595
+ const token = await getValidAccessToken();
596
+ const res = await fetch(`${apiUrl}${path6}`, {
597
+ method: "PUT",
598
+ headers: {
599
+ Authorization: `Bearer ${token}`,
600
+ Accept: "application/json",
601
+ "Content-Type": contentType
602
+ },
603
+ body
604
+ });
605
+ if (!res.ok) {
606
+ throw new CpError(res.status, await parseError(res));
607
+ }
608
+ }
609
+ function queryString(params) {
610
+ const u = new URLSearchParams();
611
+ for (const [key, value] of Object.entries(params)) {
612
+ if (value) u.set(key, value);
613
+ }
614
+ const s = u.toString();
615
+ return s ? `?${s}` : "";
616
+ }
497
617
  function listWorkspaces(apiUrl) {
498
618
  return cpGet(apiUrl, "/v1/workspaces");
499
619
  }
@@ -624,6 +744,134 @@ function verifyDomain(apiUrl, workspaceId, coderunnerId, domain) {
624
744
  {}
625
745
  );
626
746
  }
747
+ function wsPath(workspaceId, suffix) {
748
+ return `/v1/workspaces/${encodeURIComponent(workspaceId)}${suffix}`;
749
+ }
750
+ function listSecrets(apiUrl, workspaceId, environmentId) {
751
+ return cpGet(
752
+ apiUrl,
753
+ `${wsPath(workspaceId, "/secrets")}${queryString({
754
+ environment_id: environmentId
755
+ })}`
756
+ );
757
+ }
758
+ function getSecret(apiUrl, workspaceId, key, environmentId) {
759
+ return cpGet(
760
+ apiUrl,
761
+ `${wsPath(workspaceId, `/secrets/${encodeURIComponent(key)}`)}${queryString({
762
+ environment_id: environmentId
763
+ })}`
764
+ );
765
+ }
766
+ function putSecret(apiUrl, workspaceId, key, payload, environmentId) {
767
+ return cpPutJson(
768
+ apiUrl,
769
+ `${wsPath(workspaceId, `/secrets/${encodeURIComponent(key)}`)}${queryString({
770
+ environment_id: environmentId
771
+ })}`,
772
+ payload
773
+ );
774
+ }
775
+ function deleteSecret(apiUrl, workspaceId, key, environmentId) {
776
+ return cpDelete(
777
+ apiUrl,
778
+ `${wsPath(workspaceId, `/secrets/${encodeURIComponent(key)}`)}${queryString({
779
+ environment_id: environmentId
780
+ })}`
781
+ );
782
+ }
783
+ function listFiles(apiUrl, workspaceId) {
784
+ return cpGet(apiUrl, wsPath(workspaceId, "/files"));
785
+ }
786
+ function initFileUpload(apiUrl, workspaceId, payload) {
787
+ return cpPostJson(
788
+ apiUrl,
789
+ wsPath(workspaceId, "/files/upload-url"),
790
+ payload
791
+ );
792
+ }
793
+ function finalizeFileUpload(apiUrl, workspaceId, fileId) {
794
+ return cpPostJson(
795
+ apiUrl,
796
+ wsPath(workspaceId, `/files/${encodeURIComponent(fileId)}/finalize`),
797
+ {}
798
+ );
799
+ }
800
+ function getFileDownloadUrl(apiUrl, workspaceId, fileId) {
801
+ return cpGet(
802
+ apiUrl,
803
+ wsPath(workspaceId, `/files/${encodeURIComponent(fileId)}/download-url`)
804
+ );
805
+ }
806
+ function deleteFile(apiUrl, workspaceId, fileId) {
807
+ return cpDelete(
808
+ apiUrl,
809
+ wsPath(workspaceId, `/files/${encodeURIComponent(fileId)}`)
810
+ );
811
+ }
812
+ function listApps(apiUrl, workspaceId) {
813
+ return cpGet(
814
+ apiUrl,
815
+ `${wsPath(workspaceId, "/apps")}${queryString({ kind: "product" })}`
816
+ );
817
+ }
818
+ function getApp(apiUrl, workspaceId, appId) {
819
+ return cpGet(
820
+ apiUrl,
821
+ wsPath(workspaceId, `/apps/${encodeURIComponent(appId)}`)
822
+ );
823
+ }
824
+ function createApp(apiUrl, workspaceId, body) {
825
+ return cpPostJson(apiUrl, wsPath(workspaceId, "/apps"), body);
826
+ }
827
+ function patchApp(apiUrl, workspaceId, appId, body) {
828
+ return cpPatchJson(
829
+ apiUrl,
830
+ wsPath(workspaceId, `/apps/${encodeURIComponent(appId)}`),
831
+ body
832
+ );
833
+ }
834
+ function initAppReleaseUpload(apiUrl, workspaceId, appId, body) {
835
+ return cpPostJson(
836
+ apiUrl,
837
+ wsPath(workspaceId, `/apps/${encodeURIComponent(appId)}/releases/upload-url`),
838
+ body
839
+ );
840
+ }
841
+ function uploadAppReleaseContent(apiUrl, workspaceId, appId, releaseId, bytes, contentType) {
842
+ return cpPutBytes(
843
+ apiUrl,
844
+ wsPath(
845
+ workspaceId,
846
+ `/apps/${encodeURIComponent(appId)}/releases/${encodeURIComponent(releaseId)}/content`
847
+ ),
848
+ bytes,
849
+ contentType
850
+ );
851
+ }
852
+ function finalizeAppRelease(apiUrl, workspaceId, appId, releaseId) {
853
+ return cpPostJson(
854
+ apiUrl,
855
+ wsPath(
856
+ workspaceId,
857
+ `/apps/${encodeURIComponent(appId)}/releases/${encodeURIComponent(releaseId)}/finalize`
858
+ ),
859
+ {}
860
+ );
861
+ }
862
+ function getDataplaneStatus(apiUrl, workspaceId) {
863
+ return cpGet(
864
+ apiUrl,
865
+ wsPath(workspaceId, "/dataplane/status")
866
+ );
867
+ }
868
+ function provisionDataplane(apiUrl, workspaceId) {
869
+ return cpPostJson(
870
+ apiUrl,
871
+ wsPath(workspaceId, "/dataplane/provision"),
872
+ {}
873
+ );
874
+ }
627
875
 
628
876
  // src/commands/whoami.ts
629
877
  async function whoamiCommand() {
@@ -636,6 +884,10 @@ async function whoamiCommand() {
636
884
  `);
637
885
  process.stdout.write(`Subject: ${subject}
638
886
  `);
887
+ if (cfg.environment) {
888
+ process.stdout.write(`Cell: ${cfg.environment}
889
+ `);
890
+ }
639
891
  process.stdout.write(`Issuer: ${cfg.issuer}
640
892
  `);
641
893
  process.stdout.write(`API: ${cfg.apiUrl}
@@ -666,6 +918,9 @@ async function envCommand() {
666
918
  if (cfg.workspaceId) {
667
919
  lines.push(`export GROUNDFLOOR_WORKSPACE_ID=${cfg.workspaceId}`);
668
920
  }
921
+ if (cfg.environment) {
922
+ lines.push(`export GROUNDFLOOR_ENV=${cfg.environment}`);
923
+ }
669
924
  process.stdout.write(`${lines.join("\n")}
670
925
  `);
671
926
  }
@@ -803,9 +1058,9 @@ async function walkFileList(dir) {
803
1058
  await walk(dir, "");
804
1059
  return out;
805
1060
  }
806
- async function buildZip(sourceDir, files) {
1061
+ async function buildZip(sourceDir, files2) {
807
1062
  const zip = new AdmZip();
808
- for (const rel of files) {
1063
+ for (const rel of files2) {
809
1064
  const abs = path2.join(sourceDir, rel);
810
1065
  try {
811
1066
  const data = await fs2.readFile(abs);
@@ -856,13 +1111,13 @@ async function packageSource(opts) {
856
1111
  throw new Error(`Not a directory: ${sourceDir}`);
857
1112
  }
858
1113
  const useGit = await isGitRepo(sourceDir);
859
- const files = useGit ? await gitFileList(sourceDir) : await walkFileList(sourceDir);
860
- if (files.length === 0) {
1114
+ const files2 = useGit ? await gitFileList(sourceDir) : await walkFileList(sourceDir);
1115
+ if (files2.length === 0) {
861
1116
  throw new Error(
862
1117
  `No files to package in ${sourceDir} (everything ignored or empty).`
863
1118
  );
864
1119
  }
865
- const zip = await buildZip(sourceDir, files);
1120
+ const zip = await buildZip(sourceDir, files2);
866
1121
  let gitSha = null;
867
1122
  const shaRoot = cleanup ?? (useGit ? sourceDir : null);
868
1123
  if (shaRoot) {
@@ -873,7 +1128,7 @@ async function packageSource(opts) {
873
1128
  gitSha = null;
874
1129
  }
875
1130
  }
876
- return { zip, fileCount: files.length, sourceDir, cleanup, gitSha };
1131
+ return { zip, fileCount: files2.length, sourceDir, cleanup, gitSha };
877
1132
  } catch (err) {
878
1133
  if (cleanup) {
879
1134
  await fs2.rm(cleanup, { recursive: true, force: true }).catch(() => {
@@ -1271,6 +1526,415 @@ async function domainsVerifyCommand(domain, opts) {
1271
1526
  }
1272
1527
  }
1273
1528
 
1529
+ // src/commands/secrets.ts
1530
+ async function readValue(value) {
1531
+ if (value === void 0) {
1532
+ throw new Error("Pass a value, or `-` to read from stdin.");
1533
+ }
1534
+ if (value === "-") {
1535
+ const chunks = [];
1536
+ for await (const chunk of process.stdin) {
1537
+ chunks.push(Buffer.from(chunk));
1538
+ }
1539
+ return Buffer.concat(chunks).toString("utf8").replace(/\n$/, "");
1540
+ }
1541
+ return value;
1542
+ }
1543
+ async function secretsListCommand(opts) {
1544
+ const cfg = await requireWorkspace(opts.workspace);
1545
+ const data = await listSecrets(cfg.apiUrl, cfg.workspaceId, opts.environment);
1546
+ if (opts.json) {
1547
+ process.stdout.write(`${JSON.stringify(data, null, 2)}
1548
+ `);
1549
+ return;
1550
+ }
1551
+ if (data.secrets.length === 0) {
1552
+ process.stdout.write("No secrets in this workspace.\n");
1553
+ return;
1554
+ }
1555
+ for (const secret of data.secrets) {
1556
+ process.stdout.write(`${secret.key}
1557
+ `);
1558
+ }
1559
+ }
1560
+ async function secretsGetCommand(key, opts) {
1561
+ const cfg = await requireWorkspace(opts.workspace);
1562
+ const secret = await getSecret(
1563
+ cfg.apiUrl,
1564
+ cfg.workspaceId,
1565
+ key,
1566
+ opts.environment
1567
+ );
1568
+ if (opts.json) {
1569
+ process.stdout.write(`${JSON.stringify(secret, null, 2)}
1570
+ `);
1571
+ return;
1572
+ }
1573
+ process.stdout.write(`${secret.value}
1574
+ `);
1575
+ }
1576
+ async function secretsSetCommand(key, value, opts) {
1577
+ const cfg = await requireWorkspace(opts.workspace);
1578
+ const resolved = await readValue(value);
1579
+ const saved = await putSecret(
1580
+ cfg.apiUrl,
1581
+ cfg.workspaceId,
1582
+ key,
1583
+ { value: resolved, description: opts.description },
1584
+ opts.environment
1585
+ );
1586
+ process.stdout.write(`Saved secret ${saved.key}
1587
+ `);
1588
+ }
1589
+ async function secretsRmCommand(key, opts) {
1590
+ const cfg = await requireWorkspace(opts.workspace);
1591
+ await deleteSecret(cfg.apiUrl, cfg.workspaceId, key, opts.environment);
1592
+ process.stdout.write(`Deleted secret ${key}
1593
+ `);
1594
+ }
1595
+
1596
+ // src/commands/files.ts
1597
+ import { promises as fs4 } from "fs";
1598
+ import path4 from "path";
1599
+ function guessContentType(filename) {
1600
+ const ext = path4.extname(filename).toLowerCase();
1601
+ const types = {
1602
+ ".json": "application/json",
1603
+ ".txt": "text/plain",
1604
+ ".csv": "text/csv",
1605
+ ".pdf": "application/pdf",
1606
+ ".png": "image/png",
1607
+ ".jpg": "image/jpeg",
1608
+ ".jpeg": "image/jpeg",
1609
+ ".zip": "application/zip"
1610
+ };
1611
+ return types[ext] ?? "application/octet-stream";
1612
+ }
1613
+ async function filesListCommand(opts) {
1614
+ const cfg = await requireWorkspace(opts.workspace);
1615
+ const data = await listFiles(cfg.apiUrl, cfg.workspaceId);
1616
+ if (opts.json) {
1617
+ process.stdout.write(`${JSON.stringify(data, null, 2)}
1618
+ `);
1619
+ return;
1620
+ }
1621
+ if (data.files.length === 0) {
1622
+ process.stdout.write("No files in this workspace.\n");
1623
+ return;
1624
+ }
1625
+ for (const file of data.files) {
1626
+ const size = file.size_bytes != null ? `${file.size_bytes}B` : "";
1627
+ process.stdout.write(
1628
+ `${file.id} ${file.name ?? ""} ${size} [${file.status ?? ""}]
1629
+ `
1630
+ );
1631
+ }
1632
+ }
1633
+ async function filesUploadCommand(filePath, opts) {
1634
+ const cfg = await requireWorkspace(opts.workspace);
1635
+ const resolved = path4.resolve(filePath);
1636
+ const buf = await fs4.readFile(resolved);
1637
+ const name = opts.name ?? path4.basename(resolved);
1638
+ const contentType = guessContentType(name);
1639
+ const init = await initFileUpload(cfg.apiUrl, cfg.workspaceId, {
1640
+ name,
1641
+ content_type: contentType,
1642
+ size_bytes: buf.byteLength
1643
+ });
1644
+ const put = await fetch(init.upload_url, {
1645
+ method: "PUT",
1646
+ headers: { "Content-Type": contentType },
1647
+ body: buf
1648
+ });
1649
+ if (!put.ok) {
1650
+ throw new Error(
1651
+ `Upload to object storage failed: ${put.status} ${put.statusText}`
1652
+ );
1653
+ }
1654
+ const file = await finalizeFileUpload(
1655
+ cfg.apiUrl,
1656
+ cfg.workspaceId,
1657
+ init.file.id
1658
+ );
1659
+ process.stdout.write(`Uploaded ${file.id} ${file.name ?? name}
1660
+ `);
1661
+ }
1662
+ async function filesDownloadCommand(fileId, opts) {
1663
+ const cfg = await requireWorkspace(opts.workspace);
1664
+ const { download_url: url } = await getFileDownloadUrl(
1665
+ cfg.apiUrl,
1666
+ cfg.workspaceId,
1667
+ fileId
1668
+ );
1669
+ const res = await fetch(url);
1670
+ if (!res.ok) {
1671
+ throw new Error(`Download failed: ${res.status} ${res.statusText}`);
1672
+ }
1673
+ const buf = Buffer.from(await res.arrayBuffer());
1674
+ const dest = path4.resolve(opts.out ?? fileId);
1675
+ await fs4.writeFile(dest, buf);
1676
+ process.stdout.write(`Wrote ${dest}
1677
+ `);
1678
+ }
1679
+ async function filesRmCommand(fileId, opts) {
1680
+ const cfg = await requireWorkspace(opts.workspace);
1681
+ await deleteFile(cfg.apiUrl, cfg.workspaceId, fileId);
1682
+ process.stdout.write(`Deleted file ${fileId}
1683
+ `);
1684
+ }
1685
+
1686
+ // src/commands/apps.ts
1687
+ import { promises as fs5 } from "fs";
1688
+ import path5 from "path";
1689
+ var MAX_RELEASE_BYTES = 32 * 1024 * 1024;
1690
+ function slugify2(input) {
1691
+ const slug = input.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
1692
+ return slug || "app";
1693
+ }
1694
+ function formatApp(app) {
1695
+ return `${app.id} ${app.name ?? app.slug ?? ""} ${app.app_kind ?? ""} [${app.status ?? ""}]`;
1696
+ }
1697
+ async function readJsonObject(filePath) {
1698
+ const raw = await fs5.readFile(filePath, "utf8");
1699
+ const parsed = JSON.parse(raw);
1700
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
1701
+ throw new Error(`${filePath} must contain a JSON object`);
1702
+ }
1703
+ return parsed;
1704
+ }
1705
+ async function maybeReadManifest(filePath) {
1706
+ if (!filePath) return void 0;
1707
+ return readJsonObject(path5.resolve(filePath));
1708
+ }
1709
+ async function findSiblingManifest(bundlePath) {
1710
+ const dir = await isDirectory(bundlePath) ? bundlePath : path5.dirname(bundlePath);
1711
+ const candidate = path5.join(dir, "groundfloor.manifest.json");
1712
+ try {
1713
+ await fs5.access(candidate);
1714
+ } catch {
1715
+ return void 0;
1716
+ }
1717
+ return { path: candidate, manifest: await readJsonObject(candidate) };
1718
+ }
1719
+ async function isDirectory(filePath) {
1720
+ try {
1721
+ return (await fs5.stat(filePath)).isDirectory();
1722
+ } catch {
1723
+ return false;
1724
+ }
1725
+ }
1726
+ async function resolveAppId(apiUrl, workspaceId, idOrSlug) {
1727
+ const listed = await listApps(apiUrl, workspaceId);
1728
+ const match = listed.apps.find(
1729
+ (app) => app.id === idOrSlug || app.slug === idOrSlug
1730
+ );
1731
+ if (match) return match;
1732
+ try {
1733
+ return await getApp(apiUrl, workspaceId, idOrSlug);
1734
+ } catch (err) {
1735
+ if (err instanceof CpError && err.status === 404) {
1736
+ throw new Error(
1737
+ `App not found: ${idOrSlug}. Run \`gf apps ls\` or \`gf apps create\`.`
1738
+ );
1739
+ }
1740
+ throw err;
1741
+ }
1742
+ }
1743
+ async function appsListCommand(opts) {
1744
+ const cfg = await requireWorkspace(opts.workspace);
1745
+ const data = await listApps(cfg.apiUrl, cfg.workspaceId);
1746
+ if (opts.json) {
1747
+ process.stdout.write(`${JSON.stringify(data, null, 2)}
1748
+ `);
1749
+ return;
1750
+ }
1751
+ if (data.apps.length === 0) {
1752
+ process.stdout.write("No apps in this workspace.\n");
1753
+ return;
1754
+ }
1755
+ for (const app of data.apps) {
1756
+ process.stdout.write(`${formatApp(app)}
1757
+ `);
1758
+ }
1759
+ }
1760
+ async function appsGetCommand(appId, opts) {
1761
+ const cfg = await requireWorkspace(opts.workspace);
1762
+ const app = await resolveAppId(cfg.apiUrl, cfg.workspaceId, appId);
1763
+ if (opts.json) {
1764
+ process.stdout.write(`${JSON.stringify(app, null, 2)}
1765
+ `);
1766
+ return;
1767
+ }
1768
+ process.stdout.write(`${formatApp(app)}
1769
+ `);
1770
+ }
1771
+ async function appsCreateCommand(opts) {
1772
+ const cfg = await requireWorkspace(opts.workspace);
1773
+ const kindRaw = (opts.kind ?? "shell_federated").trim();
1774
+ if (kindRaw === "coderunner") {
1775
+ throw new Error(
1776
+ "Do not create app_kind=coderunner. Deploy workloads with `gf deploy`. Use --kind shell_federated or --kind standalone."
1777
+ );
1778
+ }
1779
+ if (kindRaw !== "shell_federated" && kindRaw !== "standalone") {
1780
+ throw new Error(
1781
+ `--kind must be shell_federated or standalone (got ${kindRaw})`
1782
+ );
1783
+ }
1784
+ const kind = kindRaw;
1785
+ const localManifest = await maybeReadManifest(opts.manifest);
1786
+ const name = opts.name?.trim() || (typeof localManifest?.name === "string" ? localManifest.name : "");
1787
+ const slug = opts.slug?.trim() || (typeof localManifest?.appId === "string" ? localManifest.appId : name ? slugify2(name) : "");
1788
+ if (!name) {
1789
+ throw new Error("Pass --name, or a manifest with a name field.");
1790
+ }
1791
+ if (!slug) {
1792
+ throw new Error("Pass --slug, or a manifest with appId.");
1793
+ }
1794
+ if (kind === "standalone" && !opts.primaryCoderunner?.trim()) {
1795
+ throw new Error(
1796
+ "Standalone apps require --primary-coderunner <id> (an existing service coderunner)."
1797
+ );
1798
+ }
1799
+ const body = {
1800
+ name,
1801
+ slug,
1802
+ app_kind: kind
1803
+ };
1804
+ if (opts.environment) body.environment_id = opts.environment;
1805
+ if (opts.primaryCoderunner) {
1806
+ body.primary_coderunner_id = opts.primaryCoderunner;
1807
+ }
1808
+ if (localManifest) body.manifest = localManifest;
1809
+ let app;
1810
+ try {
1811
+ app = await createApp(cfg.apiUrl, cfg.workspaceId, body);
1812
+ } catch (err) {
1813
+ if (err instanceof CpError && err.status === 409) {
1814
+ throw new Error(
1815
+ `App slug ${slug} already exists. Use \`gf apps publish ${slug}\` to ship a build.`
1816
+ );
1817
+ }
1818
+ throw err;
1819
+ }
1820
+ if (opts.json) {
1821
+ process.stdout.write(`${JSON.stringify(app, null, 2)}
1822
+ `);
1823
+ return;
1824
+ }
1825
+ process.stdout.write(`Created ${formatApp(app)}
1826
+ `);
1827
+ if (kind === "shell_federated") {
1828
+ process.stdout.write(
1829
+ "Next: npm run release && gf apps publish --path release.zip\n"
1830
+ );
1831
+ }
1832
+ }
1833
+ async function appsPublishCommand(appRef, opts) {
1834
+ const cfg = await requireWorkspace(opts.workspace);
1835
+ const inputPath = path5.resolve(opts.path ?? "release.zip");
1836
+ const bundlePath = await isDirectory(inputPath) ? path5.join(inputPath, "release.zip") : inputPath;
1837
+ let bytes;
1838
+ try {
1839
+ bytes = await fs5.readFile(bundlePath);
1840
+ } catch {
1841
+ throw new Error(
1842
+ `No bundle at ${bundlePath}. Run \`npm run release\` in the starter-kit, then pass --path release.zip.`
1843
+ );
1844
+ }
1845
+ if (bytes.byteLength === 0) {
1846
+ throw new Error(`Bundle is empty: ${bundlePath}`);
1847
+ }
1848
+ if (bytes.byteLength > MAX_RELEASE_BYTES) {
1849
+ throw new Error(
1850
+ `Bundle exceeds ${MAX_RELEASE_BYTES} bytes (${bytes.byteLength}).`
1851
+ );
1852
+ }
1853
+ const sibling = await findSiblingManifest(bundlePath);
1854
+ const ref = appRef?.trim() || opts.app?.trim() || (typeof sibling?.manifest.appId === "string" ? sibling.manifest.appId : "");
1855
+ if (!ref) {
1856
+ throw new Error(
1857
+ "Pass an app id or slug, or set appId in groundfloor.manifest.json next to the bundle."
1858
+ );
1859
+ }
1860
+ const app = await resolveAppId(cfg.apiUrl, cfg.workspaceId, ref);
1861
+ if (app.app_kind && app.app_kind !== "shell_federated") {
1862
+ throw new Error(
1863
+ `Releases are only for shell_federated apps (this app is ${app.app_kind}). Use \`gf deploy\` for coderunner workloads.`
1864
+ );
1865
+ }
1866
+ const ext = path5.extname(bundlePath).toLowerCase();
1867
+ const isZip = ext === ".zip";
1868
+ const bundle = isZip ? "zip" : "remote_entry";
1869
+ const contentType = isZip ? "application/zip" : "application/javascript";
1870
+ if (opts.syncManifest !== false && sibling) {
1871
+ await patchApp(cfg.apiUrl, cfg.workspaceId, app.id, {
1872
+ manifest: sibling.manifest
1873
+ });
1874
+ }
1875
+ const init = await initAppReleaseUpload(cfg.apiUrl, cfg.workspaceId, app.id, {
1876
+ bundle,
1877
+ label: opts.label
1878
+ });
1879
+ await uploadAppReleaseContent(
1880
+ cfg.apiUrl,
1881
+ cfg.workspaceId,
1882
+ app.id,
1883
+ init.release.id,
1884
+ bytes,
1885
+ contentType
1886
+ );
1887
+ const release = await finalizeAppRelease(
1888
+ cfg.apiUrl,
1889
+ cfg.workspaceId,
1890
+ app.id,
1891
+ init.release.id
1892
+ );
1893
+ if (opts.json) {
1894
+ process.stdout.write(`${JSON.stringify({ app, release }, null, 2)}
1895
+ `);
1896
+ return;
1897
+ }
1898
+ process.stdout.write(
1899
+ `Published ${app.slug ?? app.id} build ${release.build_number}` + (release.remote_url ? ` ${release.remote_url}` : "") + "\n"
1900
+ );
1901
+ }
1902
+
1903
+ // src/commands/dataplane.ts
1904
+ async function dataplaneStatusCommand(opts) {
1905
+ const cfg = await requireWorkspace(opts.workspace);
1906
+ const status = await getDataplaneStatus(cfg.apiUrl, cfg.workspaceId);
1907
+ if (opts.json) {
1908
+ process.stdout.write(`${JSON.stringify(status, null, 2)}
1909
+ `);
1910
+ return;
1911
+ }
1912
+ const ready = status.service_key_configured ? "configured" : "not configured";
1913
+ process.stdout.write(
1914
+ `Dataplane ${ready}${status.dataplane_tenant_id ? ` tenant ${status.dataplane_tenant_id}` : ""}
1915
+ `
1916
+ );
1917
+ }
1918
+ async function dataplaneProvisionCommand(opts) {
1919
+ const cfg = await requireWorkspace(opts.workspace);
1920
+ const result = await provisionDataplane(cfg.apiUrl, cfg.workspaceId);
1921
+ if (opts.json) {
1922
+ process.stdout.write(`${JSON.stringify(result, null, 2)}
1923
+ `);
1924
+ return;
1925
+ }
1926
+ if (result.created) {
1927
+ process.stdout.write("Dataplane provisioned for this workspace.\n");
1928
+ if (result.api_key) {
1929
+ process.stderr.write(
1930
+ "A service key was minted into workspace secrets. It is not printed again.\n"
1931
+ );
1932
+ }
1933
+ } else {
1934
+ process.stdout.write("Dataplane already configured for this workspace.\n");
1935
+ }
1936
+ }
1937
+
1274
1938
  // src/index.ts
1275
1939
  function collect(value, previous) {
1276
1940
  return previous.concat([value]);
@@ -1285,9 +1949,14 @@ function run(fn) {
1285
1949
  }
1286
1950
  var program = new Command();
1287
1951
  program.name("gf").description(
1288
- "Groundfloor CLI \u2014 log in to the Control Plane and reuse the session during local development."
1289
- ).version("0.1.0");
1290
- program.command("login").description("Log in via the browser (or --device) and cache the session").option("--issuer <url>", "Identity provider issuer URL").option("--client-id <id>", "OIDC client id").option("--api-url <url>", "Control Plane base URL").option("--device", "Use the device-code flow (headless / no local browser)").option(
1952
+ "Groundfloor CLI \u2014 sign in, deploy coderunners, publish Shell apps, and manage workspace resources."
1953
+ ).version("0.1.1");
1954
+ program.command("login").description(
1955
+ "Log in via the browser (production by default; --dev / --stage for other cells)"
1956
+ ).option("--dev", "Sign in to Groundfloor dev").option("--stage", "Sign in to Groundfloor stage").option(
1957
+ "--env <name>",
1958
+ "Cell to sign in to: production (default), stage, or dev"
1959
+ ).option("--issuer <url>", "Identity provider issuer URL").option("--client-id <id>", "OIDC client id").option("--api-url <url>", "Control Plane base URL").option("--device", "Use the device-code flow (headless / no local browser)").option(
1291
1960
  "--redirect-host <host>",
1292
1961
  "Loopback host for the redirect URI (default 127.0.0.1; try localhost)"
1293
1962
  ).option(
@@ -1325,8 +1994,55 @@ domains.command("rm <domain>").alias("remove").description("Remove a custom doma
1325
1994
  domains.command("verify <domain>").description("Re-check the DNS records for a custom domain").requiredOption("-c, --coderunner <id|slug>", "Target coderunner id or slug").option("-w, --workspace <id>", "Workspace id override").option("--json", "Output raw JSON").action(
1326
1995
  (domain, opts) => run(() => domainsVerifyCommand(domain, opts))
1327
1996
  );
1997
+ var secrets = program.command("secrets").description("Manage workspace secrets");
1998
+ secrets.command("ls").alias("list").description("List secret keys (values are not shown)").option("-w, --workspace <id>", "Workspace id override").option("-e, --environment <id>", "Environment id").option("--json", "Output raw JSON").action((opts) => run(() => secretsListCommand(opts)));
1999
+ secrets.command("get <key>").description("Print a secret value").option("-w, --workspace <id>", "Workspace id override").option("-e, --environment <id>", "Environment id").option("--json", "Output raw JSON").action((key, opts) => run(() => secretsGetCommand(key, opts)));
2000
+ secrets.command("set <key> [value]").description("Create or update a secret (pass - to read the value from stdin)").option("-w, --workspace <id>", "Workspace id override").option("-e, --environment <id>", "Environment id").option("--description <text>", "Optional description").action(
2001
+ (key, value, opts) => run(() => secretsSetCommand(key, value, opts))
2002
+ );
2003
+ secrets.command("rm <key>").alias("delete").description("Delete a secret").option("-w, --workspace <id>", "Workspace id override").option("-e, --environment <id>", "Environment id").action((key, opts) => run(() => secretsRmCommand(key, opts)));
2004
+ var files = program.command("files").description("Manage workspace files");
2005
+ files.command("ls").alias("list").description("List files").option("-w, --workspace <id>", "Workspace id override").option("--json", "Output raw JSON").action((opts) => run(() => filesListCommand(opts)));
2006
+ files.command("upload <path>").description("Upload a local file").option("-w, --workspace <id>", "Workspace id override").option("-n, --name <name>", "Display name (default: basename)").action(
2007
+ (filePath, opts) => run(() => filesUploadCommand(filePath, opts))
2008
+ );
2009
+ files.command("download <fileId>").description("Download a file").option("-w, --workspace <id>", "Workspace id override").option("-o, --out <path>", "Output path").action(
2010
+ (fileId, opts) => run(() => filesDownloadCommand(fileId, opts))
2011
+ );
2012
+ 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
+ var apps = program.command("apps").description(
2014
+ "Product apps (Shell federated / standalone) \u2014 create, list, publish"
2015
+ );
2016
+ 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
+ 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)));
2018
+ 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
+ "--kind <kind>",
2020
+ "shell_federated (default) or standalone",
2021
+ "shell_federated"
2022
+ ).option(
2023
+ "--primary-coderunner <id>",
2024
+ "Required for --kind standalone: existing service coderunner id"
2025
+ ).option("-e, --environment <id>", "Environment id to bind").option(
2026
+ "--manifest <path>",
2027
+ "Path to groundfloor.manifest.json (Shell apps)"
2028
+ ).option("--json", "Output raw JSON").action((opts) => run(() => appsCreateCommand(opts)));
2029
+ apps.command("publish [appId]").description(
2030
+ "Upload release.zip (or remoteEntry.js) and publish a Shell app"
2031
+ ).option("-w, --workspace <id>", "Workspace id override").option("--app <id|slug>", "App id or slug (else manifest appId)").option(
2032
+ "-p, --path <file>",
2033
+ "release.zip, remoteEntry.js, or a folder containing release.zip",
2034
+ "release.zip"
2035
+ ).option("-m, --label <text>", "Optional release label").option(
2036
+ "--no-sync-manifest",
2037
+ "Do not PATCH the Portal manifest from groundfloor.manifest.json"
2038
+ ).option("--json", "Output raw JSON").action(
2039
+ (appId, opts) => run(() => appsPublishCommand(appId, opts))
2040
+ );
2041
+ var dataplane = program.command("dataplane").description("Workspace Dataplane setup (required for vault, files, secrets)");
2042
+ dataplane.command("status").description("Show Dataplane setup status").option("-w, --workspace <id>", "Workspace id override").option("--json", "Output raw JSON").action((opts) => run(() => dataplaneStatusCommand(opts)));
2043
+ dataplane.command("provision").description("Provision Dataplane for this workspace (idempotent)").option("-w, --workspace <id>", "Workspace id override").option("--json", "Output raw JSON").action((opts) => run(() => dataplaneProvisionCommand(opts)));
1328
2044
  program.command("deploy").description(
1329
- "Package code (local folder or --git repo) and deploy as a standalone coderunner (D-061; not an App)"
2045
+ "Package code and deploy as a coderunner (not an App)"
1330
2046
  ).option("-w, --workspace <id>", "Workspace id override").option("--api-url <url>", "Control Plane base URL").option(
1331
2047
  "-c, --coderunner <id>",
1332
2048
  "Deploy into an existing coderunner id (skip find/create)"
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@groundfloorcloud/cli",
3
- "version": "0.1.0",
4
- "description": "Groundfloor `gf` CLI — browser login and a cached session for Control Plane / Coderunner APIs.",
3
+ "version": "0.1.1",
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": {
7
7
  "gf": "dist/index.js"