@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/src/core/types.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { VaultManager } from "../vault/VaultManager";
1
2
  import type { ComponentContext } from "@catladder/pipeline";
2
3
 
3
4
  // ─── Input Definitions ───────────────────────────────────────────────
@@ -76,6 +77,19 @@ export type InputsSchema = Record<string, InputDef>;
76
77
  * that are NOT part of a command's declared input schema.
77
78
  */
78
79
  export interface IO {
80
+ /**
81
+ * whether this context can interact with the user (prompts, editors).
82
+ * Non-interactive contexts throw {@link NonInteractiveError} when an
83
+ * interaction would be required.
84
+ */
85
+ readonly interactive: boolean;
86
+
87
+ /**
88
+ * the secrets vault of this context, created lazily from the project
89
+ * config
90
+ */
91
+ getVaultManager(): Promise<VaultManager>;
92
+
79
93
  log(message: string): void;
80
94
  /**
81
95
  * Ask for confirmation. Returns true/false.
@@ -131,6 +145,15 @@ export interface CommandDef<TInputs extends InputsSchema = InputsSchema> {
131
145
 
132
146
  // ─── Errors ──────────────────────────────────────────────────────────
133
147
 
148
+ export class NonInteractiveError extends Error {
149
+ constructor(interaction: string) {
150
+ super(
151
+ `${interaction} requires an interactive terminal, but this context is not interactive`,
152
+ );
153
+ this.name = "NonInteractiveError";
154
+ }
155
+ }
156
+
134
157
  export class MissingInputError extends Error {
135
158
  constructor(
136
159
  public readonly inputName: string,
@@ -12,17 +12,19 @@ export const getProjectRootPath = async (): Promise<string> => {
12
12
  /**
13
13
  * Get the git remote hostname and project path from the git config.
14
14
  */
15
- export const getGitRemoteHostAndPath = async (): Promise<{
15
+ export const getGitRemoteHostAndPath = async (
16
+ remoteName = "origin",
17
+ ): Promise<{
16
18
  gitRemoteHost: string;
17
19
  gitRemotePath: string;
18
20
  }> => {
19
- const remoteUrl = await gitConfigGet("remote.origin.url");
21
+ const remoteUrl = await gitConfigGet(`remote.${remoteName}.url`);
20
22
  const remoteReg = /(https:\/\/|git@)([^:/]+)[:/]([^.]*)(\.git)?/;
21
23
  const match = remoteUrl.match(remoteReg) ?? [];
22
24
  const [, , gitRemoteHost, gitRemotePath] = match;
23
25
  if (!gitRemoteHost?.length || !gitRemotePath?.length) {
24
26
  throw new Error(
25
- `Failed to parse git remote hostname and path from git configs remote.origin.url! ${remoteUrl}`,
27
+ `Failed to parse git remote hostname and path from git configs remote.${remoteName}.url! ${remoteUrl}`,
26
28
  );
27
29
  }
28
30
  return { gitRemoteHost, gitRemotePath };
@@ -0,0 +1,59 @@
1
+ import { execFile as execFileCb, spawn } from "child_process";
2
+ import { promisify } from "util";
3
+
4
+ const execFile = promisify(execFileCb);
5
+
6
+ /**
7
+ * whether the github cli (gh) is installed and authenticated
8
+ */
9
+ export const isGhAuthenticated = async (): Promise<boolean> => {
10
+ try {
11
+ await execFile("gh", ["auth", "status"]);
12
+ return true;
13
+ } catch {
14
+ return false;
15
+ }
16
+ };
17
+
18
+ /**
19
+ * the github repository (owner/name) a git remote points to.
20
+ * Which remote belongs to github is configured per pipeline type
21
+ * (`pipelines.github.gitRemote`, defaults to origin).
22
+ */
23
+ export const getGithubRepoFromRemote = async (
24
+ remoteName: string,
25
+ ): Promise<string | undefined> => {
26
+ try {
27
+ 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];
32
+ } catch {
33
+ return undefined;
34
+ }
35
+ };
36
+
37
+ /**
38
+ * sets a github actions repository secret via the gh cli.
39
+ * The value is passed via stdin so it never shows up in a process list.
40
+ */
41
+ export const setGithubSecret = async (
42
+ repo: string,
43
+ name: string,
44
+ value: string,
45
+ ): Promise<void> =>
46
+ new Promise<void>((resolve, reject) => {
47
+ const child = spawn("gh", ["secret", "set", name, "--repo", repo], {
48
+ stdio: ["pipe", "ignore", "pipe"],
49
+ });
50
+ let stderr = "";
51
+ child.stderr?.on("data", (data) => (stderr += data));
52
+ child.on("error", reject);
53
+ child.on("close", (code) =>
54
+ code === 0
55
+ ? resolve()
56
+ : reject(new Error(`gh secret set ${name} failed: ${stderr.trim()}`)),
57
+ );
58
+ child.stdin?.end(value);
59
+ });
@@ -0,0 +1,161 @@
1
+ import { getSecretVarName } from "@catladder/pipeline";
2
+ import type { Config } from "@catladder/pipeline";
3
+ import { parse, stringify } from "yaml";
4
+ import type { IO } from "../core/types";
5
+ import { getBwSession, runBw, runBwJson } from "./bw";
6
+ import type { SecretsVault } from "./types";
7
+
8
+ const DEFAULT_COLLECTION = "catladder";
9
+
10
+ const SECURE_NOTE_TYPE = 2;
11
+
12
+ type BwCollection = { id: string; name: string; organizationId?: string };
13
+ type BwItem = {
14
+ id: string;
15
+ name: string;
16
+ notes?: string | null;
17
+ organizationId?: string | null;
18
+ collectionIds?: string[];
19
+ };
20
+
21
+ /**
22
+ * one yaml note per env and component:
23
+ * `<customerName>/<appName>/<env>/<componentName>/secrets.yml`
24
+ */
25
+ const itemName = (config: Config, env: string, componentName: string): string =>
26
+ `${config.customerName}/${config.appName}/${env}/${componentName}/secrets.yml`;
27
+
28
+ const itemPrefix = (config: Config) =>
29
+ `${config.customerName}/${config.appName}/`;
30
+
31
+ /**
32
+ * parses env and componentName back out of an item name
33
+ */
34
+ const parseItemName = (config: Config, name: string) => {
35
+ const match = name
36
+ .slice(itemPrefix(config).length)
37
+ .match(/^([^/]+)\/([^/]+)\/secrets\.yml$/);
38
+ return match ? { env: match[1], componentName: match[2] } : null;
39
+ };
40
+
41
+ const findCollection = async (
42
+ session: string,
43
+ collectionName: string,
44
+ ): Promise<BwCollection> => {
45
+ const collections = await runBwJson<BwCollection[]>(session, [
46
+ "list",
47
+ "collections",
48
+ "--search",
49
+ collectionName,
50
+ ]);
51
+ const collection = collections.find((c) => c.name === collectionName);
52
+ if (!collection) {
53
+ throw new Error(
54
+ `bitwarden collection '${collectionName}' not found — create it and make sure you have access`,
55
+ );
56
+ }
57
+ return collection;
58
+ };
59
+
60
+ const encodePayload = (payload: unknown) =>
61
+ Buffer.from(JSON.stringify(payload), "utf-8").toString("base64");
62
+
63
+ export class BitwardenVault implements SecretsVault {
64
+ readonly id: string;
65
+
66
+ private readonly collectionName: string;
67
+
68
+ constructor(
69
+ private readonly config: Config,
70
+ options: { collection?: string },
71
+ /**
72
+ * whether a locked bitwarden may be unlocked interactively
73
+ * (false with catenv --vault-mode no-prompt)
74
+ */
75
+ private readonly allowPrompt: boolean,
76
+ ) {
77
+ this.collectionName = options.collection ?? DEFAULT_COLLECTION;
78
+ this.id = `bitwarden:${this.collectionName}`;
79
+ }
80
+
81
+ private session(io: IO | null) {
82
+ return getBwSession(io, { allowPrompt: this.allowPrompt });
83
+ }
84
+
85
+ async readAllSecrets(io: IO | null): Promise<Record<string, string>> {
86
+ const { config, collectionName } = this;
87
+ const bwSession = await this.session(io);
88
+ const collection = await findCollection(bwSession, collectionName);
89
+ const items = await runBwJson<BwItem[]>(bwSession, [
90
+ "list",
91
+ "items",
92
+ "--collectionid",
93
+ collection.id,
94
+ ]);
95
+
96
+ const secrets: Record<string, string> = {};
97
+ for (const item of items) {
98
+ const parsed = parseItemName(config, item.name);
99
+ if (!parsed || !item.notes) {
100
+ continue;
101
+ }
102
+ const values = parse(item.notes) as Record<string, unknown> | null;
103
+ Object.entries(values ?? {}).forEach(([key, value]) => {
104
+ secrets[getSecretVarName(parsed.env, parsed.componentName, key)] =
105
+ typeof value === "string" ? value : JSON.stringify(value);
106
+ });
107
+ }
108
+ return secrets;
109
+ }
110
+
111
+ async writeSecrets(
112
+ io: IO,
113
+ env: string,
114
+ componentName: string,
115
+ secrets: Record<string, unknown>,
116
+ ): Promise<void> {
117
+ const { config, collectionName } = this;
118
+ const bwSession = await this.session(io);
119
+ const collection = await findCollection(bwSession, collectionName);
120
+ const name = itemName(config, env, componentName);
121
+
122
+ const existing = (
123
+ await runBwJson<BwItem[]>(bwSession, [
124
+ "list",
125
+ "items",
126
+ "--collectionid",
127
+ collection.id,
128
+ "--search",
129
+ name,
130
+ ])
131
+ ).find((item) => item.name === name);
132
+
133
+ const notes = stringify(secrets);
134
+
135
+ if (existing) {
136
+ await runBw(bwSession, [
137
+ "edit",
138
+ "item",
139
+ existing.id,
140
+ encodePayload({ ...existing, notes }),
141
+ ]);
142
+ } else {
143
+ await runBw(bwSession, [
144
+ "create",
145
+ "item",
146
+ encodePayload({
147
+ type: SECURE_NOTE_TYPE,
148
+ secureNote: { type: 0 },
149
+ name,
150
+ notes,
151
+ ...(collection.organizationId
152
+ ? {
153
+ organizationId: collection.organizationId,
154
+ collectionIds: [collection.id],
155
+ }
156
+ : {}),
157
+ }),
158
+ ]);
159
+ }
160
+ }
161
+ }
@@ -0,0 +1,25 @@
1
+ import type { IO } from "../core/types";
2
+ import { getAllVariables, upsertAllVariables } from "../utils/gitlab";
3
+ import type { SecretsVault } from "./types";
4
+
5
+ /**
6
+ * the legacy (and default) vault: the gitlab project variables double
7
+ * as the secret store
8
+ */
9
+ export class GitlabVault implements SecretsVault {
10
+ readonly id = "gitlab";
11
+
12
+ async readAllSecrets(io: IO | null): Promise<Record<string, string>> {
13
+ const variables = await getAllVariables(io);
14
+ return Object.fromEntries(variables.map((v) => [v.key, v.value]));
15
+ }
16
+
17
+ async writeSecrets(
18
+ io: IO,
19
+ env: string,
20
+ componentName: string,
21
+ secrets: Record<string, unknown>,
22
+ ): Promise<void> {
23
+ await upsertAllVariables(io, secrets, env, componentName);
24
+ }
25
+ }
@@ -0,0 +1,211 @@
1
+ import type { Config } from "@catladder/pipeline";
2
+ import {
3
+ getEnvironment,
4
+ getSecretVarName,
5
+ getVaultConfig,
6
+ } from "@catladder/pipeline";
7
+ import type { IO } from "../core/types";
8
+ import { BitwardenVault } from "./BitwardenVault";
9
+ import type { CachedSecrets } from "./cache";
10
+ import {
11
+ readSecretsCache,
12
+ updateSecretsCache,
13
+ writeSecretsCache,
14
+ } from "./cache";
15
+ import { GitlabVault } from "./GitlabVault";
16
+ import type { SecretsMode, SecretsVault } from "./types";
17
+
18
+ /**
19
+ * only secrets of this env are cached locally — the other envs'
20
+ * secrets have no business sitting on developer machines
21
+ */
22
+ const CACHED_ENV = "local";
23
+
24
+ export type VaultManagerOptions = {
25
+ mode?: SecretsMode;
26
+ /**
27
+ * default IO for vault interactions (logging, unlock prompts).
28
+ * Method-level io takes precedence.
29
+ */
30
+ io?: IO | null;
31
+ };
32
+
33
+ /**
34
+ * owns the configured vault, the secrets mode and the local cache.
35
+ * The vault is the readable source of truth for secrets; CI backends
36
+ * only receive mirrored copies.
37
+ */
38
+ export class VaultManager {
39
+ readonly mode: SecretsMode;
40
+
41
+ readonly vault: SecretsVault;
42
+
43
+ private readonly io: IO | null;
44
+
45
+ /** refresh mode bypasses the cache only once per manager */
46
+ private refreshed = false;
47
+
48
+ constructor(
49
+ private readonly config: Config,
50
+ options: VaultManagerOptions = {},
51
+ ) {
52
+ this.mode = options.mode ?? "auto";
53
+ this.io = options.io ?? null;
54
+ this.vault = this.createVault();
55
+ }
56
+
57
+ private createVault(): SecretsVault {
58
+ const vaultConfig = getVaultConfig(this.config);
59
+ switch (vaultConfig.type) {
60
+ case "gitlab":
61
+ return new GitlabVault();
62
+ case "bitwarden":
63
+ return new BitwardenVault(
64
+ this.config,
65
+ vaultConfig,
66
+ this.mode === "auto" || this.mode === "refresh",
67
+ );
68
+ }
69
+ }
70
+
71
+ /**
72
+ * the vault secrets, served from the local cache when it contains
73
+ * all required names; otherwise (depending on the mode) refreshed
74
+ * from the vault. Only the "local" env is (re-)cached.
75
+ */
76
+ async readSecrets(
77
+ requiredNames: string[],
78
+ io: IO | null = this.io,
79
+ ): Promise<Record<string, string>> {
80
+ const bypassCache = this.mode === "refresh" && !this.refreshed;
81
+ const cache = bypassCache ? null : await readSecretsCache();
82
+ // known entries include recorded misses (null values), so declared
83
+ // but intentionally unset secrets don't force a vault refresh
84
+ const known =
85
+ cache?.vaultId === this.vault.id
86
+ ? flattenSecrets(cache.secrets)
87
+ : undefined;
88
+
89
+ const missing = requiredNames.filter((name) => !(name in (known ?? {})));
90
+ // nothing missing (or nothing required at all): don't touch the vault
91
+ if (missing.length === 0 && (known || requiredNames.length === 0)) {
92
+ return withoutMisses(known ?? {});
93
+ }
94
+
95
+ if (this.mode === "offline") {
96
+ if (missing.length > 0) {
97
+ io?.log(
98
+ `⚠️ vault is offline (--vault-mode offline) and ${missing.length} secrets are not in the local cache: ${missing.join(", ")}`,
99
+ );
100
+ }
101
+ return withoutMisses(known ?? {});
102
+ }
103
+
104
+ const secrets = await this.vault.readAllSecrets(io);
105
+ this.refreshed = true;
106
+ await writeSecretsCache(this.vault.id, await this.cacheableSubset(secrets));
107
+ return secrets;
108
+ }
109
+
110
+ /**
111
+ * writes secrets of one env/component to the vault and updates the
112
+ * local cache (write-through, "local" env only)
113
+ */
114
+ async writeSecrets(
115
+ env: string,
116
+ componentName: string,
117
+ secrets: Record<string, unknown>,
118
+ io: IO | null = this.io,
119
+ ): Promise<void> {
120
+ if (!io) {
121
+ throw new Error("writing to the vault requires an interactive context");
122
+ }
123
+ await this.vault.writeSecrets(io, env, componentName, secrets);
124
+ if (env === CACHED_ENV) {
125
+ await updateSecretsCache(
126
+ this.vault.id,
127
+ env,
128
+ componentName,
129
+ Object.fromEntries(
130
+ Object.entries(secrets).map(([key, value]) => [
131
+ key,
132
+ typeof value === "string" ? value : JSON.stringify(value),
133
+ ]),
134
+ ),
135
+ );
136
+ }
137
+ }
138
+
139
+ /**
140
+ * the subset of the vault secrets that may be cached: the declared
141
+ * secrets of the "local" env, per component, as a hierarchy
142
+ */
143
+ private async cacheableSubset(
144
+ flatSecrets: Record<string, string>,
145
+ ): Promise<CachedSecrets> {
146
+ const local: CachedSecrets[string] = {};
147
+ for (const componentName of Object.keys(this.config.components)) {
148
+ const keys = await this.declaredSecretKeys(componentName);
149
+ const values = Object.fromEntries(
150
+ keys.map((key): [string, string | null] => [
151
+ key,
152
+ // null records "absent in the vault" (a known miss)
153
+ flatSecrets[getSecretVarName(CACHED_ENV, componentName, key)] ?? null,
154
+ ]),
155
+ );
156
+ if (Object.keys(values).length > 0) {
157
+ local[componentName] = values;
158
+ }
159
+ }
160
+ return Object.keys(local).length > 0 ? { [CACHED_ENV]: local } : {};
161
+ }
162
+
163
+ private async declaredSecretKeys(componentName: string): Promise<string[]> {
164
+ try {
165
+ const { secretEnvVarKeys, jobOnlyVars } = await getEnvironment({
166
+ config: this.config,
167
+ componentName,
168
+ env: CACHED_ENV,
169
+ });
170
+ return [
171
+ ...secretEnvVarKeys,
172
+ ...jobOnlyVars.build.secretEnvVarKeys,
173
+ ...jobOnlyVars.deploy.secretEnvVarKeys,
174
+ ].map(({ key }) => key);
175
+ } catch {
176
+ // components without a local env simply have nothing to cache
177
+ return [];
178
+ }
179
+ }
180
+ }
181
+
182
+ /**
183
+ * the cache hierarchy as a flat map of raw secret variable names
184
+ * (null values = recorded misses)
185
+ */
186
+ const flattenSecrets = (
187
+ secrets: CachedSecrets,
188
+ ): Record<string, string | null> =>
189
+ Object.fromEntries(
190
+ Object.entries(secrets).flatMap(([env, components]) =>
191
+ Object.entries(components).flatMap(([componentName, values]) =>
192
+ Object.entries(values).map(([key, value]) => [
193
+ getSecretVarName(env, componentName, key),
194
+ value,
195
+ ]),
196
+ ),
197
+ ),
198
+ );
199
+
200
+ /**
201
+ * only actual values — recorded misses stay unresolved, like any other
202
+ * unset variable
203
+ */
204
+ const withoutMisses = (
205
+ known: Record<string, string | null>,
206
+ ): Record<string, string> =>
207
+ Object.fromEntries(
208
+ Object.entries(known).filter(
209
+ (entry): entry is [string, string] => entry[1] !== null,
210
+ ),
211
+ );
@@ -0,0 +1,110 @@
1
+ import { execFile as execFileCb, spawn } from "child_process";
2
+ import { promisify } from "util";
3
+ import type { IO } from "../core/types";
4
+ import {
5
+ getPreference,
6
+ hasPreference,
7
+ setPreference,
8
+ } from "../utils/preferences";
9
+
10
+ const execFile = promisify(execFileCb);
11
+
12
+ const SESSION_PREFERENCE = "bwsession";
13
+
14
+ class BitwardenLockedError extends Error {
15
+ constructor() {
16
+ super(
17
+ "bitwarden is locked and prompting is disabled — run `bw unlock` or catenv without --vault-mode no-prompt",
18
+ );
19
+ }
20
+ }
21
+
22
+ /**
23
+ * runs an interactive bw command that prints a session key on stdout
24
+ * (login/unlock) while the user types the password on the terminal
25
+ */
26
+ const interactiveSessionCommand = (args: string[]): Promise<string> =>
27
+ new Promise((resolve, reject) => {
28
+ const child = spawn("bw", [...args, "--raw"], {
29
+ stdio: ["inherit", "pipe", "inherit"],
30
+ });
31
+ let session = "";
32
+ child.stdout?.on("data", (data) => (session += data.toString("utf-8")));
33
+ child.on("error", reject);
34
+ child.on("close", (code) =>
35
+ code === 0 && session.trim()
36
+ ? resolve(session.trim())
37
+ : reject(new Error(`bw ${args[0]} failed`)),
38
+ );
39
+ });
40
+
41
+ const getBwStatus = async (
42
+ session: string | null,
43
+ ): Promise<"unlocked" | "locked" | "unauthenticated"> => {
44
+ try {
45
+ const { stdout } = await execFile("bw", ["status"], {
46
+ env: { ...process.env, ...(session ? { BW_SESSION: session } : {}) },
47
+ });
48
+ return JSON.parse(stdout).status;
49
+ } catch {
50
+ return "unauthenticated";
51
+ }
52
+ };
53
+
54
+ /**
55
+ * a working bw session: reuses the cached one, otherwise unlocks (or
56
+ * logs in) interactively — unless prompting is disabled
57
+ */
58
+ export const getBwSession = async (
59
+ io: IO | null,
60
+ { allowPrompt }: { allowPrompt: boolean },
61
+ ): Promise<string> => {
62
+ const cached = (await hasPreference(SESSION_PREFERENCE))
63
+ ? String(await getPreference(SESSION_PREFERENCE))
64
+ : null;
65
+
66
+ const status = await getBwStatus(cached);
67
+ if (status === "unlocked" && cached) {
68
+ return cached;
69
+ }
70
+ if (!allowPrompt) {
71
+ throw new BitwardenLockedError();
72
+ }
73
+
74
+ io?.log("");
75
+ io?.log(
76
+ status === "unauthenticated"
77
+ ? "# Please login to Bitwarden:"
78
+ : "# Bitwarden is locked, please unlock:",
79
+ );
80
+ io?.log("");
81
+ const session = await interactiveSessionCommand([
82
+ status === "unauthenticated" ? "login" : "unlock",
83
+ ]);
84
+ await setPreference(SESSION_PREFERENCE, session);
85
+ await runBw(session, ["sync"]);
86
+ return session;
87
+ };
88
+
89
+ /**
90
+ * runs a bw command with the given session and returns stdout
91
+ */
92
+ export const runBw = async (
93
+ session: string,
94
+ args: string[],
95
+ ): Promise<string> => {
96
+ const { stdout } = await execFile(
97
+ "bw",
98
+ [...args, "--raw", "--nointeraction"],
99
+ {
100
+ env: { ...process.env, BW_SESSION: session },
101
+ maxBuffer: 50 * 1024 * 1024,
102
+ },
103
+ );
104
+ return stdout;
105
+ };
106
+
107
+ export const runBwJson = async <T>(
108
+ session: string,
109
+ args: string[],
110
+ ): Promise<T> => JSON.parse(await runBw(session, args));