@groundfloorcloud/cli 0.1.10 → 0.1.12
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 +26 -4
- package/dist/index.js +259 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# `@groundfloorcloud/cli` (`gf`)
|
|
2
2
|
|
|
3
3
|
Sign in to Groundfloor, manage workspaces, and work with Coderunner,
|
|
4
|
-
secrets, files, apps, and Dataplane from your terminal.
|
|
4
|
+
secrets, feature flags, files, apps, and Dataplane from your terminal.
|
|
5
5
|
|
|
6
6
|
`gf login` opens a browser, you sign in, and the session is cached and
|
|
7
7
|
refreshed automatically.
|
|
@@ -226,6 +226,27 @@ gf apps ls
|
|
|
226
226
|
gf apps get <id|slug>
|
|
227
227
|
```
|
|
228
228
|
|
|
229
|
+
## Feature flags
|
|
230
|
+
|
|
231
|
+
Per-environment toggles and config values. `-e` takes an environment id or name
|
|
232
|
+
(`dev`, `staging`, `prod`); without it the workspace default (`dev`) is used.
|
|
233
|
+
|
|
234
|
+
```bash
|
|
235
|
+
gf flags ls -e prod
|
|
236
|
+
gf flags set new-checkout true -e dev # boolean (inferred)
|
|
237
|
+
gf flags set max-items 25 -e dev # number (inferred)
|
|
238
|
+
gf flags set banner '{"text":"Hi"}' --type json -e dev
|
|
239
|
+
gf flags get new-checkout -e prod
|
|
240
|
+
gf flags compare dev prod
|
|
241
|
+
gf flags promote dev prod --dry-run # then without --dry-run
|
|
242
|
+
gf flags rm new-checkout -e dev
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
Keys are lowercase letters, digits, `-`, `_` and `.`, starting with a letter.
|
|
246
|
+
Apps read flags at runtime from `GET /v1/workspaces/{id}/flags/runtime`: Shell
|
|
247
|
+
apps with the end-user token, Coderunners with `gf m2m token` (read-only). See
|
|
248
|
+
https://docs.groundfloor.cloud/docs/configure/feature-flags.
|
|
249
|
+
|
|
229
250
|
## Commands
|
|
230
251
|
|
|
231
252
|
| Command | Description |
|
|
@@ -247,6 +268,7 @@ gf apps get <id|slug>
|
|
|
247
268
|
| `gf deploy` | Package code and deploy as a coderunner. |
|
|
248
269
|
| `gf domains ls\|add\|rm\|verify -c <id\|slug>` | Custom domains for a service. |
|
|
249
270
|
| `gf secrets ls\|get\|set\|rm` | Workspace secrets. |
|
|
271
|
+
| `gf flags ls\|get\|set\|rm\|compare\|promote` | Feature flags per environment (`-e <id\|name>`). |
|
|
250
272
|
| `gf files ls\|upload\|download\|rm` | Workspace files. |
|
|
251
273
|
| `gf apps init --slug <slug>` | Download the official Shell starter-kit and stamp APP_ID. |
|
|
252
274
|
| `gf apps ls\|get\|create\|publish` | Product apps (Shell publish / standalone wrap). |
|
|
@@ -287,7 +309,7 @@ sign-in fails with an invalid redirect URI:
|
|
|
287
309
|
## More
|
|
288
310
|
|
|
289
311
|
- [Customer Portal](https://app.groundfloor.cloud)
|
|
290
|
-
- [Developers](https://docs.groundfloor.cloud/docs/
|
|
291
|
-
- [Deploy with agents](https://docs.groundfloor.cloud/docs/
|
|
292
|
-
- [Coderunner](https://docs.groundfloor.cloud/docs/
|
|
312
|
+
- [Developers](https://docs.groundfloor.cloud/docs/developer-tools/cli)
|
|
313
|
+
- [Deploy with agents](https://docs.groundfloor.cloud/docs/developer-tools/ai-agents)
|
|
314
|
+
- [Coderunner](https://docs.groundfloor.cloud/docs/deploy)
|
|
293
315
|
- [API authentication](https://docs.groundfloor.cloud/docs/api/authentication)
|
package/dist/index.js
CHANGED
|
@@ -850,6 +850,53 @@ function deleteSecret(apiUrl, workspaceId, key, environmentId) {
|
|
|
850
850
|
})}`
|
|
851
851
|
);
|
|
852
852
|
}
|
|
853
|
+
function listFlags(apiUrl, workspaceId, environmentId) {
|
|
854
|
+
return cpGet(
|
|
855
|
+
apiUrl,
|
|
856
|
+
`${wsPath(workspaceId, "/flags")}${queryString({ environment_id: environmentId })}`
|
|
857
|
+
);
|
|
858
|
+
}
|
|
859
|
+
function getFlag(apiUrl, workspaceId, key, environmentId) {
|
|
860
|
+
return cpGet(
|
|
861
|
+
apiUrl,
|
|
862
|
+
`${wsPath(workspaceId, `/flags/${encodeURIComponent(key)}`)}${queryString({
|
|
863
|
+
environment_id: environmentId
|
|
864
|
+
})}`
|
|
865
|
+
);
|
|
866
|
+
}
|
|
867
|
+
function putFlag(apiUrl, workspaceId, key, payload, environmentId) {
|
|
868
|
+
return cpPutJson(
|
|
869
|
+
apiUrl,
|
|
870
|
+
`${wsPath(workspaceId, `/flags/${encodeURIComponent(key)}`)}${queryString({
|
|
871
|
+
environment_id: environmentId
|
|
872
|
+
})}`,
|
|
873
|
+
payload
|
|
874
|
+
);
|
|
875
|
+
}
|
|
876
|
+
function deleteFlag(apiUrl, workspaceId, key, environmentId) {
|
|
877
|
+
return cpDelete(
|
|
878
|
+
apiUrl,
|
|
879
|
+
`${wsPath(workspaceId, `/flags/${encodeURIComponent(key)}`)}${queryString({
|
|
880
|
+
environment_id: environmentId
|
|
881
|
+
})}`
|
|
882
|
+
);
|
|
883
|
+
}
|
|
884
|
+
function compareFlags(apiUrl, workspaceId, sourceEnvironmentId, targetEnvironmentId) {
|
|
885
|
+
return cpGet(
|
|
886
|
+
apiUrl,
|
|
887
|
+
`${wsPath(workspaceId, "/flags/compare")}${queryString({
|
|
888
|
+
source_environment_id: sourceEnvironmentId,
|
|
889
|
+
target_environment_id: targetEnvironmentId
|
|
890
|
+
})}`
|
|
891
|
+
);
|
|
892
|
+
}
|
|
893
|
+
function promoteFlags(apiUrl, workspaceId, body) {
|
|
894
|
+
return cpPostJson(
|
|
895
|
+
apiUrl,
|
|
896
|
+
wsPath(workspaceId, "/flags/promote"),
|
|
897
|
+
body
|
|
898
|
+
);
|
|
899
|
+
}
|
|
853
900
|
function listFiles(apiUrl, workspaceId) {
|
|
854
901
|
return cpGet(apiUrl, wsPath(workspaceId, "/files"));
|
|
855
902
|
}
|
|
@@ -2395,6 +2442,200 @@ async function secretsRmCommand(key, opts) {
|
|
|
2395
2442
|
`);
|
|
2396
2443
|
}
|
|
2397
2444
|
|
|
2445
|
+
// src/commands/flags.ts
|
|
2446
|
+
var VALUE_TYPES = [
|
|
2447
|
+
"boolean",
|
|
2448
|
+
"string",
|
|
2449
|
+
"number",
|
|
2450
|
+
"json"
|
|
2451
|
+
];
|
|
2452
|
+
var UUID_RE2 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
2453
|
+
function matchEnvironment(environments2, idOrName) {
|
|
2454
|
+
const wanted = idOrName.trim();
|
|
2455
|
+
const byId = environments2.find((env) => env.id === wanted);
|
|
2456
|
+
if (byId) return byId.id;
|
|
2457
|
+
const byName = environments2.filter(
|
|
2458
|
+
(env) => env.name.toLowerCase() === wanted.toLowerCase()
|
|
2459
|
+
);
|
|
2460
|
+
if (byName.length === 1) return byName[0].id;
|
|
2461
|
+
const names2 = environments2.map((env) => env.name).join(", ") || "none";
|
|
2462
|
+
throw new Error(
|
|
2463
|
+
`Environment "${wanted}" not found in this workspace (have: ${names2}). Run \`gf environments ls\`.`
|
|
2464
|
+
);
|
|
2465
|
+
}
|
|
2466
|
+
async function resolveEnvironment(apiUrl, workspaceId, idOrName) {
|
|
2467
|
+
if (!idOrName) return void 0;
|
|
2468
|
+
if (UUID_RE2.test(idOrName.trim())) return idOrName.trim();
|
|
2469
|
+
const environments2 = await listWorkspaceEnvironments(apiUrl, workspaceId);
|
|
2470
|
+
return matchEnvironment(environments2, idOrName);
|
|
2471
|
+
}
|
|
2472
|
+
function parseFlagValue(raw, type) {
|
|
2473
|
+
if (type !== void 0 && !VALUE_TYPES.includes(type)) {
|
|
2474
|
+
throw new Error(`--type must be one of: ${VALUE_TYPES.join(", ")}`);
|
|
2475
|
+
}
|
|
2476
|
+
const lower = raw.trim().toLowerCase();
|
|
2477
|
+
const valueType = type ?? (lower === "true" || lower === "false" ? "boolean" : raw.trim() !== "" && Number.isFinite(Number(raw)) ? "number" : "string");
|
|
2478
|
+
switch (valueType) {
|
|
2479
|
+
case "boolean":
|
|
2480
|
+
if (lower !== "true" && lower !== "false") {
|
|
2481
|
+
throw new Error("boolean flags take true or false");
|
|
2482
|
+
}
|
|
2483
|
+
return { value: lower === "true", value_type: "boolean" };
|
|
2484
|
+
case "number": {
|
|
2485
|
+
const n = Number(raw);
|
|
2486
|
+
if (raw.trim() === "" || !Number.isFinite(n)) {
|
|
2487
|
+
throw new Error(`"${raw}" is not a number`);
|
|
2488
|
+
}
|
|
2489
|
+
return { value: n, value_type: "number" };
|
|
2490
|
+
}
|
|
2491
|
+
case "json":
|
|
2492
|
+
try {
|
|
2493
|
+
return { value: JSON.parse(raw), value_type: "json" };
|
|
2494
|
+
} catch {
|
|
2495
|
+
throw new Error("--type json needs a valid JSON value");
|
|
2496
|
+
}
|
|
2497
|
+
default:
|
|
2498
|
+
return { value: raw, value_type: "string" };
|
|
2499
|
+
}
|
|
2500
|
+
}
|
|
2501
|
+
function formatValue(value) {
|
|
2502
|
+
return typeof value === "string" ? value : JSON.stringify(value);
|
|
2503
|
+
}
|
|
2504
|
+
async function flagsListCommand(opts) {
|
|
2505
|
+
const cfg = await requireWorkspace(opts.workspace);
|
|
2506
|
+
const env = await resolveEnvironment(
|
|
2507
|
+
cfg.apiUrl,
|
|
2508
|
+
cfg.workspaceId,
|
|
2509
|
+
opts.environment
|
|
2510
|
+
);
|
|
2511
|
+
const data = await listFlags(cfg.apiUrl, cfg.workspaceId, env);
|
|
2512
|
+
if (opts.json) {
|
|
2513
|
+
process.stdout.write(`${JSON.stringify(data, null, 2)}
|
|
2514
|
+
`);
|
|
2515
|
+
return;
|
|
2516
|
+
}
|
|
2517
|
+
process.stderr.write(`environment ${data.environment_id}
|
|
2518
|
+
`);
|
|
2519
|
+
if (data.flags.length === 0) {
|
|
2520
|
+
process.stdout.write("No flags in this environment.\n");
|
|
2521
|
+
return;
|
|
2522
|
+
}
|
|
2523
|
+
for (const flag of data.flags) {
|
|
2524
|
+
process.stdout.write(
|
|
2525
|
+
`${flag.key} ${flag.value_type} ${formatValue(flag.value)}
|
|
2526
|
+
`
|
|
2527
|
+
);
|
|
2528
|
+
}
|
|
2529
|
+
}
|
|
2530
|
+
async function flagsGetCommand(key, opts) {
|
|
2531
|
+
const cfg = await requireWorkspace(opts.workspace);
|
|
2532
|
+
const env = await resolveEnvironment(
|
|
2533
|
+
cfg.apiUrl,
|
|
2534
|
+
cfg.workspaceId,
|
|
2535
|
+
opts.environment
|
|
2536
|
+
);
|
|
2537
|
+
const flag = await getFlag(cfg.apiUrl, cfg.workspaceId, key, env);
|
|
2538
|
+
if (opts.json) {
|
|
2539
|
+
process.stdout.write(`${JSON.stringify(flag, null, 2)}
|
|
2540
|
+
`);
|
|
2541
|
+
return;
|
|
2542
|
+
}
|
|
2543
|
+
process.stdout.write(`${formatValue(flag.value)}
|
|
2544
|
+
`);
|
|
2545
|
+
}
|
|
2546
|
+
async function flagsSetCommand(key, value, opts) {
|
|
2547
|
+
const cfg = await requireWorkspace(opts.workspace);
|
|
2548
|
+
const env = await resolveEnvironment(
|
|
2549
|
+
cfg.apiUrl,
|
|
2550
|
+
cfg.workspaceId,
|
|
2551
|
+
opts.environment
|
|
2552
|
+
);
|
|
2553
|
+
const parsed = parseFlagValue(value, opts.type);
|
|
2554
|
+
const saved = await putFlag(
|
|
2555
|
+
cfg.apiUrl,
|
|
2556
|
+
cfg.workspaceId,
|
|
2557
|
+
key,
|
|
2558
|
+
{ ...parsed, description: opts.description },
|
|
2559
|
+
env
|
|
2560
|
+
);
|
|
2561
|
+
if (opts.json) {
|
|
2562
|
+
process.stdout.write(`${JSON.stringify(saved, null, 2)}
|
|
2563
|
+
`);
|
|
2564
|
+
return;
|
|
2565
|
+
}
|
|
2566
|
+
process.stdout.write(
|
|
2567
|
+
`Set ${saved.key} = ${formatValue(saved.value)} (${saved.value_type}) in environment ${saved.environment_id}
|
|
2568
|
+
`
|
|
2569
|
+
);
|
|
2570
|
+
}
|
|
2571
|
+
async function flagsRmCommand(key, opts) {
|
|
2572
|
+
const cfg = await requireWorkspace(opts.workspace);
|
|
2573
|
+
const env = await resolveEnvironment(
|
|
2574
|
+
cfg.apiUrl,
|
|
2575
|
+
cfg.workspaceId,
|
|
2576
|
+
opts.environment
|
|
2577
|
+
);
|
|
2578
|
+
await deleteFlag(cfg.apiUrl, cfg.workspaceId, key, env);
|
|
2579
|
+
process.stdout.write(`Deleted flag ${key}
|
|
2580
|
+
`);
|
|
2581
|
+
}
|
|
2582
|
+
async function flagsCompareCommand(source, target, opts) {
|
|
2583
|
+
const cfg = await requireWorkspace(opts.workspace);
|
|
2584
|
+
const [src, tgt] = await Promise.all([
|
|
2585
|
+
resolveEnvironment(cfg.apiUrl, cfg.workspaceId, source),
|
|
2586
|
+
resolveEnvironment(cfg.apiUrl, cfg.workspaceId, target)
|
|
2587
|
+
]);
|
|
2588
|
+
const data = await compareFlags(cfg.apiUrl, cfg.workspaceId, src, tgt);
|
|
2589
|
+
if (opts.json) {
|
|
2590
|
+
process.stdout.write(`${JSON.stringify(data, null, 2)}
|
|
2591
|
+
`);
|
|
2592
|
+
return;
|
|
2593
|
+
}
|
|
2594
|
+
if (data.keys.length === 0) {
|
|
2595
|
+
process.stdout.write("No flags in either environment.\n");
|
|
2596
|
+
return;
|
|
2597
|
+
}
|
|
2598
|
+
for (const row of data.keys) {
|
|
2599
|
+
process.stdout.write(
|
|
2600
|
+
`${row.key} ${row.status} ${formatValue(row.source_value ?? null)} \u2192 ${formatValue(row.target_value ?? null)}
|
|
2601
|
+
`
|
|
2602
|
+
);
|
|
2603
|
+
}
|
|
2604
|
+
}
|
|
2605
|
+
async function flagsPromoteCommand(source, target, opts) {
|
|
2606
|
+
const cfg = await requireWorkspace(opts.workspace);
|
|
2607
|
+
const [src, tgt] = await Promise.all([
|
|
2608
|
+
resolveEnvironment(cfg.apiUrl, cfg.workspaceId, source),
|
|
2609
|
+
resolveEnvironment(cfg.apiUrl, cfg.workspaceId, target)
|
|
2610
|
+
]);
|
|
2611
|
+
const keys = opts.keys?.split(",").map((k) => k.trim()).filter(Boolean);
|
|
2612
|
+
const result = await promoteFlags(cfg.apiUrl, cfg.workspaceId, {
|
|
2613
|
+
source_environment_id: src,
|
|
2614
|
+
target_environment_id: tgt,
|
|
2615
|
+
keys: keys && keys.length > 0 ? keys : void 0,
|
|
2616
|
+
mode: opts.overwrite ? "overwrite" : "skip_existing",
|
|
2617
|
+
dry_run: Boolean(opts.dryRun)
|
|
2618
|
+
});
|
|
2619
|
+
if (opts.json) {
|
|
2620
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}
|
|
2621
|
+
`);
|
|
2622
|
+
return;
|
|
2623
|
+
}
|
|
2624
|
+
const prefix = result.dry_run ? "[dry run] " : "";
|
|
2625
|
+
process.stdout.write(
|
|
2626
|
+
`${prefix}copied: ${result.copied.join(", ") || "none"}
|
|
2627
|
+
${prefix}skipped (already set; use --overwrite): ${result.skipped.join(", ") || "none"}
|
|
2628
|
+
`
|
|
2629
|
+
);
|
|
2630
|
+
for (const failure of result.failed) {
|
|
2631
|
+
process.stdout.write(`${prefix}failed: ${failure.key} \u2014 ${failure.error}
|
|
2632
|
+
`);
|
|
2633
|
+
}
|
|
2634
|
+
if (result.failed.length > 0) {
|
|
2635
|
+
process.exitCode = 1;
|
|
2636
|
+
}
|
|
2637
|
+
}
|
|
2638
|
+
|
|
2398
2639
|
// src/commands/files.ts
|
|
2399
2640
|
import { promises as fs5 } from "fs";
|
|
2400
2641
|
import path6 from "path";
|
|
@@ -3459,6 +3700,24 @@ secrets.command("set <key> [value]").description("Create or update a secret (pas
|
|
|
3459
3700
|
(key, value, opts) => run(() => secretsSetCommand(key, value, opts))
|
|
3460
3701
|
);
|
|
3461
3702
|
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)));
|
|
3703
|
+
var flags = program.command("flags").description(
|
|
3704
|
+
"Manage feature flags per environment (apps read them via GET \u2026/flags/runtime)"
|
|
3705
|
+
);
|
|
3706
|
+
var ENV_HELP = "Environment id or name (dev, staging, prod). Default: workspace default (dev)";
|
|
3707
|
+
flags.command("ls").alias("list").description("List flags and values in an environment").option("-w, --workspace <id>", "Workspace id override").option("-e, --environment <idOrName>", ENV_HELP).option("--json", "Output raw JSON").action((opts) => run(() => flagsListCommand(opts)));
|
|
3708
|
+
flags.command("get <key>").description("Print a flag value").option("-w, --workspace <id>", "Workspace id override").option("-e, --environment <idOrName>", ENV_HELP).option("--json", "Output raw JSON").action((key, opts) => run(() => flagsGetCommand(key, opts)));
|
|
3709
|
+
flags.command("set <key> <value>").description(
|
|
3710
|
+
"Create or update a flag (type inferred: true/false \u2192 boolean, numeric \u2192 number, else string)"
|
|
3711
|
+
).option("-w, --workspace <id>", "Workspace id override").option("-e, --environment <idOrName>", ENV_HELP).option("-t, --type <type>", "boolean | string | number | json").option("--description <text>", "Optional description (max 255 chars)").option("--json", "Output raw JSON").action(
|
|
3712
|
+
(key, value, opts) => run(() => flagsSetCommand(key, value, opts))
|
|
3713
|
+
);
|
|
3714
|
+
flags.command("rm <key>").alias("delete").description("Delete a flag from one environment").option("-w, --workspace <id>", "Workspace id override").option("-e, --environment <idOrName>", ENV_HELP).action((key, opts) => run(() => flagsRmCommand(key, opts)));
|
|
3715
|
+
flags.command("compare <source> <target>").alias("diff").description("Diff flags between two environments (ids or names)").option("-w, --workspace <id>", "Workspace id override").option("--json", "Output raw JSON").action(
|
|
3716
|
+
(source, target, opts) => run(() => flagsCompareCommand(source, target, opts))
|
|
3717
|
+
);
|
|
3718
|
+
flags.command("promote <source> <target>").description("Copy flags from one environment to another").option("-w, --workspace <id>", "Workspace id override").option("-k, --keys <keys>", "Comma-separated keys (default: all)").option("--overwrite", "Replace values already set in the target").option("--dry-run", "Show what would change without writing").option("--json", "Output raw JSON").action(
|
|
3719
|
+
(source, target, opts) => run(() => flagsPromoteCommand(source, target, opts))
|
|
3720
|
+
);
|
|
3462
3721
|
var files = program.command("files").description("Manage workspace files");
|
|
3463
3722
|
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)));
|
|
3464
3723
|
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(
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@groundfloorcloud/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.12",
|
|
4
4
|
"description": "Groundfloor gf CLI — sign in, deploy coderunners, publish Shell apps, and manage secrets, files, and Dataplane.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"url": "git+ssh://git@bitbucket.org/codementeam/groundfloor-client-portal.git",
|
|
19
19
|
"directory": "packages/cli"
|
|
20
20
|
},
|
|
21
|
-
"homepage": "https://docs.groundfloor.cloud/docs/
|
|
21
|
+
"homepage": "https://docs.groundfloor.cloud/docs/developer-tools/cli",
|
|
22
22
|
"keywords": [
|
|
23
23
|
"groundfloor",
|
|
24
24
|
"cli",
|