@catladder/cli 0.0.0-next-4e7c6848 → 0.0.0-next-8b9ad5ca

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 (103) hide show
  1. package/dist/bundles/catenv/index.js +617 -19
  2. package/dist/bundles/cli/index.js +811 -18
  3. package/dist/cli/src/adapters/baseContext.d.ts +4 -0
  4. package/dist/cli/src/adapters/baseContext.js +5 -0
  5. package/dist/cli/src/adapters/baseContext.js.map +1 -1
  6. package/dist/cli/src/adapters/nonInteractive.d.ts +9 -0
  7. package/dist/cli/src/adapters/nonInteractive.js +32 -0
  8. package/dist/cli/src/adapters/nonInteractive.js.map +1 -0
  9. package/dist/cli/src/adapters/programmatic.d.ts +1 -0
  10. package/dist/cli/src/adapters/programmatic.js +6 -1
  11. package/dist/cli/src/adapters/programmatic.js.map +1 -1
  12. package/dist/cli/src/adapters/terminal.d.ts +1 -0
  13. package/dist/cli/src/adapters/terminal.js +10 -1
  14. package/dist/cli/src/adapters/terminal.js.map +1 -1
  15. package/dist/cli/src/adapters/vaultManagerAccess.d.ts +8 -0
  16. package/dist/cli/src/adapters/vaultManagerAccess.js +26 -0
  17. package/dist/cli/src/adapters/vaultManagerAccess.js.map +1 -0
  18. package/dist/cli/src/apps/catenv/catenv.d.ts +2 -0
  19. package/dist/cli/src/apps/catenv/catenv.js +8 -1
  20. package/dist/cli/src/apps/catenv/catenv.js.map +1 -1
  21. package/dist/cli/src/apps/catenv/printVariables.d.ts +2 -2
  22. package/dist/cli/src/apps/catenv/printVariables.js +4 -4
  23. package/dist/cli/src/apps/catenv/printVariables.js.map +1 -1
  24. package/dist/cli/src/apps/catenv/types.d.ts +9 -0
  25. package/dist/cli/src/apps/catenv/writeDotEnvFiles.d.ts +2 -2
  26. package/dist/cli/src/apps/catenv/writeDotEnvFiles.js +1 -1
  27. package/dist/cli/src/apps/catenv/writeDotEnvFiles.js.map +1 -1
  28. package/dist/cli/src/apps/cli/commands/project/commandConfigSecrets.js +52 -3
  29. package/dist/cli/src/apps/cli/commands/project/commandConfigSecrets.js.map +1 -1
  30. package/dist/cli/src/catenv.js +3 -2
  31. package/dist/cli/src/catenv.js.map +1 -1
  32. package/dist/cli/src/commands/index.d.ts +1 -0
  33. package/dist/cli/src/commands/index.js +8 -1
  34. package/dist/cli/src/commands/index.js.map +1 -1
  35. package/dist/cli/src/commands/project/commandSecretsSyncGithub.d.ts +37 -0
  36. package/dist/cli/src/commands/project/commandSecretsSyncGithub.js +127 -0
  37. package/dist/cli/src/commands/project/commandSecretsSyncGithub.js.map +1 -0
  38. package/dist/cli/src/config/getProjectConfig.d.ts +1 -1
  39. package/dist/cli/src/config/getProjectConfig.js +12 -2
  40. package/dist/cli/src/config/getProjectConfig.js.map +1 -1
  41. package/dist/cli/src/core/types.d.ts +15 -0
  42. package/dist/cli/src/core/types.js +8 -1
  43. package/dist/cli/src/core/types.js.map +1 -1
  44. package/dist/cli/src/git/gitProjectInformation.d.ts +1 -1
  45. package/dist/cli/src/git/gitProjectInformation.js +3 -3
  46. package/dist/cli/src/git/gitProjectInformation.js.map +1 -1
  47. package/dist/cli/src/utils/github.d.ts +15 -0
  48. package/dist/cli/src/utils/github.js +54 -0
  49. package/dist/cli/src/utils/github.js.map +1 -0
  50. package/dist/cli/src/vault/BitwardenVault.d.ts +24 -0
  51. package/dist/cli/src/vault/BitwardenVault.js +107 -0
  52. package/dist/cli/src/vault/BitwardenVault.js.map +1 -0
  53. package/dist/cli/src/vault/GitlabVault.d.ts +11 -0
  54. package/dist/cli/src/vault/GitlabVault.js +24 -0
  55. package/dist/cli/src/vault/GitlabVault.js.map +1 -0
  56. package/dist/cli/src/vault/VaultManager.d.ts +43 -0
  57. package/dist/cli/src/vault/VaultManager.js +131 -0
  58. package/dist/cli/src/vault/VaultManager.js.map +1 -0
  59. package/dist/cli/src/vault/bw.d.ts +15 -0
  60. package/dist/cli/src/vault/bw.js +89 -0
  61. package/dist/cli/src/vault/bw.js.map +1 -0
  62. package/dist/cli/src/vault/cache.d.ts +38 -0
  63. package/dist/cli/src/vault/cache.js +53 -0
  64. package/dist/cli/src/vault/cache.js.map +1 -0
  65. package/dist/cli/src/vault/index.d.ts +5 -0
  66. package/dist/cli/src/vault/index.js +48 -0
  67. package/dist/cli/src/vault/index.js.map +1 -0
  68. package/dist/cli/src/vault/types.d.ts +33 -0
  69. package/dist/cli/src/vault/types.js +5 -0
  70. package/dist/cli/src/vault/types.js.map +1 -0
  71. package/dist/pipeline/src/backends/index.d.ts +17 -1
  72. package/dist/pipeline/src/backends/index.js +33 -1
  73. package/dist/pipeline/src/backends/index.js.map +1 -1
  74. package/dist/pipeline/src/constants.js +1 -1
  75. package/dist/pipeline/src/deploy/cloudRun/index.js +11 -4
  76. package/dist/pipeline/src/deploy/cloudRun/index.js.map +1 -1
  77. package/dist/pipeline/src/types/config.d.ts +37 -3
  78. package/dist/tsconfig.tsbuildinfo +1 -1
  79. package/package.json +1 -1
  80. package/src/adapters/baseContext.ts +10 -0
  81. package/src/adapters/nonInteractive.ts +30 -0
  82. package/src/adapters/programmatic.ts +7 -1
  83. package/src/adapters/terminal.ts +9 -1
  84. package/src/adapters/vaultManagerAccess.ts +25 -0
  85. package/src/apps/catenv/catenv.ts +5 -1
  86. package/src/apps/catenv/printVariables.ts +16 -6
  87. package/src/apps/catenv/types.ts +9 -0
  88. package/src/apps/catenv/writeDotEnvFiles.ts +3 -3
  89. package/src/apps/cli/commands/project/commandConfigSecrets.ts +94 -9
  90. package/src/catenv.ts +22 -2
  91. package/src/commands/index.ts +1 -0
  92. package/src/commands/project/commandSecretsSyncGithub.ts +149 -0
  93. package/src/config/getProjectConfig.ts +21 -7
  94. package/src/core/types.ts +23 -0
  95. package/src/git/gitProjectInformation.ts +5 -3
  96. package/src/utils/github.ts +59 -0
  97. package/src/vault/BitwardenVault.ts +161 -0
  98. package/src/vault/GitlabVault.ts +25 -0
  99. package/src/vault/VaultManager.ts +211 -0
  100. package/src/vault/bw.ts +110 -0
  101. package/src/vault/cache.ts +80 -0
  102. package/src/vault/index.ts +5 -0
  103. package/src/vault/types.ts +42 -0
package/package.json CHANGED
@@ -53,7 +53,7 @@
53
53
  }
54
54
  ],
55
55
  "license": "MIT",
56
- "version": "0.0.0-next-4e7c6848",
56
+ "version": "0.0.0-next-8b9ad5ca",
57
57
  "scripts": {
58
58
  "lint": "eslint \"src/**/*.ts\"",
59
59
  "lint:fix": "eslint \"src/**/*.ts\" --fix",
@@ -6,6 +6,8 @@ import type {
6
6
  InputsSchema,
7
7
  OptionalInput,
8
8
  } from "../core/types";
9
+ import type { VaultManager } from "../vault/VaultManager";
10
+ import { createVaultManagerGetter } from "./vaultManagerAccess";
9
11
 
10
12
  /**
11
13
  * Base class for CommandContext implementations.
@@ -19,9 +21,17 @@ export abstract class BaseContext<TInputs extends InputsSchema>
19
21
 
20
22
  constructor(protected readonly command: CommandDef<TInputs>) {}
21
23
 
24
+ abstract readonly interactive: boolean;
25
+
22
26
  abstract log(message: string): void;
23
27
  abstract confirm(message: string): Promise<boolean>;
24
28
 
29
+ private readonly vaultManagerGetter = createVaultManagerGetter(() => this);
30
+
31
+ getVaultManager(): Promise<VaultManager> {
32
+ return this.vaultManagerGetter();
33
+ }
34
+
25
35
  /**
26
36
  * Resolve a single input value. Called once per input (result is cached).
27
37
  * Implementations check pre-supplied values, prompt interactively, use defaults, or throw.
@@ -0,0 +1,30 @@
1
+ import type { IO } from "../core/types";
2
+ import { NonInteractiveError } from "../core/types";
3
+ import type { SecretsMode } from "../vault";
4
+ import { createVaultManagerGetter } from "./vaultManagerAccess";
5
+
6
+ /**
7
+ * the context of non-interactive runs (catenv via direnv): logging
8
+ * goes to stderr, any required interaction fails with a clear error.
9
+ */
10
+ export function createNonInteractiveIO(
11
+ options: { vaultMode?: SecretsMode } = {},
12
+ ): IO {
13
+ const io: IO = {
14
+ interactive: false,
15
+ getVaultManager: createVaultManagerGetter(() => io, {
16
+ mode: options.vaultMode,
17
+ }),
18
+ log(message: string): void {
19
+ // stderr: stdout may be evaluated (direnv)
20
+ console.error(message);
21
+ },
22
+ async confirm(message: string): Promise<boolean> {
23
+ throw new NonInteractiveError(`confirmation ("${message}")`);
24
+ },
25
+ async promptDirect(spec): Promise<never> {
26
+ throw new NonInteractiveError(`input "${spec.name}"`);
27
+ },
28
+ };
29
+ return io;
30
+ }
@@ -7,6 +7,7 @@ import type {
7
7
  } from "../core/types";
8
8
  import { MissingInputError } from "../core/types";
9
9
  import { BaseContext } from "./baseContext";
10
+ import { createVaultManagerGetter } from "./vaultManagerAccess";
10
11
 
11
12
  export interface ProgrammaticOptions {
12
13
  /** Pre-supplied values keyed by input name */
@@ -21,6 +22,8 @@ class ProgrammaticContext<
21
22
  private inputs: Record<string, unknown>;
22
23
  private onLog?: (message: string) => void;
23
24
 
25
+ readonly interactive = false;
26
+
24
27
  constructor(command: CommandDef<TInputs>, options: ProgrammaticOptions) {
25
28
  super(command);
26
29
  this.inputs = options.inputs ?? {};
@@ -66,7 +69,9 @@ export function createProgrammaticContext<TInputs extends InputsSchema>(
66
69
  export function createProgrammaticIO(options: ProgrammaticOptions = {}): IO {
67
70
  const { inputs = {}, onLog } = options;
68
71
 
69
- return {
72
+ const io: IO = {
73
+ interactive: false,
74
+ getVaultManager: createVaultManagerGetter(() => io),
70
75
  log(message: string): void {
71
76
  onLog?.(message);
72
77
  },
@@ -85,4 +90,5 @@ export function createProgrammaticIO(options: ProgrammaticOptions = {}): IO {
85
90
  throw new MissingInputError(spec.name, spec.message);
86
91
  },
87
92
  };
93
+ return io;
88
94
  }
@@ -8,6 +8,7 @@ import type {
8
8
  } from "../core/types";
9
9
  import { MissingInputError } from "../core/types";
10
10
  import { BaseContext } from "./baseContext";
11
+ import { createVaultManagerGetter } from "./vaultManagerAccess";
11
12
 
12
13
  /**
13
14
  * Resolve choices from a choices function.
@@ -93,6 +94,10 @@ class TerminalContext<
93
94
  super(command);
94
95
  }
95
96
 
97
+ get interactive(): boolean {
98
+ return this.options.interactive ?? process.stdin.isTTY === true;
99
+ }
100
+
96
101
  log(message: string): void {
97
102
  console.log(message);
98
103
  }
@@ -180,7 +185,9 @@ export function createTerminalContext<TInputs extends InputsSchema>(
180
185
  * Create a bare IO instance for terminal helper functions.
181
186
  */
182
187
  export function createTerminalIO(options: TerminalOptions = {}): IO {
183
- return {
188
+ const io: IO = {
189
+ interactive: options.interactive ?? process.stdin.isTTY === true,
190
+ getVaultManager: createVaultManagerGetter(() => io),
184
191
  log(message: string): void {
185
192
  console.log(message);
186
193
  },
@@ -204,4 +211,5 @@ export function createTerminalIO(options: TerminalOptions = {}): IO {
204
211
  throw new MissingInputError(spec.name, spec.message);
205
212
  },
206
213
  };
214
+ return io;
207
215
  }
@@ -0,0 +1,25 @@
1
+ import { getProjectConfig } from "../config/getProjectConfig";
2
+ import type { IO } from "../core/types";
3
+ import type { VaultManagerOptions } from "../vault/VaultManager";
4
+ import { VaultManager } from "../vault/VaultManager";
5
+
6
+ /**
7
+ * lazily creates the vault manager of a context (once per context),
8
+ * loading the project config on first use
9
+ */
10
+ export const createVaultManagerGetter = (
11
+ io: () => IO,
12
+ options: Omit<VaultManagerOptions, "io"> = {},
13
+ ): (() => Promise<VaultManager>) => {
14
+ let managerPromise: Promise<VaultManager> | undefined;
15
+ return () =>
16
+ (managerPromise ??= (async () => {
17
+ const config = await getProjectConfig();
18
+ if (!config) {
19
+ throw new Error(
20
+ "no catladder config found — the secrets vault needs a project config",
21
+ );
22
+ }
23
+ return new VaultManager(config, { ...options, io: io() });
24
+ })());
25
+ };
@@ -8,10 +8,13 @@ import {
8
8
  createCatenvContext,
9
9
  generatePipelineFiles,
10
10
  } from "@catladder/pipeline";
11
+ import type { SecretsMode } from "../../vault";
12
+ import { createNonInteractiveIO } from "../../adapters/nonInteractive";
11
13
 
12
14
  type Options = {
13
15
  verbose?: boolean;
14
16
  printVariables?: boolean;
17
+ vaultMode?: SecretsMode;
15
18
  };
16
19
  export default async (choice?: Choice, options?: Options) => {
17
20
  const config = await getProjectConfig();
@@ -19,7 +22,8 @@ export default async (choice?: Choice, options?: Options) => {
19
22
  return;
20
23
  }
21
24
 
22
- const context = createCatenvContext(config);
25
+ const io = createNonInteractiveIO({ vaultMode: options?.vaultMode });
26
+ const context = { ...createCatenvContext(config), io };
23
27
  if (options?.verbose) {
24
28
  printVerboseBanner();
25
29
  }
@@ -1,4 +1,6 @@
1
- import type { CatenvContext, Config } from "@catladder/pipeline";
1
+ import type { Config } from "@catladder/pipeline";
2
+ import type { IO } from "../../core/types";
3
+ import type { CatenvCliContext } from "./types";
2
4
  import { getEnvVarsResolved } from "../../config/getProjectConfig";
3
5
  import type { Choice } from "./types";
4
6
  import {
@@ -7,21 +9,25 @@ import {
7
9
  sanitizeEnvVarName,
8
10
  } from "./utils";
9
11
 
10
- const getAllVariablesToPrint = async (config: Config, choice?: Choice) => {
12
+ const getAllVariablesToPrint = async (
13
+ io: IO,
14
+ config: Config,
15
+ choice?: Choice,
16
+ ) => {
11
17
  const { env, currentComponent } = await getCurrentComponentAndEnvFromChoice(
12
18
  config,
13
19
  choice,
14
20
  );
15
21
 
16
22
  if (currentComponent) {
17
- return await getEnvVarsResolved(null, env, currentComponent);
23
+ return await getEnvVarsResolved(io, env, currentComponent);
18
24
  } else {
19
25
  // when in a monorep and not in a subapp, merge all env vars.
20
26
  // this is not 100% correct, but better than not exporting any vars at all
21
27
  // so we also add prefixed variants
22
28
  return await Object.keys(config.components).reduce(
23
29
  async (acc, componentName) => {
24
- const subappvars = await getEnvVarsResolved(null, env, componentName);
30
+ const subappvars = await getEnvVarsResolved(io, env, componentName);
25
31
  delete subappvars["_ALL_ENV_VAR_KEYS"];
26
32
  return {
27
33
  ...(await acc),
@@ -41,10 +47,14 @@ const getAllVariablesToPrint = async (config: Config, choice?: Choice) => {
41
47
  };
42
48
 
43
49
  export const printVariables = async (
44
- context: CatenvContext,
50
+ context: CatenvCliContext,
45
51
  choice?: Choice,
46
52
  ) => {
47
- const variables = await getAllVariablesToPrint(context.config, choice);
53
+ const variables = await getAllVariablesToPrint(
54
+ context.io,
55
+ context.config,
56
+ choice,
57
+ );
48
58
 
49
59
  console.log(makeExportKeyValuestring(variables));
50
60
  };
@@ -1,3 +1,12 @@
1
+ import type { CatenvContext } from "@catladder/pipeline";
2
+ import type { IO } from "../../core/types";
3
+
4
+ /**
5
+ * the pipeline's catenv context extended with the cli io context
6
+ * (non-interactive when run via direnv)
7
+ */
8
+ export type CatenvCliContext = CatenvContext & { io: IO };
9
+
1
10
  import type { VariableValue } from "@catladder/pipeline";
2
11
 
3
12
  export type Choice = {
@@ -7,10 +7,10 @@ import {
7
7
  getCurrentComponentAndEnvFromChoice,
8
8
  makeKeyValueString,
9
9
  } from "./utils";
10
- import type { CatenvContext } from "@catladder/pipeline";
10
+ import type { CatenvCliContext } from "./types";
11
11
 
12
12
  export const writeDotEnvFiles = async (
13
- context: CatenvContext,
13
+ context: CatenvCliContext,
14
14
  choice?: Choice,
15
15
  ) => {
16
16
  const { env, currentComponent } = await getCurrentComponentAndEnvFromChoice(
@@ -32,7 +32,7 @@ export const writeDotEnvFiles = async (
32
32
  const gitRoot = await getGitRoot();
33
33
 
34
34
  for (const componentName of componentsToActuallyWriteDotEnvNow) {
35
- const variables = await getEnvVarsResolved(null, env, componentName);
35
+ const variables = await getEnvVarsResolved(context.io, env, componentName);
36
36
  delete variables["_ALL_ENV_VAR_KEYS"];
37
37
  const componentDir = getComponentFullPath(
38
38
  gitRoot,
@@ -1,4 +1,9 @@
1
- import { getSecretVarName } from "@catladder/pipeline";
1
+ import {
2
+ getEnabledPipelineTypes,
3
+ getSecretVarName,
4
+ getVaultConfig,
5
+ } from "@catladder/pipeline";
6
+ import type { Config } from "@catladder/pipeline";
2
7
  import { stripIndents } from "common-tags";
3
8
  import { difference } from "lodash-es";
4
9
  import type { IO } from "../../../../core/types";
@@ -8,9 +13,15 @@ import {
8
13
  getEnvVarsResolved,
9
14
  getJobOnlyEnvVarsResolved,
10
15
  getProjectComponents,
16
+ getProjectConfig,
11
17
  parseChoice,
12
18
  } from "../../../../config/getProjectConfig";
19
+ import {
20
+ getConfiguredGithubRepo,
21
+ pushSecretsToGithub,
22
+ } from "../../../../commands/project/commandSecretsSyncGithub";
13
23
  import { editAsFile } from "../../../../utils/editAsFile";
24
+ import { isGhAuthenticated } from "../../../../utils/github";
14
25
  import { upsertAllVariables } from "../../../../utils/gitlab";
15
26
  import { delay } from "../../../../utils/promise";
16
27
 
@@ -169,27 +180,101 @@ const doItFor = async (
169
180
  }
170
181
  }
171
182
  }
183
+ const config = await getProjectConfig();
184
+ if (!config) {
185
+ throw new Error("no catladder config found");
186
+ }
187
+
172
188
  instance.log("");
173
- instance.log("upserting all variables, please wait...");
189
+ instance.log("writing all secrets to the vault, please wait...");
174
190
  instance.log("");
175
191
  for (const [componentName, envs] of Object.entries(envAndComponents)) {
176
192
  for (const env of envs) {
177
- instance.log("upserting " + env + ":" + componentName + "...\n");
178
- await upsertAllVariables(
179
- instance,
180
- valuesToEdit[componentName][env],
181
- env,
182
- componentName,
183
- );
193
+ instance.log("writing " + env + ":" + componentName + "...\n");
194
+ await (
195
+ await instance.getVaultManager()
196
+ ).writeSecrets(env, componentName, valuesToEdit[componentName][env]);
184
197
  instance.log("");
185
198
  instance.log("✅ " + env + ":" + componentName);
186
199
  instance.log("--------------------------------\n");
187
200
  }
188
201
  }
202
+
203
+ await mirrorSecretsToCiBackends(
204
+ instance,
205
+ config,
206
+ envAndComponents,
207
+ valuesToEdit,
208
+ );
209
+
189
210
  instance.log("done! 😻");
190
211
  instance.log("");
191
212
  };
192
213
 
214
+ /**
215
+ * the vault is the source of truth; every enabled CI backend receives
216
+ * a mirrored copy of the edited secrets (gitlab variables, github
217
+ * secrets), so all CI systems stay in sync
218
+ */
219
+ const mirrorSecretsToCiBackends = async (
220
+ instance: IO,
221
+ config: Config,
222
+ envAndComponents: { [componentName: string]: string[] },
223
+ valuesToEdit: Vars,
224
+ ) => {
225
+ const enabled = getEnabledPipelineTypes(config);
226
+
227
+ // gitlab mirror (unless gitlab is the vault itself, then it is
228
+ // already written)
229
+ if (getVaultConfig(config).type !== "gitlab" && enabled.includes("gitlab")) {
230
+ for (const [componentName, envs] of Object.entries(envAndComponents)) {
231
+ for (const env of envs) {
232
+ await upsertAllVariables(
233
+ instance,
234
+ valuesToEdit[componentName][env],
235
+ env,
236
+ componentName,
237
+ );
238
+ }
239
+ }
240
+ instance.log("✅ mirrored to gitlab");
241
+ }
242
+
243
+ if (!enabled.includes("github")) {
244
+ return;
245
+ }
246
+ const secrets = Object.entries(envAndComponents).flatMap(
247
+ ([componentName, envs]) =>
248
+ envs.flatMap((env) =>
249
+ Object.entries(valuesToEdit[componentName][env] ?? {}).map(
250
+ ([key, value]) => ({
251
+ name: getSecretVarName(env, componentName, key),
252
+ value:
253
+ typeof value === "string" ? value : JSON.stringify(value ?? ""),
254
+ }),
255
+ ),
256
+ ),
257
+ );
258
+ if (secrets.length === 0) {
259
+ return;
260
+ }
261
+ if (!(await isGhAuthenticated())) {
262
+ instance.log(
263
+ "⚠️ github pipeline is enabled, but the github cli (gh) is not authenticated — run `project secrets-sync-github` later to mirror the secrets",
264
+ );
265
+ return;
266
+ }
267
+ const repo = await getConfiguredGithubRepo();
268
+ if (!repo) {
269
+ instance.log(
270
+ "⚠️ github pipeline is enabled, but no github repository was found — run `project secrets-sync-github` later to mirror the secrets",
271
+ );
272
+ return;
273
+ }
274
+ instance.log(`mirroring ${secrets.length} secrets to github ('${repo}')...`);
275
+ await pushSecretsToGithub(instance, repo, secrets);
276
+ };
277
+
193
278
  export const projectConfigSecrets = async (io: IO, envComponent?: string) => {
194
279
  if (!envComponent) {
195
280
  const allEnvAndcomponents = await getAllComponentsWithAllEnvsHierarchical();
package/src/catenv.ts CHANGED
@@ -1,7 +1,8 @@
1
- import { Command } from "commander";
1
+ import { Command, Option } from "commander";
2
2
  import catenv from "./apps/catenv/catenv";
3
3
  import { parseChoice } from "./config/parseChoice";
4
4
  import packageInfos from "./packageInfos";
5
+ import type { SecretsMode } from "./vault";
5
6
 
6
7
  const program = new Command();
7
8
 
@@ -12,14 +13,33 @@ program
12
13
  .argument("[envComponent]", "env or env:component")
13
14
  .option("-v, --verbose", "verbose output")
14
15
  .option("-p, --print-variables", "print variables")
16
+ .addOption(
17
+ new Option(
18
+ "--vault-mode <mode>",
19
+ [
20
+ "how to interact with the secrets vault:",
21
+ "auto: contact the vault when cached secrets are missing (may prompt to unlock)",
22
+ "no-prompt: like auto, but fail instead of prompting",
23
+ "offline: never contact the vault, use only locally cached secrets",
24
+ "refresh: ignore the local cache once and refresh it from the vault",
25
+ ].join("\n"),
26
+ )
27
+ .choices(["auto", "no-prompt", "offline", "refresh"])
28
+ .default("auto"),
29
+ )
15
30
  .action(
16
31
  (
17
32
  envComponent: string | undefined,
18
- opts: { verbose?: boolean; printVariables?: boolean },
33
+ opts: {
34
+ verbose?: boolean;
35
+ printVariables?: boolean;
36
+ vaultMode: SecretsMode;
37
+ },
19
38
  ) => {
20
39
  catenv(envComponent ? parseChoice(envComponent) : null, {
21
40
  verbose: opts.verbose ?? false,
22
41
  printVariables: opts.printVariables ?? false,
42
+ vaultMode: opts.vaultMode,
23
43
  }).then(() => {
24
44
  // we have to exit manually, because we have some file watches
25
45
  process.exit();
@@ -15,6 +15,7 @@ export { commandOpenGit } from "./project/commandOpenGit";
15
15
  export { commandCloudSqlProxy } from "./project/commandCloudSqlProxy";
16
16
  export { commandConfigSecrets } from "./project/commandConfigSecrets";
17
17
  export { commandSecretsClearBackups } from "./project/commandSecretsClearBackups";
18
+ export { commandSecretsSyncGithub } from "./project/commandSecretsSyncGithub";
18
19
  export { commandOpenLogs } from "./project/commandOpenLogs";
19
20
  export { commandTriggerCronjobGeneral } from "./project/k8s/commandTriggerCronjob";
20
21
  export { commandRenewToken } from "./project/commandRenewToken";
@@ -0,0 +1,149 @@
1
+ import {
2
+ getEnabledPipelineTypes,
3
+ getPipelineGitRemote,
4
+ getSecretVarName,
5
+ } from "@catladder/pipeline";
6
+ import {
7
+ getAllComponentsWithAllEnvsHierarchical,
8
+ getEnvironment,
9
+ getProjectConfig,
10
+ } from "../../config/getProjectConfig";
11
+ import { defineCommand } from "../../core/defineCommand";
12
+ import type { IO } from "../../core/types";
13
+ import {
14
+ getGithubRepoFromRemote,
15
+ isGhAuthenticated,
16
+ setGithubSecret,
17
+ } from "../../utils/github";
18
+
19
+ /**
20
+ * all secrets declared in the config, with their values read from the
21
+ * vault (github secrets are write-only and can never act as a source)
22
+ */
23
+ export const collectSecretsFromVault = async (io: IO) => {
24
+ const envAndComponents = await getAllComponentsWithAllEnvsHierarchical();
25
+
26
+ const names: string[] = [];
27
+ for (const [componentName, envs] of Object.entries(envAndComponents)) {
28
+ for (const env of envs) {
29
+ const { secretEnvVarKeys, jobOnlyVars } = await getEnvironment(
30
+ env,
31
+ componentName,
32
+ );
33
+ const keys = [
34
+ ...secretEnvVarKeys,
35
+ ...jobOnlyVars.build.secretEnvVarKeys,
36
+ ...jobOnlyVars.deploy.secretEnvVarKeys,
37
+ ];
38
+ keys.forEach(({ key }) =>
39
+ names.push(getSecretVarName(env, componentName, key)),
40
+ );
41
+ }
42
+ }
43
+
44
+ const values = await (await io.getVaultManager()).readSecrets(names, io);
45
+ const secrets = names
46
+ .filter((name) => values[name] !== undefined)
47
+ .map((name) => ({ name, value: values[name] }));
48
+ const missing = names.filter((name) => values[name] === undefined);
49
+ return { secrets, missing };
50
+ };
51
+
52
+ /**
53
+ * pushes secrets to github repository secrets via the gh cli
54
+ */
55
+ export const pushSecretsToGithub = async (
56
+ io: IO,
57
+ repo: string,
58
+ secrets: { name: string; value: string }[],
59
+ ) => {
60
+ for (const { name, value } of secrets) {
61
+ await setGithubSecret(repo, name, value);
62
+ io.log(`✅ ${name}`);
63
+ }
64
+ };
65
+
66
+ /**
67
+ * resolves the github repository from the configured git remote
68
+ * (`pipelines.github.gitRemote`, defaults to origin)
69
+ */
70
+ export const getConfiguredGithubRepo = async (): Promise<
71
+ string | undefined
72
+ > => {
73
+ const config = await getProjectConfig();
74
+ if (!config) {
75
+ return undefined;
76
+ }
77
+ return getGithubRepoFromRemote(getPipelineGitRemote(config, "github"));
78
+ };
79
+
80
+ /**
81
+ * copies the catladder secrets from the vault to github actions
82
+ * repository secrets, keeping the exact same names, so the github
83
+ * pipeline can reference them as ${{ secrets.<NAME> }}.
84
+ */
85
+ export const commandSecretsSyncGithub = defineCommand({
86
+ name: "project secrets-sync-github",
87
+ description:
88
+ "copies all secrets from the vault to github repo secrets (same names), for the github pipeline",
89
+ group: "project",
90
+ inputs: {
91
+ repo: {
92
+ type: "string",
93
+ message: "github repository (owner/name)",
94
+ positional: true,
95
+ required: false,
96
+ },
97
+ },
98
+ execute: async (ctx) => {
99
+ const config = await getProjectConfig();
100
+ if (!config) {
101
+ throw new Error("no catladder config found");
102
+ }
103
+ const enabled = getEnabledPipelineTypes(config);
104
+ if (!enabled.includes("github")) {
105
+ throw new Error(
106
+ "the github pipeline is not enabled in `pipelines` — nothing to sync to",
107
+ );
108
+ }
109
+ if (!(await isGhAuthenticated())) {
110
+ throw new Error(
111
+ "the github cli (gh) is not installed or not authenticated — run `gh auth login` first",
112
+ );
113
+ }
114
+ const repo = (await ctx.get("repo")) ?? (await getConfiguredGithubRepo());
115
+ if (!repo) {
116
+ throw new Error(
117
+ `no github repository found on the '${getPipelineGitRemote(config, "github")}' remote — pass it as owner/name or configure pipelines.github.gitRemote`,
118
+ );
119
+ }
120
+
121
+ const { secrets, missing } = await collectSecretsFromVault(ctx);
122
+
123
+ if (missing.length > 0) {
124
+ ctx.log(
125
+ `⚠️ ${missing.length} secrets are not set in the vault and will be skipped:`,
126
+ );
127
+ missing.forEach((name) => ctx.log(` - ${name}`));
128
+ ctx.log("");
129
+ }
130
+ if (secrets.length === 0) {
131
+ ctx.log("no secrets to sync 🤷");
132
+ return;
133
+ }
134
+
135
+ ctx.log(`about to set ${secrets.length} secrets on '${repo}':`);
136
+ secrets.forEach(({ name }) => ctx.log(` - ${name}`));
137
+ ctx.log("");
138
+ const confirmed = await ctx.confirm("continue? 🤔");
139
+ if (!confirmed) {
140
+ throw new Error("abort");
141
+ }
142
+
143
+ await pushSecretsToGithub(ctx, repo, secrets);
144
+ ctx.log("");
145
+ ctx.log(
146
+ "done! 😻 the github pipeline can use them as ${{ secrets.<NAME> }}",
147
+ );
148
+ },
149
+ });
@@ -12,7 +12,7 @@ import {
12
12
  } from "@catladder/pipeline";
13
13
 
14
14
  import type { IO } from "../core/types";
15
- import { getAllVariables, getVariableValueByRawName } from "../utils/gitlab";
15
+ import { getVariableValueByRawName } from "../utils/gitlab";
16
16
 
17
17
  import { getGitRoot } from "../utils/projects";
18
18
 
@@ -121,10 +121,24 @@ export const getGitlabVar = async (
121
121
  };
122
122
 
123
123
  const resolveSecrets = async (
124
- io: IO | null,
124
+ io: IO,
125
125
  varSets: EnvironmentEnvVars[],
126
126
  ): Promise<Record<string, string>> => {
127
- const allVariablesInGitlab = await getAllVariables(io);
127
+ // the secret variables actually referenced by the env var values —
128
+ // when they are all in the local cache, the vault is not contacted
129
+ const referencedNames = [
130
+ ...new Set(
131
+ varSets.flatMap(({ envVars }) =>
132
+ Object.values(envVars).flatMap(
133
+ (value) => `${value}`.match(/\$CL_\w+/g) ?? [],
134
+ ),
135
+ ),
136
+ ),
137
+ ].map((reference) => reference.slice(1));
138
+
139
+ const vaultManager = await io.getVaultManager();
140
+ const secrets = await vaultManager.readSecrets(referencedNames, io);
141
+ const secretEntries = Object.entries(secrets);
128
142
 
129
143
  return Object.fromEntries(
130
144
  varSets.flatMap(({ envVars, secretEnvVarKeys }) =>
@@ -137,9 +151,9 @@ const resolveSecrets = async (
137
151
  )
138
152
  .map(([key, value]) => [
139
153
  key,
140
- allVariablesInGitlab.reduce(
141
- (acc, curr) =>
142
- acc.replace(new RegExp("\\$" + curr.key, "g"), curr.value),
154
+ secretEntries.reduce(
155
+ (acc, [name, secretValue]) =>
156
+ acc.replace(new RegExp("\\$" + name, "g"), secretValue),
143
157
  `${value}`,
144
158
  ),
145
159
  ]),
@@ -148,7 +162,7 @@ const resolveSecrets = async (
148
162
  };
149
163
 
150
164
  export const getEnvVarsResolved = async (
151
- io: IO | null,
165
+ io: IO,
152
166
  env: string,
153
167
  componentName: string | null,
154
168
  ) => {