@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.
- package/README.md +121 -114
- package/dist/index.js +745 -29
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,15 +1,10 @@
|
|
|
1
1
|
# `@groundfloorcloud/cli` (`gf`)
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
9
|
-
|
|
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
|
-
|
|
16
|
+
Without a global install:
|
|
22
17
|
|
|
23
18
|
```bash
|
|
24
19
|
npx @groundfloorcloud/cli login
|
|
25
20
|
```
|
|
26
21
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
39
|
-
gf
|
|
40
|
-
gf
|
|
41
|
-
gf
|
|
42
|
-
gf
|
|
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
|
|
39
|
+
Use the session from a shell or script:
|
|
46
40
|
|
|
47
41
|
```bash
|
|
48
|
-
eval "$(gf env)"
|
|
42
|
+
eval "$(gf env)"
|
|
49
43
|
curl -H "Authorization: Bearer $(gf token)" \
|
|
50
|
-
"$CONTROLPLANE_URL/v1/workspaces"
|
|
44
|
+
"$CONTROLPLANE_URL/v1/workspaces"
|
|
51
45
|
```
|
|
52
46
|
|
|
53
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
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
|
|
67
|
+
- `--coderunner <id>` deploys into that coderunner.
|
|
89
68
|
- Otherwise the name comes from `--name`, then `groundfloor.json`, then the
|
|
90
|
-
folder
|
|
91
|
-
|
|
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
|
-
|
|
72
|
+
Runtime is auto-detected when omitted (`package.json` → node,
|
|
73
|
+
`requirements.txt` / `pyproject.toml` → python), defaulting to python.
|
|
97
74
|
|
|
98
|
-
|
|
99
|
-
win):
|
|
75
|
+
### `groundfloor.json`
|
|
100
76
|
|
|
101
77
|
```json
|
|
102
78
|
{
|
|
@@ -109,13 +85,13 @@ win):
|
|
|
109
85
|
}
|
|
110
86
|
```
|
|
111
87
|
|
|
112
|
-
###
|
|
88
|
+
### Deploy flags
|
|
113
89
|
|
|
114
90
|
| Flag | Description |
|
|
115
91
|
|------|-------------|
|
|
116
|
-
| `-w, --workspace <id>` | Workspace override (else
|
|
117
|
-
| `-p, --path <dir>` |
|
|
118
|
-
| `--git <url>` / `--ref <ref>` | Clone and deploy a remote repo
|
|
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
|
|
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
|
|
134
|
-
| `gf token` | Print a fresh
|
|
135
|
-
| `gf env` | Print `export
|
|
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
|
|
138
|
-
| `gf coderunner status -c <id\|slug>` | Status
|
|
139
|
-
| `gf coderunner run -c <id\|slug> [--payload '{}']` |
|
|
140
|
-
| `gf deploy` | Package code
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
|
150
|
-
|
|
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 |
|
|
153
|
-
|
|
154
|
-
|
|
|
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
|
-
|
|
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
|
-
|
|
188
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
201
|
-
|
|
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
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
|
78
|
-
const
|
|
79
|
-
const
|
|
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 ${
|
|
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,
|
|
523
|
+
async function cpGet(apiUrl, path6) {
|
|
459
524
|
const token = await getValidAccessToken();
|
|
460
|
-
const res = await fetch(`${apiUrl}${
|
|
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,
|
|
536
|
+
async function cpPostJson(apiUrl, path6, body) {
|
|
472
537
|
const token = await getValidAccessToken();
|
|
473
|
-
const res = await fetch(`${apiUrl}${
|
|
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,
|
|
552
|
+
async function cpDelete(apiUrl, path6) {
|
|
488
553
|
const token = await getValidAccessToken();
|
|
489
|
-
const res = await fetch(`${apiUrl}${
|
|
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,
|
|
1061
|
+
async function buildZip(sourceDir, files2) {
|
|
807
1062
|
const zip = new AdmZip();
|
|
808
|
-
for (const rel of
|
|
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
|
|
860
|
-
if (
|
|
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,
|
|
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:
|
|
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
|
|
1289
|
-
).version("0.1.
|
|
1290
|
-
program.command("login").description(
|
|
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
|
|
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.
|
|
4
|
-
"description": "Groundfloor
|
|
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"
|