@catladder/cli 0.0.0-next-aea5d3ad → 0.0.0-next-2b60440e

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 (56) hide show
  1. package/dist/bundles/cli/index.js +705 -229
  2. package/dist/bundles/runner-images/semantic-release/Dockerfile +15 -12
  3. package/dist/bundles/runner-images/semantic-release/scripts/semanticRelease +10 -1
  4. package/dist/bundles/skills/catladder-cli/references/commands.md +37 -2
  5. package/dist/bundles/skills/catladder-secrets/SKILL.md +42 -9
  6. package/dist/cli/src/commands/index.d.ts +4 -0
  7. package/dist/cli/src/commands/index.js +9 -1
  8. package/dist/cli/src/commands/index.js.map +1 -1
  9. package/dist/cli/src/commands/project/commandConfigSecrets.d.ts +6 -1
  10. package/dist/cli/src/commands/project/commandConfigSecrets.js +56 -15
  11. package/dist/cli/src/commands/project/commandConfigSecrets.js.map +1 -1
  12. package/dist/cli/src/commands/project/secrets/commandSecretsList.d.ts +24 -0
  13. package/dist/cli/src/commands/project/secrets/commandSecretsList.js +64 -0
  14. package/dist/cli/src/commands/project/secrets/commandSecretsList.js.map +1 -0
  15. package/dist/cli/src/commands/project/secrets/commandSecretsPull.d.ts +19 -0
  16. package/dist/cli/src/commands/project/secrets/commandSecretsPull.js +48 -0
  17. package/dist/cli/src/commands/project/secrets/commandSecretsPull.js.map +1 -0
  18. package/dist/cli/src/commands/project/secrets/commandSecretsPush.d.ts +19 -0
  19. package/dist/cli/src/commands/project/secrets/commandSecretsPush.js +74 -0
  20. package/dist/cli/src/commands/project/secrets/commandSecretsPush.js.map +1 -0
  21. package/dist/cli/src/commands/project/secrets/commandSecretsSet.d.ts +24 -0
  22. package/dist/cli/src/commands/project/secrets/commandSecretsSet.js +82 -0
  23. package/dist/cli/src/commands/project/secrets/commandSecretsSet.js.map +1 -0
  24. package/dist/cli/src/commands/project/secrets/document.d.ts +47 -0
  25. package/dist/cli/src/commands/project/secrets/document.js +180 -0
  26. package/dist/cli/src/commands/project/secrets/document.js.map +1 -0
  27. package/dist/cli/src/commands/project/secrets/parseKeysInput.d.ts +2 -0
  28. package/dist/cli/src/commands/project/secrets/parseKeysInput.js +10 -0
  29. package/dist/cli/src/commands/project/secrets/parseKeysInput.js.map +1 -0
  30. package/dist/cli/src/commands/project/secrets/scope.d.ts +35 -0
  31. package/dist/cli/src/commands/project/secrets/scope.js +113 -0
  32. package/dist/cli/src/commands/project/secrets/scope.js.map +1 -0
  33. package/dist/cli/src/utils/readStdin.d.ts +2 -0
  34. package/dist/cli/src/utils/readStdin.js +13 -0
  35. package/dist/cli/src/utils/readStdin.js.map +1 -0
  36. package/dist/runner-images/semantic-release/Dockerfile +15 -12
  37. package/dist/runner-images/semantic-release/scripts/semanticRelease +10 -1
  38. package/dist/skills/catladder-cli/references/commands.md +37 -2
  39. package/dist/skills/catladder-secrets/SKILL.md +42 -9
  40. package/dist/tsconfig.tsbuildinfo +1 -1
  41. package/package.json +1 -1
  42. package/src/commands/index.ts +4 -0
  43. package/src/commands/project/commandConfigSecrets.ts +76 -15
  44. package/src/commands/project/secrets/__tests__/document.test.ts +122 -0
  45. package/src/commands/project/secrets/commandSecretsList.ts +75 -0
  46. package/src/commands/project/secrets/commandSecretsPull.ts +55 -0
  47. package/src/commands/project/secrets/commandSecretsPush.ts +100 -0
  48. package/src/commands/project/secrets/commandSecretsSet.ts +102 -0
  49. package/src/commands/project/secrets/document.ts +269 -0
  50. package/src/commands/project/secrets/parseKeysInput.ts +8 -0
  51. package/src/commands/project/secrets/scope.ts +151 -0
  52. package/src/utils/readStdin.ts +8 -0
  53. package/dist/cli/src/apps/cli/commands/project/commandConfigSecrets.d.ts +0 -2
  54. package/dist/cli/src/apps/cli/commands/project/commandConfigSecrets.js +0 -206
  55. package/dist/cli/src/apps/cli/commands/project/commandConfigSecrets.js.map +0 -1
  56. package/src/apps/cli/commands/project/commandConfigSecrets.ts +0 -322
package/package.json CHANGED
@@ -53,7 +53,7 @@
53
53
  }
54
54
  ],
55
55
  "license": "MIT",
56
- "version": "0.0.0-next-aea5d3ad",
56
+ "version": "0.0.0-next-2b60440e",
57
57
  "scripts": {
58
58
  "lint": "eslint \"src/**/*.ts\"",
59
59
  "lint:fix": "eslint \"src/**/*.ts\" --fix",
@@ -14,6 +14,10 @@ export { commandOpenEnv } from "./project/commandOpenEnv";
14
14
  export { commandOpenGit } from "./project/commandOpenGit";
15
15
  export { commandCloudSqlProxy } from "./project/commandCloudSqlProxy";
16
16
  export { commandConfigSecrets } from "./project/commandConfigSecrets";
17
+ export { commandSecretsPull } from "./project/secrets/commandSecretsPull";
18
+ export { commandSecretsPush } from "./project/secrets/commandSecretsPush";
19
+ export { commandSecretsSet } from "./project/secrets/commandSecretsSet";
20
+ export { commandSecretsList } from "./project/secrets/commandSecretsList";
17
21
  export { commandSecretsClearBackups } from "./project/commandSecretsClearBackups";
18
22
  export { commandSecretsSyncGithub } from "./project/commandSecretsSyncGithub";
19
23
  export { commandOpenLogs } from "./project/commandOpenLogs";
@@ -1,31 +1,92 @@
1
+ import { stripIndents } from "common-tags";
1
2
  import { defineCommand } from "../../core/defineCommand";
2
- import { projectConfigSecrets } from "../../apps/cli/commands/project/commandConfigSecrets";
3
- import { envAndComponents } from "../../apps/cli/commands/project/utils/autocompletions";
3
+ import { editAsFile } from "../../utils/editAsFile";
4
+ import { delay } from "../../utils/promise";
5
+ import {
6
+ buildSecretsDocument,
7
+ checkSecretsDocument,
8
+ collectSecretsWrites,
9
+ writeSecretsAndMirror,
10
+ } from "./secrets/document";
11
+ import {
12
+ formatScope,
13
+ getScopedSecretKeys,
14
+ resolveSecretsScope,
15
+ secretsScopeChoices,
16
+ } from "./secrets/scope";
17
+ import { parseKeysInput } from "./secrets/parseKeysInput";
4
18
 
5
- // This command delegates to the existing implementation which uses
6
- // editAsFile (opens editor) and has retry loops with confirm prompts.
7
- // Full migration to CommandIO would require reworking the editor flow,
8
- // so for now we bridge it.
9
19
  export const commandConfigSecrets = defineCommand({
10
20
  name: "project config-secrets",
11
- description: "setup/update secrets stored in pass",
21
+ description: "setup/update secrets in the vault via your editor",
12
22
  group: "project",
13
23
  inputs: {
14
24
  envComponent: {
15
25
  type: "string",
16
- message: "environment:component",
26
+ message: "scope: env:component, env:, :component or empty for everything",
17
27
  positional: true,
18
28
  required: false,
19
- choices: async () => envAndComponents(),
29
+ choices: async () => secretsScopeChoices(),
30
+ },
31
+ key: {
32
+ type: "string",
33
+ message: "only edit these secrets (comma-separated keys)",
34
+ required: false,
20
35
  },
21
36
  },
22
37
  execute: async (ctx) => {
23
38
  const envComponent = await ctx.get("envComponent");
24
- // Bridge: the existing implementation accepts CommandInstance-like
25
- // objects for logging and prompting. Since it uses this.prompt()
26
- // internally (not ctx.prompt), we pass ctx which satisfies the
27
- // IO interface for logging. The interactive prompts inside
28
- // projectConfigSecrets will need a deeper migration later.
29
- await projectConfigSecrets(ctx, envComponent);
39
+ const keys = parseKeysInput(await ctx.get("key"));
40
+
41
+ if (!ctx.interactive) {
42
+ throw new Error(
43
+ "project config-secrets opens an editor and needs an interactive terminal — " +
44
+ "use `project secrets-pull`, `project secrets-push` or `project secrets-set` instead",
45
+ );
46
+ }
47
+
48
+ const scope = await resolveSecretsScope(envComponent);
49
+ const scoped = await getScopedSecretKeys(scope, keys);
50
+
51
+ let document = await buildSecretsDocument(ctx, scoped);
52
+ let hasErrors = true;
53
+ while (hasErrors) {
54
+ document = await editAsFile(
55
+ document,
56
+ stripIndents`
57
+ Please fill in all secrets for:
58
+
59
+ ${formatScope(scoped)}
60
+
61
+ `,
62
+ );
63
+ const problems = checkSecretsDocument(document, scoped, {
64
+ requireComplete: true,
65
+ });
66
+ hasErrors = problems.length > 0;
67
+ if (hasErrors) {
68
+ ctx.log("");
69
+ ctx.log("😿 Oh no! There is something wrong:");
70
+ ctx.log("");
71
+ problems.forEach((problem) => ctx.log(problem));
72
+ ctx.log("");
73
+
74
+ await delay(1000);
75
+ const shouldContinue = await ctx.confirm("Try again? 🤔");
76
+ if (!shouldContinue) {
77
+ throw new Error("abort");
78
+ }
79
+ }
80
+ }
81
+
82
+ const { writes, skipped } = collectSecretsWrites(document, scoped);
83
+ if (skipped.length > 0) {
84
+ ctx.log("");
85
+ ctx.log("⏭️ these secrets are still unset and will not be written:");
86
+ skipped.forEach((entry) => ctx.log(` - ${entry}`));
87
+ }
88
+ await writeSecretsAndMirror(ctx, writes);
89
+ ctx.log("done! 😻");
90
+ ctx.log("");
30
91
  },
31
92
  });
@@ -0,0 +1,122 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ FILL_ME,
4
+ checkSecretsDocument,
5
+ collectSecretsWrites,
6
+ } from "../document";
7
+ import { parseKeysInput } from "../parseKeysInput";
8
+ import type { ScopedSecretKeys } from "../scope";
9
+
10
+ const scoped: ScopedSecretKeys = [
11
+ { env: "dev", componentName: "web", keys: ["API_KEY", "SMTP_PASSWORD"] },
12
+ { env: "prod", componentName: "web", keys: ["API_KEY", "SMTP_PASSWORD"] },
13
+ { env: "dev", componentName: "worker", keys: ["QUEUE_TOKEN"] },
14
+ ];
15
+
16
+ describe("checkSecretsDocument", () => {
17
+ it("accepts a complete document", () => {
18
+ const problems = checkSecretsDocument(
19
+ {
20
+ web: {
21
+ dev: { API_KEY: "a", SMTP_PASSWORD: "b" },
22
+ prod: { API_KEY: "c", SMTP_PASSWORD: "d" },
23
+ },
24
+ worker: { dev: { QUEUE_TOKEN: "e" } },
25
+ },
26
+ scoped,
27
+ { requireComplete: true },
28
+ );
29
+ expect(problems).toEqual([]);
30
+ });
31
+
32
+ it("accepts a partial document when completeness is not required", () => {
33
+ const problems = checkSecretsDocument(
34
+ { web: { prod: { API_KEY: "rotated" } } },
35
+ scoped,
36
+ { requireComplete: false },
37
+ );
38
+ expect(problems).toEqual([]);
39
+ });
40
+
41
+ it("reports missing keys when completeness is required", () => {
42
+ const problems = checkSecretsDocument(
43
+ { web: { prod: { API_KEY: "rotated" } } },
44
+ scoped,
45
+ { requireComplete: true },
46
+ );
47
+ expect(problems).toEqual([
48
+ '"dev:web": "API_KEY" has not been provided',
49
+ '"dev:web": "SMTP_PASSWORD" has not been provided',
50
+ '"prod:web": "SMTP_PASSWORD" has not been provided',
51
+ '"dev:worker": "QUEUE_TOKEN" has not been provided',
52
+ ]);
53
+ });
54
+
55
+ it("rejects undeclared keys, components and envs", () => {
56
+ const problems = checkSecretsDocument(
57
+ {
58
+ web: {
59
+ dev: { TYPO_KEY: "a" },
60
+ stage: { API_KEY: "b" },
61
+ },
62
+ api: { dev: { API_KEY: "c" } },
63
+ },
64
+ scoped,
65
+ { requireComplete: false },
66
+ );
67
+ expect(problems).toEqual([
68
+ '"dev:web": "TYPO_KEY" is not declared in the config (or filtered out by --key)',
69
+ '"stage:web" is not in this scope — remove it or widen the scope',
70
+ '"api" is not a component in this scope — remove it or widen the scope',
71
+ ]);
72
+ });
73
+ });
74
+
75
+ describe("collectSecretsWrites", () => {
76
+ it("collects only provided values and skips placeholders", () => {
77
+ const { writes, skipped } = collectSecretsWrites(
78
+ {
79
+ web: {
80
+ dev: { API_KEY: "a", SMTP_PASSWORD: FILL_ME },
81
+ prod: { API_KEY: null as unknown as string },
82
+ },
83
+ worker: { dev: {} },
84
+ },
85
+ scoped,
86
+ );
87
+ expect(writes).toEqual([
88
+ { env: "dev", componentName: "web", secrets: { API_KEY: "a" } },
89
+ ]);
90
+ expect(skipped).toEqual(["dev:web SMTP_PASSWORD", "prod:web API_KEY"]);
91
+ });
92
+
93
+ it("keeps objects and empty strings as values", () => {
94
+ const { writes, skipped } = collectSecretsWrites(
95
+ { web: { dev: { API_KEY: "", SMTP_PASSWORD: { user: "a" } } } },
96
+ scoped,
97
+ );
98
+ expect(writes).toEqual([
99
+ {
100
+ env: "dev",
101
+ componentName: "web",
102
+ secrets: { API_KEY: "", SMTP_PASSWORD: { user: "a" } },
103
+ },
104
+ ]);
105
+ expect(skipped).toEqual([]);
106
+ });
107
+ });
108
+
109
+ describe("parseKeysInput", () => {
110
+ it("splits and trims comma-separated keys", () => {
111
+ expect(parseKeysInput("API_KEY, SMTP_PASSWORD ,")).toEqual([
112
+ "API_KEY",
113
+ "SMTP_PASSWORD",
114
+ ]);
115
+ });
116
+
117
+ it("returns undefined for empty input", () => {
118
+ expect(parseKeysInput(undefined)).toBeUndefined();
119
+ expect(parseKeysInput("")).toBeUndefined();
120
+ expect(parseKeysInput(" , ")).toBeUndefined();
121
+ });
122
+ });
@@ -0,0 +1,75 @@
1
+ import { getSecretVarName } from "@catladder/pipeline";
2
+ import { defineCommand } from "../../../core/defineCommand";
3
+ import {
4
+ getScopedSecretKeys,
5
+ resolveSecretsScope,
6
+ secretsScopeChoices,
7
+ } from "./scope";
8
+ import { parseKeysInput } from "./parseKeysInput";
9
+
10
+ export const commandSecretsList = defineCommand({
11
+ name: "project secrets-list",
12
+ description:
13
+ "lists the declared secrets of a scope with their set/unset status (values only with --reveal)",
14
+ group: "project",
15
+ inputs: {
16
+ envComponent: {
17
+ type: "string",
18
+ message: "scope: env:component, env:, :component or empty for everything",
19
+ positional: true,
20
+ required: false,
21
+ choices: async () => secretsScopeChoices(),
22
+ },
23
+ key: {
24
+ type: "string",
25
+ message: "only these secrets (comma-separated keys)",
26
+ required: false,
27
+ },
28
+ reveal: {
29
+ type: "boolean",
30
+ message: "also print the secret values",
31
+ default: false,
32
+ },
33
+ check: {
34
+ type: "boolean",
35
+ message: "exit with an error when any secret is unset (for CI/agents)",
36
+ default: false,
37
+ },
38
+ },
39
+ execute: async (ctx) => {
40
+ const scope = await resolveSecretsScope(await ctx.get("envComponent"));
41
+ const scoped = await getScopedSecretKeys(
42
+ scope,
43
+ parseKeysInput(await ctx.get("key")),
44
+ );
45
+ const reveal = await ctx.get("reveal");
46
+
47
+ const names = scoped.flatMap(({ env, componentName, keys }) =>
48
+ keys.map((key) => getSecretVarName(env, componentName, key)),
49
+ );
50
+ const values = await (await ctx.getVaultManager()).readSecrets(names, ctx);
51
+
52
+ let unsetCount = 0;
53
+ let setCount = 0;
54
+ for (const { env, componentName, keys } of scoped) {
55
+ for (const key of keys) {
56
+ const value = values[getSecretVarName(env, componentName, key)];
57
+ if (value === undefined) {
58
+ unsetCount += 1;
59
+ ctx.log(`🚨 ${env}:${componentName} ${key} (unset)`);
60
+ } else {
61
+ setCount += 1;
62
+ ctx.log(
63
+ `✅ ${env}:${componentName} ${key}` + (reveal ? ` = ${value}` : ""),
64
+ );
65
+ }
66
+ }
67
+ }
68
+ ctx.log("");
69
+ ctx.log(`${setCount} set, ${unsetCount} unset`);
70
+
71
+ if ((await ctx.get("check")) && unsetCount > 0) {
72
+ throw new Error(`${unsetCount} secrets are unset`);
73
+ }
74
+ },
75
+ });
@@ -0,0 +1,55 @@
1
+ import { writeFile } from "fs-extra";
2
+ import { stringify } from "yaml";
3
+ import { defineCommand } from "../../../core/defineCommand";
4
+ import { buildSecretsDocument } from "./document";
5
+ import {
6
+ getScopedSecretKeys,
7
+ resolveSecretsScope,
8
+ secretsScopeChoices,
9
+ } from "./scope";
10
+ import { parseKeysInput } from "./parseKeysInput";
11
+
12
+ export const commandSecretsPull = defineCommand({
13
+ name: "project secrets-pull",
14
+ description:
15
+ "prints the current secrets of a scope as yaml (unset ones as a placeholder) — edit and feed to secrets-push",
16
+ group: "project",
17
+ inputs: {
18
+ envComponent: {
19
+ type: "string",
20
+ message: "scope: env:component, env:, :component or empty for everything",
21
+ positional: true,
22
+ required: false,
23
+ choices: async () => secretsScopeChoices(),
24
+ },
25
+ key: {
26
+ type: "string",
27
+ message: "only these secrets (comma-separated keys)",
28
+ required: false,
29
+ },
30
+ out: {
31
+ type: "string",
32
+ message: "write the yaml to this file instead of stdout",
33
+ required: false,
34
+ },
35
+ },
36
+ execute: async (ctx) => {
37
+ const scope = await resolveSecretsScope(await ctx.get("envComponent"));
38
+ const scoped = await getScopedSecretKeys(
39
+ scope,
40
+ parseKeysInput(await ctx.get("key")),
41
+ );
42
+ const document = await buildSecretsDocument(ctx, scoped);
43
+ const yaml = stringify(document, { aliasDuplicateObjects: false });
44
+
45
+ const out = await ctx.get("out");
46
+ if (out) {
47
+ await writeFile(out, yaml);
48
+ ctx.log(
49
+ `wrote secrets to ${out} — ⚠️ contains secret values, don't commit it`,
50
+ );
51
+ } else {
52
+ ctx.log(yaml);
53
+ }
54
+ },
55
+ });
@@ -0,0 +1,100 @@
1
+ import { readFile } from "fs-extra";
2
+ import { parse } from "yaml";
3
+ import { defineCommand } from "../../../core/defineCommand";
4
+ import { readStdin } from "../../../utils/readStdin";
5
+ import type { SecretsDocument } from "./document";
6
+ import {
7
+ checkSecretsDocument,
8
+ collectSecretsWrites,
9
+ writeSecretsAndMirror,
10
+ } from "./document";
11
+ import {
12
+ getScopedSecretKeys,
13
+ resolveSecretsScope,
14
+ secretsScopeChoices,
15
+ } from "./scope";
16
+ import { parseKeysInput } from "./parseKeysInput";
17
+
18
+ export const commandSecretsPush = defineCommand({
19
+ name: "project secrets-push",
20
+ description:
21
+ "writes secrets from a yaml document (secrets-pull format, --file or stdin) to the vault; may be partial",
22
+ group: "project",
23
+ inputs: {
24
+ envComponent: {
25
+ type: "string",
26
+ message: "scope: env:component, env:, :component or empty for everything",
27
+ positional: true,
28
+ required: false,
29
+ choices: async () => secretsScopeChoices(),
30
+ },
31
+ key: {
32
+ type: "string",
33
+ message: "only write these secrets (comma-separated keys)",
34
+ required: false,
35
+ },
36
+ file: {
37
+ type: "string",
38
+ message:
39
+ "yaml file to push (component → env → key: value); stdin when omitted",
40
+ required: false,
41
+ },
42
+ },
43
+ execute: async (ctx) => {
44
+ const scope = await resolveSecretsScope(await ctx.get("envComponent"));
45
+ const scoped = await getScopedSecretKeys(
46
+ scope,
47
+ parseKeysInput(await ctx.get("key")),
48
+ );
49
+
50
+ const file = await ctx.get("file");
51
+ if (!file && process.stdin.isTTY) {
52
+ throw new Error(
53
+ "nothing to push — pass --file <secrets.yml> or pipe yaml (secrets-pull format) to stdin",
54
+ );
55
+ }
56
+ const raw = file
57
+ ? (await readFile(file)).toString("utf-8")
58
+ : await readStdin();
59
+ const document = parse(raw) as SecretsDocument;
60
+ if (!document || typeof document !== "object") {
61
+ throw new Error(
62
+ "the document must be a yaml object: component → env → key: value (see secrets-pull)",
63
+ );
64
+ }
65
+
66
+ const problems = checkSecretsDocument(document, scoped, {
67
+ requireComplete: false,
68
+ });
69
+ if (problems.length > 0) {
70
+ throw new Error(
71
+ "😿 the document does not match the config:\n" +
72
+ problems.map((problem) => ` - ${problem}`).join("\n"),
73
+ );
74
+ }
75
+
76
+ const { writes, skipped } = collectSecretsWrites(document, scoped);
77
+ if (skipped.length > 0) {
78
+ ctx.log("⏭️ skipping placeholder/empty values:");
79
+ skipped.forEach((entry) => ctx.log(` - ${entry}`));
80
+ }
81
+ if (writes.length === 0) {
82
+ ctx.log("no secrets to write 🤷");
83
+ return;
84
+ }
85
+
86
+ ctx.log("about to write:");
87
+ writes.forEach(({ env, componentName, secrets }) =>
88
+ ctx.log(
89
+ ` - ${env}:${componentName}: ${Object.keys(secrets).join(", ")}`,
90
+ ),
91
+ );
92
+ const confirmed = await ctx.confirm("continue? 🤔");
93
+ if (!confirmed) {
94
+ throw new Error("abort");
95
+ }
96
+
97
+ await writeSecretsAndMirror(ctx, writes);
98
+ ctx.log("done! 😻");
99
+ },
100
+ });
@@ -0,0 +1,102 @@
1
+ import { readFile } from "fs-extra";
2
+ import { defineCommand } from "../../../core/defineCommand";
3
+ import type { IO } from "../../../core/types";
4
+ import { readStdin } from "../../../utils/readStdin";
5
+ import type { SecretsWrite } from "./document";
6
+ import { writeSecretsAndMirror } from "./document";
7
+ import {
8
+ getScopedSecretKeys,
9
+ resolveSecretsScope,
10
+ secretsScopeChoices,
11
+ } from "./scope";
12
+
13
+ const resolveValue = async (
14
+ io: IO,
15
+ value: string | undefined,
16
+ valueFile: string | undefined,
17
+ ): Promise<string> => {
18
+ if (valueFile) {
19
+ // a trailing newline is almost never part of the secret
20
+ return (await readFile(valueFile)).toString("utf-8").replace(/\n$/, "");
21
+ }
22
+ if (value !== undefined) {
23
+ return value;
24
+ }
25
+ if (!process.stdin.isTTY) {
26
+ return (await readStdin()).replace(/\n$/, "");
27
+ }
28
+ return io.promptDirect({
29
+ name: "value",
30
+ type: "string",
31
+ message: "value",
32
+ });
33
+ };
34
+
35
+ export const commandSecretsSet = defineCommand({
36
+ name: "project secrets-set",
37
+ description:
38
+ "sets one secret — in one env (dev:web), one env everywhere (dev:), or all envs of a component (:web); value via --value, --value-file or stdin",
39
+ group: "project",
40
+ inputs: {
41
+ envComponent: {
42
+ type: "string",
43
+ message: "scope: env:component, env:, or :component",
44
+ positional: true,
45
+ choices: async () => secretsScopeChoices(),
46
+ },
47
+ key: {
48
+ type: "string",
49
+ message: "which secret?",
50
+ positional: true,
51
+ choices: async (ctx) => {
52
+ const scope = await resolveSecretsScope(await ctx.get("envComponent"));
53
+ const scoped = await getScopedSecretKeys(scope);
54
+ return [...new Set(scoped.flatMap(({ keys }) => keys))];
55
+ },
56
+ },
57
+ value: {
58
+ type: "string",
59
+ message:
60
+ "the secret value (prefer --value-file or stdin over shell history)",
61
+ required: false,
62
+ },
63
+ valueFile: {
64
+ type: "string",
65
+ message: "read the secret value from this file",
66
+ required: false,
67
+ },
68
+ },
69
+ execute: async (ctx) => {
70
+ const scope = await resolveSecretsScope(await ctx.get("envComponent"));
71
+ const key = await ctx.get("key");
72
+ // drops env/component pairs where the key is not declared and
73
+ // throws when it is declared nowhere in the scope
74
+ const scoped = await getScopedSecretKeys(scope, [key]);
75
+ const value = await resolveValue(
76
+ ctx,
77
+ await ctx.get("value"),
78
+ await ctx.get("valueFile"),
79
+ );
80
+
81
+ const targets = scoped.map(
82
+ ({ env, componentName }) => `${env}:${componentName}`,
83
+ );
84
+ ctx.log(`setting ${key} in: ${targets.join(", ")}`);
85
+ if (targets.length > 1) {
86
+ const confirmed = await ctx.confirm(
87
+ `write the same value to ${targets.length} environments? 🤔`,
88
+ );
89
+ if (!confirmed) {
90
+ throw new Error("abort");
91
+ }
92
+ }
93
+
94
+ const writes: SecretsWrite[] = scoped.map(({ env, componentName }) => ({
95
+ env,
96
+ componentName,
97
+ secrets: { [key]: value },
98
+ }));
99
+ await writeSecretsAndMirror(ctx, writes);
100
+ ctx.log("done! 😻");
101
+ },
102
+ });