@catladder/cli 0.0.0-fix-github-release-deploy-key-8a37ce13 → 0.0.0-fix-ghcr-lowercase-image-names-c09dd5a2

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 (146) hide show
  1. package/dist/apps/cli/src/apps/cli/commands/project/doctor/checkGithub.js +3 -3
  2. package/dist/apps/cli/src/apps/cli/commands/project/doctor/checkGithub.js.map +1 -1
  3. package/dist/apps/cli/src/apps/cli/commands/project/doctor/checkGithubImageCasing.d.ts +15 -0
  4. package/dist/apps/cli/src/apps/cli/commands/project/doctor/checkGithubImageCasing.js +60 -0
  5. package/dist/apps/cli/src/apps/cli/commands/project/doctor/checkGithubImageCasing.js.map +1 -0
  6. package/dist/apps/cli/src/apps/cli/commands/project/doctor/index.js +2 -0
  7. package/dist/apps/cli/src/apps/cli/commands/project/doctor/index.js.map +1 -1
  8. package/dist/apps/cli/src/apps/cli/commands/project/setup/setupAccessTokens.js +2 -2
  9. package/dist/apps/cli/src/apps/cli/commands/project/setup/setupAccessTokens.js.map +1 -1
  10. package/dist/apps/cli/src/apps/cli/commands/project/setup/setupGithub.js +2 -2
  11. package/dist/apps/cli/src/apps/cli/commands/project/setup/setupGithub.js.map +1 -1
  12. package/dist/apps/cli/src/apps/cli/commands/project/setup/setupKubernetes.js +23 -7
  13. package/dist/apps/cli/src/apps/cli/commands/project/setup/setupKubernetes.js.map +1 -1
  14. package/dist/apps/cli/src/commands/index.d.ts +1 -0
  15. package/dist/apps/cli/src/commands/index.js +4 -1
  16. package/dist/apps/cli/src/commands/index.js.map +1 -1
  17. package/dist/apps/cli/src/commands/project/commandConfigSecrets.js +2 -1
  18. package/dist/apps/cli/src/commands/project/commandConfigSecrets.js.map +1 -1
  19. package/dist/apps/cli/src/commands/project/commandMrPin.d.ts +21 -0
  20. package/dist/apps/cli/src/commands/project/commandMrPin.js +119 -0
  21. package/dist/apps/cli/src/commands/project/commandMrPin.js.map +1 -0
  22. package/dist/apps/cli/src/commands/project/commandSecretsSyncGithub.d.ts +1 -42
  23. package/dist/apps/cli/src/commands/project/commandSecretsSyncGithub.js +5 -124
  24. package/dist/apps/cli/src/commands/project/commandSecretsSyncGithub.js.map +1 -1
  25. package/dist/apps/cli/src/commands/project/secrets/commandSecretsPush.js +2 -1
  26. package/dist/apps/cli/src/commands/project/secrets/commandSecretsPush.js.map +1 -1
  27. package/dist/apps/cli/src/commands/project/secrets/commandSecretsSet.js +2 -2
  28. package/dist/apps/cli/src/commands/project/secrets/commandSecretsSet.js.map +1 -1
  29. package/dist/apps/cli/src/commands/project/secrets/document.d.ts +1 -12
  30. package/dist/apps/cli/src/commands/project/secrets/document.js +1 -79
  31. package/dist/apps/cli/src/commands/project/secrets/document.js.map +1 -1
  32. package/dist/apps/cli/src/gcloud/serviceAccounts.js +15 -4
  33. package/dist/apps/cli/src/gcloud/serviceAccounts.js.map +1 -1
  34. package/dist/apps/cli/src/release/changesetsReleaseJob.js +3 -0
  35. package/dist/apps/cli/src/release/changesetsReleaseJob.js.map +1 -1
  36. package/dist/apps/cli/src/release/releaseEntry.d.ts +12 -0
  37. package/dist/apps/cli/src/release/releaseEntry.js +71 -0
  38. package/dist/apps/cli/src/release/releaseEntry.js.map +1 -0
  39. package/dist/apps/cli/src/secrets/github.d.ts +41 -0
  40. package/dist/apps/cli/src/secrets/github.js +127 -0
  41. package/dist/apps/cli/src/secrets/github.js.map +1 -0
  42. package/dist/apps/cli/src/secrets/index.d.ts +2 -0
  43. package/dist/apps/cli/src/secrets/index.js +19 -0
  44. package/dist/apps/cli/src/secrets/index.js.map +1 -0
  45. package/dist/apps/cli/src/secrets/writeSecrets.d.ts +18 -0
  46. package/dist/apps/cli/src/secrets/writeSecrets.js +88 -0
  47. package/dist/apps/cli/src/secrets/writeSecrets.js.map +1 -0
  48. package/dist/apps/cli/src/utils/github.js +2 -2
  49. package/dist/apps/cli/src/utils/github.js.map +1 -1
  50. package/dist/apps/cli/src/utils/gitlab.d.ts +10 -0
  51. package/dist/apps/cli/src/utils/gitlab.js +30 -5
  52. package/dist/apps/cli/src/utils/gitlab.js.map +1 -1
  53. package/dist/apps/cli/src/vault/BitwardenVault.d.ts +6 -0
  54. package/dist/apps/cli/src/vault/BitwardenVault.js +11 -1
  55. package/dist/apps/cli/src/vault/BitwardenVault.js.map +1 -1
  56. package/dist/apps/cli/src/vault/GitlabVault.d.ts +2 -2
  57. package/dist/apps/cli/src/vault/GitlabVault.js +3 -2
  58. package/dist/apps/cli/src/vault/GitlabVault.js.map +1 -1
  59. package/dist/apps/cli/src/vault/VaultManager.d.ts +2 -2
  60. package/dist/apps/cli/src/vault/VaultManager.js +2 -2
  61. package/dist/apps/cli/src/vault/VaultManager.js.map +1 -1
  62. package/dist/apps/cli/src/vault/types.d.ts +16 -2
  63. package/dist/bundles/catci/index.js +4 -4
  64. package/dist/bundles/catenv/index.js +265 -28
  65. package/dist/bundles/cli/index.js +851 -329
  66. package/dist/bundles/runner-images/semantic-release/Dockerfile +1 -0
  67. package/dist/bundles/runner-images/semantic-release/scripts/semanticRelease +14 -4
  68. package/dist/bundles/skills/catladder-cli/references/commands.md +13 -0
  69. package/dist/bundles/skills/catladder-config/SKILL.md +3 -1
  70. package/dist/bundles/skills/catladder-deploys/SKILL.md +7 -0
  71. package/dist/bundles/skills/catladder-migrate-ci-backend/SKILL.md +18 -2
  72. package/dist/bundles/skills/catladder-pipelines/SKILL.md +51 -1
  73. package/dist/bundles/skills/catladder-releases/SKILL.md +16 -2
  74. package/dist/bundles/skills/catladder-secrets/SKILL.md +16 -0
  75. package/dist/packages/pipeline/src/autoStop.d.ts +22 -0
  76. package/dist/packages/pipeline/src/autoStop.js +30 -0
  77. package/dist/packages/pipeline/src/autoStop.js.map +1 -0
  78. package/dist/packages/pipeline/src/backends/github/GithubBackend.js +11 -5
  79. package/dist/packages/pipeline/src/backends/github/GithubBackend.js.map +1 -1
  80. package/dist/packages/pipeline/src/backends/github/ciVariables.d.ts +13 -2
  81. package/dist/packages/pipeline/src/backends/github/ciVariables.js +18 -5
  82. package/dist/packages/pipeline/src/backends/github/ciVariables.js.map +1 -1
  83. package/dist/packages/pipeline/src/backends/github/createGithubJobs.js +1 -1
  84. package/dist/packages/pipeline/src/backends/github/createGithubJobs.js.map +1 -1
  85. package/dist/packages/pipeline/src/backends/github/ghcr.d.ts +29 -0
  86. package/dist/packages/pipeline/src/backends/github/ghcr.js +40 -0
  87. package/dist/packages/pipeline/src/backends/github/ghcr.js.map +1 -0
  88. package/dist/packages/pipeline/src/backends/github/index.d.ts +2 -0
  89. package/dist/packages/pipeline/src/backends/github/index.js +2 -0
  90. package/dist/packages/pipeline/src/backends/github/index.js.map +1 -1
  91. package/dist/packages/pipeline/src/backends/github/registryImage.d.ts +20 -0
  92. package/dist/packages/pipeline/src/backends/github/registryImage.js +49 -0
  93. package/dist/packages/pipeline/src/backends/github/registryImage.js.map +1 -0
  94. package/dist/packages/pipeline/src/backends/gitlab/GitlabBackend.js +2 -0
  95. package/dist/packages/pipeline/src/backends/gitlab/GitlabBackend.js.map +1 -1
  96. package/dist/packages/pipeline/src/backends/gitlab/createGitlabPipeline.d.ts +4 -1
  97. package/dist/packages/pipeline/src/backends/gitlab/createGitlabPipeline.js +21 -1
  98. package/dist/packages/pipeline/src/backends/gitlab/createGitlabPipeline.js.map +1 -1
  99. package/dist/packages/pipeline/src/customImages/jobImagesPlan.d.ts +15 -3
  100. package/dist/packages/pipeline/src/customImages/jobImagesPlan.js +14 -3
  101. package/dist/packages/pipeline/src/customImages/jobImagesPlan.js.map +1 -1
  102. package/dist/packages/pipeline/src/deploy/base/deploy.js +8 -2
  103. package/dist/packages/pipeline/src/deploy/base/deploy.js.map +1 -1
  104. package/dist/packages/pipeline/src/index.d.ts +1 -0
  105. package/dist/packages/pipeline/src/index.js +1 -0
  106. package/dist/packages/pipeline/src/index.js.map +1 -1
  107. package/dist/packages/pipeline/src/types/config.d.ts +44 -0
  108. package/dist/runner-images/semantic-release/Dockerfile +1 -0
  109. package/dist/runner-images/semantic-release/scripts/semanticRelease +14 -4
  110. package/dist/skills/catladder-cli/references/commands.md +13 -0
  111. package/dist/skills/catladder-config/SKILL.md +3 -1
  112. package/dist/skills/catladder-deploys/SKILL.md +7 -0
  113. package/dist/skills/catladder-migrate-ci-backend/SKILL.md +18 -2
  114. package/dist/skills/catladder-pipelines/SKILL.md +51 -1
  115. package/dist/skills/catladder-releases/SKILL.md +16 -2
  116. package/dist/skills/catladder-secrets/SKILL.md +16 -0
  117. package/dist/tsconfig.tsbuildinfo +1 -1
  118. package/package.json +1 -1
  119. package/src/apps/cli/commands/project/doctor/checkGithub.ts +1 -1
  120. package/src/apps/cli/commands/project/doctor/checkGithubImageCasing.ts +87 -0
  121. package/src/apps/cli/commands/project/doctor/index.ts +2 -0
  122. package/src/apps/cli/commands/project/setup/setupAccessTokens.ts +3 -2
  123. package/src/apps/cli/commands/project/setup/setupGithub.ts +1 -1
  124. package/src/apps/cli/commands/project/setup/setupKubernetes.ts +31 -20
  125. package/src/commands/index.ts +1 -0
  126. package/src/commands/project/commandConfigSecrets.ts +1 -1
  127. package/src/commands/project/commandMrPin.ts +184 -0
  128. package/src/commands/project/commandSecretsSyncGithub.ts +7 -154
  129. package/src/commands/project/secrets/commandSecretsPush.ts +2 -5
  130. package/src/commands/project/secrets/commandSecretsSet.ts +2 -2
  131. package/src/commands/project/secrets/document.ts +2 -127
  132. package/src/gcloud/serviceAccounts.ts +16 -7
  133. package/src/release/__tests__/releaseEntry.test.ts +88 -0
  134. package/src/release/changesetsReleaseJob.ts +3 -0
  135. package/src/release/releaseEntry.ts +86 -0
  136. package/src/secrets/__tests__/writeSecrets.test.ts +147 -0
  137. package/src/secrets/github.ts +157 -0
  138. package/src/secrets/index.ts +2 -0
  139. package/src/secrets/writeSecrets.ts +138 -0
  140. package/src/utils/github.ts +2 -4
  141. package/src/utils/gitlab.ts +34 -7
  142. package/src/vault/BitwardenVault.ts +10 -1
  143. package/src/vault/GitlabVault.ts +9 -2
  144. package/src/vault/VaultManager.ts +3 -2
  145. package/src/vault/__tests__/BitwardenVault.test.ts +88 -0
  146. package/src/vault/types.ts +17 -1
@@ -0,0 +1,138 @@
1
+ import type { Config } from "@catladder/pipeline";
2
+ import {
3
+ getEnabledPipelineTypes,
4
+ getSecretVarName,
5
+ getVaultConfig,
6
+ } from "@catladder/pipeline";
7
+ import { getEnvironment, getProjectConfig } from "../config/getProjectConfig";
8
+ import type { IO } from "../core/types";
9
+ import { isGhAuthenticated } from "../utils/github";
10
+ import { upsertAllVariables } from "../utils/gitlab";
11
+ import type { WriteSecretsOptions } from "../vault/types";
12
+ import {
13
+ collectSecretTargets,
14
+ getConfiguredGithubRepo,
15
+ pushSecretsToGithub,
16
+ } from "./github";
17
+
18
+ /** secrets of one env of one component, ready to be written */
19
+ export type SecretsWrite = {
20
+ env: string;
21
+ componentName: string;
22
+ secrets: Record<string, unknown>;
23
+ };
24
+
25
+ /**
26
+ * the one door for writing secret values: they go into the vault (the
27
+ * source of truth) and are mirrored to every enabled CI backend —
28
+ * which vault and which backends exist is invisible to the caller.
29
+ *
30
+ * Everything that produces a secret value must come through here: the
31
+ * secrets commands, but also the credentials `project setup`
32
+ * provisions (gcloud service account keys, kubernetes tokens).
33
+ */
34
+ export const writeSecretsAndMirror = async (
35
+ io: IO,
36
+ writes: SecretsWrite[],
37
+ options: WriteSecretsOptions = {},
38
+ ): Promise<void> => {
39
+ const config = await getProjectConfig();
40
+ if (!config) {
41
+ throw new Error("no catladder config found");
42
+ }
43
+
44
+ io.log("");
45
+ io.log("writing secrets to the vault, please wait...");
46
+ io.log("");
47
+ for (const { env, componentName, secrets } of writes) {
48
+ io.log("writing " + env + ":" + componentName + "...\n");
49
+ await (
50
+ await io.getVaultManager()
51
+ ).writeSecrets(env, componentName, secrets, io, options);
52
+ io.log("");
53
+ io.log("✅ " + env + ":" + componentName);
54
+ io.log("--------------------------------\n");
55
+ }
56
+
57
+ await mirrorSecretsToCiBackends(io, config, writes, options);
58
+ };
59
+
60
+ /**
61
+ * the vault is the source of truth; every enabled CI backend receives
62
+ * a mirrored copy of the written secrets (gitlab variables, github
63
+ * secrets), so all CI systems stay in sync
64
+ */
65
+ const mirrorSecretsToCiBackends = async (
66
+ io: IO,
67
+ config: Config,
68
+ writes: SecretsWrite[],
69
+ options: WriteSecretsOptions,
70
+ ) => {
71
+ const enabled = getEnabledPipelineTypes(config);
72
+
73
+ // gitlab mirror (unless gitlab is the vault itself, then it is
74
+ // already written)
75
+ if (getVaultConfig(config).type !== "gitlab" && enabled.includes("gitlab")) {
76
+ for (const { env, componentName, secrets } of writes) {
77
+ await upsertAllVariables(
78
+ io,
79
+ secrets,
80
+ env,
81
+ componentName,
82
+ options.backup ?? true,
83
+ );
84
+ }
85
+ io.log("✅ mirrored to gitlab");
86
+ }
87
+
88
+ if (!enabled.includes("github")) {
89
+ return;
90
+ }
91
+ const kinds = new Map<string, "secret" | "variable">();
92
+ for (const { env, componentName } of writes) {
93
+ const { secretEnvVarKeys, jobOnlyVars } = await getEnvironment(
94
+ env,
95
+ componentName,
96
+ );
97
+ [
98
+ ...secretEnvVarKeys,
99
+ ...jobOnlyVars.build.secretEnvVarKeys,
100
+ ...jobOnlyVars.deploy.secretEnvVarKeys,
101
+ ].forEach(({ key, kind }) =>
102
+ kinds.set(getSecretVarName(env, componentName, key), kind ?? "secret"),
103
+ );
104
+ }
105
+ const secrets = writes.flatMap(({ env, componentName, secrets }) =>
106
+ Object.entries(secrets).map(([key, value]) => {
107
+ const name = getSecretVarName(env, componentName, key);
108
+ return {
109
+ name,
110
+ value: typeof value === "string" ? value : JSON.stringify(value ?? ""),
111
+ kind: kinds.get(name) ?? ("secret" as const),
112
+ };
113
+ }),
114
+ );
115
+ if (secrets.length === 0) {
116
+ return;
117
+ }
118
+ if (!(await isGhAuthenticated())) {
119
+ io.log(
120
+ "⚠️ github pipeline is enabled, but the github cli (gh) is not authenticated — run `project secrets-sync-github` later to mirror the secrets",
121
+ );
122
+ return;
123
+ }
124
+ const repo = await getConfiguredGithubRepo();
125
+ if (!repo) {
126
+ io.log(
127
+ "⚠️ github pipeline is enabled, but no github repository was found — run `project secrets-sync-github` later to mirror the secrets",
128
+ );
129
+ return;
130
+ }
131
+ io.log(`mirroring ${secrets.length} secrets to github ('${repo}')...`);
132
+ await pushSecretsToGithub(
133
+ io,
134
+ repo,
135
+ secrets,
136
+ await collectSecretTargets(config),
137
+ );
138
+ };
@@ -1,5 +1,6 @@
1
1
  import { execFile as execFileCb, spawn } from "child_process";
2
2
  import { promisify } from "util";
3
+ import { parseGithubRepoFromRemoteUrl } from "@catladder/pipeline";
3
4
 
4
5
  const execFile = promisify(execFileCb);
5
6
 
@@ -25,10 +26,7 @@ export const getGithubRepoFromRemote = async (
25
26
  ): Promise<string | undefined> => {
26
27
  try {
27
28
  const { stdout } = await execFile("git", ["remote", "get-url", remoteName]);
28
- const match = stdout.match(
29
- /github\.com[:/]([\w.-]+\/[\w.-]+?)(\.git)?\s*$/,
30
- );
31
- return match?.[1];
29
+ return parseGithubRepoFromRemoteUrl(stdout);
32
30
  } catch {
33
31
  return undefined;
34
32
  }
@@ -57,6 +57,21 @@ export const getGitlabToken = async (io: IO | null) => {
57
57
  return getPreference(TOKEN_KEY);
58
58
  };
59
59
 
60
+ /**
61
+ * a gitlab api call that came back 404. Carries the path so a caller
62
+ * that wants to treat "does not exist yet" as normal can say so
63
+ * (`instanceof`) instead of matching on a message.
64
+ */
65
+ export class GitlabNotFoundError extends Error {
66
+ constructor(
67
+ readonly path: string,
68
+ readonly host: string,
69
+ ) {
70
+ super(`gitlab api: ${path} not found on ${host}`);
71
+ this.name = "GitlabNotFoundError";
72
+ }
73
+ }
74
+
60
75
  type Method = "GET" | "PUT" | "POST" | "DELETE";
61
76
  export const doGitlabRequest = async <T = any>(
62
77
  io: IO | null,
@@ -87,7 +102,7 @@ export const doGitlabRequest = async <T = any>(
87
102
  return null;
88
103
  }
89
104
  if (result.status === 404) {
90
- throw new Error("not found");
105
+ throw new GitlabNotFoundError(path, gitRemoteHost);
91
106
  }
92
107
 
93
108
  throw new Error(
@@ -124,12 +139,24 @@ export const doGitlabRequestAllPages = async <T = any>(
124
139
  export const getProjectInfo = async (
125
140
  io: IO | null,
126
141
  ): Promise<{ id: string; web_url: string }> => {
127
- const { gitRemotePath } = await getGitRemoteHostAndPath();
128
- const project = await doGitlabRequest(
129
- io,
130
- `projects/${encodeURIComponent(gitRemotePath)}`,
131
- );
132
- return project;
142
+ const { gitRemoteHost, gitRemotePath } = await getGitRemoteHostAndPath();
143
+ try {
144
+ return await doGitlabRequest(
145
+ io,
146
+ `projects/${encodeURIComponent(gitRemotePath)}`,
147
+ );
148
+ } catch (e) {
149
+ if (e instanceof GitlabNotFoundError) {
150
+ // the classic: the remote is not a gitlab at all (a github one,
151
+ // say), so the request goes to a host without /api/v4 and every
152
+ // path answers 404
153
+ throw new Error(
154
+ `no gitlab project '${gitRemotePath}' on '${gitRemoteHost}'. ` +
155
+ "Check the git remote and `pipelines` in catladder.ts — this command talks to the gitlab api.",
156
+ );
157
+ }
158
+ throw e;
159
+ }
133
160
  };
134
161
 
135
162
  type GitlabVariable = {
@@ -108,6 +108,12 @@ export class BitwardenVault implements SecretsVault {
108
108
  return secrets;
109
109
  }
110
110
 
111
+ /**
112
+ * upserts into the item's yaml note. The note is rewritten as a
113
+ * whole, so the values that are not part of this write have to be
114
+ * carried over — a single-key write (`secrets-set`, a credential
115
+ * provisioned by `project setup`) must not drop the rest.
116
+ */
111
117
  async writeSecrets(
112
118
  io: IO,
113
119
  env: string,
@@ -130,7 +136,10 @@ export class BitwardenVault implements SecretsVault {
130
136
  ])
131
137
  ).find((item) => item.name === name);
132
138
 
133
- const notes = stringify(secrets);
139
+ const previous = existing?.notes
140
+ ? ((parse(existing.notes) as Record<string, unknown> | null) ?? {})
141
+ : {};
142
+ const notes = stringify({ ...previous, ...secrets });
134
143
 
135
144
  if (existing) {
136
145
  await runBw(bwSession, [
@@ -1,6 +1,6 @@
1
1
  import type { IO } from "../core/types";
2
2
  import { getAllVariables, upsertAllVariables } from "../utils/gitlab";
3
- import type { SecretsVault } from "./types";
3
+ import type { SecretsVault, WriteSecretsOptions } from "./types";
4
4
 
5
5
  /**
6
6
  * the legacy (and default) vault: the gitlab project variables double
@@ -19,7 +19,14 @@ export class GitlabVault implements SecretsVault {
19
19
  env: string,
20
20
  componentName: string,
21
21
  secrets: Record<string, unknown>,
22
+ options: WriteSecretsOptions = {},
22
23
  ): Promise<void> {
23
- await upsertAllVariables(io, secrets, env, componentName);
24
+ await upsertAllVariables(
25
+ io,
26
+ secrets,
27
+ env,
28
+ componentName,
29
+ options.backup ?? true,
30
+ );
24
31
  }
25
32
  }
@@ -13,7 +13,7 @@ import {
13
13
  writeSecretsCache,
14
14
  } from "./cache";
15
15
  import { GitlabVault } from "./GitlabVault";
16
- import type { SecretsMode, SecretsVault } from "./types";
16
+ import type { SecretsMode, SecretsVault, WriteSecretsOptions } from "./types";
17
17
 
18
18
  /**
19
19
  * only secrets of this env are cached locally — the other envs'
@@ -119,11 +119,12 @@ export class VaultManager {
119
119
  componentName: string,
120
120
  secrets: Record<string, unknown>,
121
121
  io: IO | null = this.io,
122
+ options: WriteSecretsOptions = {},
122
123
  ): Promise<void> {
123
124
  if (!io) {
124
125
  throw new Error("writing to the vault requires an interactive context");
125
126
  }
126
- await this.vault.writeSecrets(io, env, componentName, secrets);
127
+ await this.vault.writeSecrets(io, env, componentName, secrets, options);
127
128
  if (env === CACHED_ENV) {
128
129
  await updateSecretsCache(
129
130
  this.vault.id,
@@ -0,0 +1,88 @@
1
+ import type { Config } from "@catladder/pipeline";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { parse } from "yaml";
4
+ import type { IO } from "../../core/types";
5
+ import { BitwardenVault } from "../BitwardenVault";
6
+
7
+ const { getBwSession, runBw, runBwJson } = vi.hoisted(() => ({
8
+ getBwSession: vi.fn(),
9
+ runBw: vi.fn(),
10
+ runBwJson: vi.fn(),
11
+ }));
12
+ vi.mock("../bw", () => ({ getBwSession, runBw, runBwJson }));
13
+
14
+ const io = { log: vi.fn() } as unknown as IO;
15
+ const config = { customerName: "pan", appName: "app" } as Config;
16
+ const ITEM_NAME = "pan/app/dev/www/secrets.yml";
17
+
18
+ /** the item the vault would edit, or none at all */
19
+ const bwContains = (item?: Record<string, unknown>) =>
20
+ runBwJson.mockImplementation(async (_session: string, args: string[]) =>
21
+ args[1] === "collections"
22
+ ? [{ id: "collection-id", name: "catladder", organizationId: "org-id" }]
23
+ : item
24
+ ? [item]
25
+ : [],
26
+ );
27
+
28
+ /** the notes of the `bw edit|create item` payload, parsed back */
29
+ const writtenNotes = () => {
30
+ const [, args] = runBw.mock.calls[0];
31
+ const payload = JSON.parse(
32
+ Buffer.from(args[args.length - 1], "base64").toString("utf-8"),
33
+ );
34
+ return parse(payload.notes);
35
+ };
36
+
37
+ beforeEach(() => {
38
+ vi.clearAllMocks();
39
+ getBwSession.mockResolvedValue("session");
40
+ });
41
+
42
+ describe("BitwardenVault.writeSecrets", () => {
43
+ it("merges into the existing note instead of replacing it", async () => {
44
+ bwContains({
45
+ id: "item-id",
46
+ name: ITEM_NAME,
47
+ notes: "API_KEY: sesame\nSMTP_PASSWORD: hunter2\n",
48
+ });
49
+
50
+ await new BitwardenVault(config, {}, false).writeSecrets(io, "dev", "www", {
51
+ // the shape `project setup` writes: one provisioned credential,
52
+ // nothing else — the other secrets must survive it
53
+ GCLOUD_DEPLOY_credentialsKey: '{"type":"service_account"}',
54
+ });
55
+
56
+ expect(runBw.mock.calls[0][1].slice(0, 3)).toEqual([
57
+ "edit",
58
+ "item",
59
+ "item-id",
60
+ ]);
61
+ expect(writtenNotes()).toEqual({
62
+ API_KEY: "sesame",
63
+ SMTP_PASSWORD: "hunter2",
64
+ GCLOUD_DEPLOY_credentialsKey: '{"type":"service_account"}',
65
+ });
66
+ });
67
+
68
+ it("overwrites the values it is given", async () => {
69
+ bwContains({ id: "item-id", name: ITEM_NAME, notes: "API_KEY: old\n" });
70
+
71
+ await new BitwardenVault(config, {}, false).writeSecrets(io, "dev", "www", {
72
+ API_KEY: "rotated",
73
+ });
74
+
75
+ expect(writtenNotes()).toEqual({ API_KEY: "rotated" });
76
+ });
77
+
78
+ it("creates the item when the env/component has no note yet", async () => {
79
+ bwContains();
80
+
81
+ await new BitwardenVault(config, {}, false).writeSecrets(io, "dev", "www", {
82
+ API_KEY: "sesame",
83
+ });
84
+
85
+ expect(runBw.mock.calls[0][1].slice(0, 2)).toEqual(["create", "item"]);
86
+ expect(writtenNotes()).toEqual({ API_KEY: "sesame" });
87
+ });
88
+ });
@@ -20,16 +20,32 @@ export type SecretsVault = {
20
20
 
21
21
  /**
22
22
  * upserts the secrets of one env and component
23
- * (keys as declared in the config, not the raw variable names)
23
+ * (keys as declared in the config, not the raw variable names).
24
+ * Upsert, not replace: values that are not part of `secrets` stay
25
+ * untouched.
24
26
  */
25
27
  writeSecrets(
26
28
  io: IO,
27
29
  env: string,
28
30
  componentName: string,
29
31
  secrets: Record<string, unknown>,
32
+ options?: WriteSecretsOptions,
30
33
  ): Promise<void>;
31
34
  };
32
35
 
36
+ export type WriteSecretsOptions = {
37
+ /**
38
+ * keep the previous value around where the store supports it (the
39
+ * gitlab store writes `<KEY>_backup_<timestamp>` variables) —
40
+ * a hand-written secret's only undo.
41
+ *
42
+ * Machine-provisioned credentials (service account keys, kubernetes
43
+ * tokens) are regenerated on demand and their old value is worthless,
44
+ * so `project setup` writes them with `backup: false`.
45
+ */
46
+ backup?: boolean;
47
+ };
48
+
33
49
  /**
34
50
  * how secret resolution may interact with the vault:
35
51
  * - auto (default): contact the vault when the local cache is