@catladder/cli 0.0.0-main-2355fd7c → 0.0.0-main-018e2dee

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 (83) hide show
  1. package/dist/apps/cli/src/apps/catenv/catenv.js +3 -0
  2. package/dist/apps/cli/src/apps/catenv/catenv.js.map +1 -1
  3. package/dist/apps/cli/src/apps/cli/commands/project/doctor/GcloudProjectInspector.d.ts +4 -0
  4. package/dist/apps/cli/src/apps/cli/commands/project/doctor/GcloudProjectInspector.js +12 -0
  5. package/dist/apps/cli/src/apps/cli/commands/project/doctor/GcloudProjectInspector.js.map +1 -1
  6. package/dist/apps/cli/src/apps/cli/commands/project/doctor/checkCloudRun.d.ts +2 -2
  7. package/dist/apps/cli/src/apps/cli/commands/project/doctor/checkCloudRun.js +3 -1
  8. package/dist/apps/cli/src/apps/cli/commands/project/doctor/checkCloudRun.js.map +1 -1
  9. package/dist/apps/cli/src/apps/cli/commands/project/doctor/checkLocalAccess.d.ts +10 -0
  10. package/dist/apps/cli/src/apps/cli/commands/project/doctor/checkLocalAccess.js +98 -0
  11. package/dist/apps/cli/src/apps/cli/commands/project/doctor/checkLocalAccess.js.map +1 -0
  12. package/dist/apps/cli/src/apps/cli/commands/project/doctor/index.js +1 -1
  13. package/dist/apps/cli/src/apps/cli/commands/project/doctor/index.js.map +1 -1
  14. package/dist/apps/cli/src/apps/cli/commands/project/setup/setupContext.js +2 -0
  15. package/dist/apps/cli/src/apps/cli/commands/project/setup/setupContext.js.map +1 -1
  16. package/dist/apps/cli/src/apps/cli/commands/project/setup/setupLocalAccess.d.ts +9 -0
  17. package/dist/apps/cli/src/apps/cli/commands/project/setup/setupLocalAccess.js +31 -0
  18. package/dist/apps/cli/src/apps/cli/commands/project/setup/setupLocalAccess.js.map +1 -0
  19. package/dist/apps/cli/src/config/getProjectConfig.js +9 -1
  20. package/dist/apps/cli/src/config/getProjectConfig.js.map +1 -1
  21. package/dist/apps/cli/src/gcloud/localAccess.d.ts +53 -0
  22. package/dist/apps/cli/src/gcloud/localAccess.js +176 -0
  23. package/dist/apps/cli/src/gcloud/localAccess.js.map +1 -0
  24. package/dist/apps/cli/src/gcloud/serviceAccounts.js +9 -2
  25. package/dist/apps/cli/src/gcloud/serviceAccounts.js.map +1 -1
  26. package/dist/apps/cli/src/localAccess/declared.d.ts +14 -0
  27. package/dist/apps/cli/src/localAccess/declared.js +34 -0
  28. package/dist/apps/cli/src/localAccess/declared.js.map +1 -0
  29. package/dist/apps/cli/src/localAccess/keyFiles.d.ts +28 -0
  30. package/dist/apps/cli/src/localAccess/keyFiles.js +91 -0
  31. package/dist/apps/cli/src/localAccess/keyFiles.js.map +1 -0
  32. package/dist/apps/cli/src/utils/writePrivateFile.d.ts +7 -0
  33. package/dist/apps/cli/src/utils/writePrivateFile.js +25 -0
  34. package/dist/apps/cli/src/utils/writePrivateFile.js.map +1 -0
  35. package/dist/apps/cli/src/vault/BitwardenVault.js +13 -22
  36. package/dist/apps/cli/src/vault/BitwardenVault.js.map +1 -1
  37. package/dist/apps/cli/src/vault/VaultManager.d.ts +6 -0
  38. package/dist/apps/cli/src/vault/VaultManager.js +18 -0
  39. package/dist/apps/cli/src/vault/VaultManager.js.map +1 -1
  40. package/dist/apps/cli/src/vault/bw.d.ts +6 -2
  41. package/dist/apps/cli/src/vault/bw.js +32 -6
  42. package/dist/apps/cli/src/vault/bw.js.map +1 -1
  43. package/dist/apps/cli/src/vault/cache.js +5 -2
  44. package/dist/apps/cli/src/vault/cache.js.map +1 -1
  45. package/dist/bundles/catenv/index.js +436 -31
  46. package/dist/bundles/cli/index.js +786 -35
  47. package/dist/bundles/skills/catladder-secrets/SKILL.md +35 -3
  48. package/dist/packages/pipeline/src/index.d.ts +1 -0
  49. package/dist/packages/pipeline/src/index.js +1 -0
  50. package/dist/packages/pipeline/src/index.js.map +1 -1
  51. package/dist/packages/pipeline/src/localAccess/index.d.ts +53 -0
  52. package/dist/packages/pipeline/src/localAccess/index.js +177 -0
  53. package/dist/packages/pipeline/src/localAccess/index.js.map +1 -0
  54. package/dist/packages/pipeline/src/types/config.d.ts +75 -0
  55. package/dist/packages/pipeline/src/types/config.js.map +1 -1
  56. package/dist/skills/catladder-secrets/SKILL.md +35 -3
  57. package/dist/tsconfig.tsbuildinfo +1 -1
  58. package/package.json +1 -1
  59. package/src/apps/catenv/catenv.ts +4 -0
  60. package/src/apps/cli/commands/project/doctor/GcloudProjectInspector.ts +25 -1
  61. package/src/apps/cli/commands/project/doctor/__tests__/checkLocalAccess.test.ts +127 -0
  62. package/src/apps/cli/commands/project/doctor/checkCloudRun.ts +5 -1
  63. package/src/apps/cli/commands/project/doctor/checkLocalAccess.ts +146 -0
  64. package/src/apps/cli/commands/project/doctor/index.ts +1 -1
  65. package/src/apps/cli/commands/project/setup/setupContext.ts +2 -0
  66. package/src/apps/cli/commands/project/setup/setupLocalAccess.ts +44 -0
  67. package/src/config/__tests__/getEnvVarsResolved.test.ts +45 -0
  68. package/src/config/getProjectConfig.ts +11 -1
  69. package/src/gcloud/__tests__/localAccess.test.ts +82 -0
  70. package/src/gcloud/__tests__/syncLocalAccess.test.ts +196 -0
  71. package/src/gcloud/localAccess.ts +257 -0
  72. package/src/gcloud/serviceAccounts.ts +8 -2
  73. package/src/localAccess/__tests__/keyFiles.test.ts +166 -0
  74. package/src/localAccess/declared.ts +38 -0
  75. package/src/localAccess/keyFiles.ts +126 -0
  76. package/src/utils/__tests__/writePrivateFile.test.ts +40 -0
  77. package/src/utils/writePrivateFile.ts +20 -0
  78. package/src/vault/BitwardenVault.ts +8 -9
  79. package/src/vault/VaultManager.ts +28 -0
  80. package/src/vault/__tests__/BitwardenVault.test.ts +4 -4
  81. package/src/vault/__tests__/cache.test.ts +37 -0
  82. package/src/vault/bw.ts +34 -9
  83. package/src/vault/cache.ts +5 -2
@@ -0,0 +1,126 @@
1
+ import type { Config } from "@catladder/pipeline";
2
+ import {
3
+ getLocalAccessGcpSecretName,
4
+ getSecretVarName,
5
+ } from "@catladder/pipeline";
6
+ import { mkdir, readdir, rm, writeFile } from "fs/promises";
7
+ import { existsSync } from "fs";
8
+ import { join } from "path";
9
+ import type { IO } from "../core/types";
10
+ import { writePrivateFile } from "../utils/writePrivateFile";
11
+ import {
12
+ getDeclaredLocalAccessCredentialsEnvVars,
13
+ getDeclaredLocalAccessEnvs,
14
+ } from "./declared";
15
+ import { getProjectRootPath } from "../git/gitProjectInformation";
16
+
17
+ const sanitize = (s: string) => s.replace(/[^A-Za-z0-9]+/g, "_");
18
+
19
+ /** file name of a component/env key, relative to the local access folder */
20
+ export const getLocalAccessKeyFileName = (env: string, componentName: string) =>
21
+ `gcp-${sanitize(env)}-${sanitize(componentName)}.json`;
22
+
23
+ /**
24
+ * env var exposing the key file path. Deliberately NOT
25
+ * GOOGLE_APPLICATION_CREDENTIALS: the key must only be used where a
26
+ * command opts in (isolated gcloud config, or a declared
27
+ * `credentialsEnvVar`), never by ADC implicitly.
28
+ */
29
+ export const getLocalAccessKeyFileVarName = (env: string) =>
30
+ `CATLADDER_LOCAL_ACCESS_GCP_${sanitize(env).toUpperCase()}_KEY_FILE`;
31
+
32
+ // inside .catladder/, which ignores itself (see vault/cache.ts)
33
+ const getFolder = async () =>
34
+ join(await getProjectRootPath(), ".catladder", "local-access");
35
+
36
+ /**
37
+ * env vars pointing at the key files that exist for the component
38
+ * (only paths, no secrets): one per env, plus the declared
39
+ * `credentialsEnvVar` names
40
+ */
41
+ export const getLocalAccessKeyFileVars = async (
42
+ config: Config,
43
+ componentName: string,
44
+ ): Promise<Record<string, string>> => {
45
+ const folder = await getFolder();
46
+ const getPath = (env: string) =>
47
+ join(folder, getLocalAccessKeyFileName(env, componentName));
48
+ return Object.fromEntries(
49
+ [
50
+ ...getDeclaredLocalAccessEnvs(config, componentName).map(
51
+ (env): [string, string] => [
52
+ getLocalAccessKeyFileVarName(env),
53
+ getPath(env),
54
+ ],
55
+ ),
56
+ ...Object.entries(
57
+ getDeclaredLocalAccessCredentialsEnvVars(config, componentName),
58
+ ).map(([name, env]): [string, string] => [name, getPath(env)]),
59
+ ].filter(([, path]) => existsSync(path)),
60
+ );
61
+ };
62
+
63
+ /**
64
+ * writes the stored local access service account keys to gitignored
65
+ * files (mode 0600) and removes files of declarations that are gone.
66
+ * A no-op without any declaration (nothing is created). Never throws:
67
+ * a vault that cannot be reached or an invalid declaration (reported by
68
+ * `project doctor`) must not break catenv.
69
+ *
70
+ * The key is only read through the cache; if setup rotated it on another
71
+ * machine, refresh with `catenv --vault-mode refresh`.
72
+ */
73
+ export const writeLocalAccessKeyFiles = async (io: IO, config: Config) => {
74
+ const wanted = Object.keys(config.components).flatMap((componentName) =>
75
+ getDeclaredLocalAccessEnvs(config, componentName).map((env) => ({
76
+ env,
77
+ componentName,
78
+ secretName: getSecretVarName(
79
+ "local",
80
+ componentName,
81
+ getLocalAccessGcpSecretName(env),
82
+ ),
83
+ fileName: getLocalAccessKeyFileName(env, componentName),
84
+ })),
85
+ );
86
+
87
+ const folder = await getFolder();
88
+ if (wanted.length === 0 && !existsSync(folder)) return;
89
+
90
+ try {
91
+ const secrets =
92
+ wanted.length > 0
93
+ ? await (
94
+ await io.getVaultManager()
95
+ ).readSecrets(
96
+ wanted.map((w) => w.secretName),
97
+ io,
98
+ )
99
+ : {};
100
+ await mkdir(folder, { recursive: true, mode: 0o700 });
101
+ // the parent folder ignores itself
102
+ await writeFile(join(folder, "..", ".gitignore"), "*\n");
103
+
104
+ const written = new Set<string>();
105
+ for (const { secretName, fileName, env, componentName } of wanted) {
106
+ const key = secrets[secretName];
107
+ if (!key) {
108
+ io.log(
109
+ `⚠️ no local access key for ${env}:${componentName} in the secret store — run \`catladder project setup\` (or \`catenv --vault-mode refresh\` if it was set up on another machine)`,
110
+ );
111
+ continue;
112
+ }
113
+ const path = join(folder, fileName);
114
+ await writePrivateFile(path, key);
115
+ written.add(fileName);
116
+ }
117
+
118
+ for (const file of await readdir(folder)) {
119
+ if (!written.has(file)) {
120
+ await rm(join(folder, file), { force: true });
121
+ }
122
+ }
123
+ } catch (e) {
124
+ io.log(`⚠️ could not write local access keys: ${e.message}`);
125
+ }
126
+ };
@@ -0,0 +1,40 @@
1
+ import {
2
+ chmod,
3
+ mkdtemp,
4
+ readFile,
5
+ readdir,
6
+ rm,
7
+ stat,
8
+ writeFile,
9
+ } from "fs/promises";
10
+ import { tmpdir } from "os";
11
+ import { join } from "path";
12
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
13
+ import { writePrivateFile } from "../writePrivateFile";
14
+
15
+ let dir: string;
16
+ beforeEach(async () => {
17
+ dir = await mkdtemp(join(tmpdir(), "private-file-test-"));
18
+ });
19
+ afterEach(async () => {
20
+ await rm(dir, { recursive: true, force: true });
21
+ });
22
+
23
+ describe("writePrivateFile", () => {
24
+ it("creates the file with mode 0600", async () => {
25
+ const path = join(dir, "a.json");
26
+ await writePrivateFile(path, "secret");
27
+ expect(await readFile(path, "utf-8")).toBe("secret");
28
+ expect((await stat(path)).mode & 0o777).toBe(0o600);
29
+ });
30
+
31
+ it("tightens an existing 0644 file and leaves no temp file behind", async () => {
32
+ const path = join(dir, "a.json");
33
+ await writeFile(path, "old");
34
+ await chmod(path, 0o644);
35
+ await writePrivateFile(path, "new");
36
+ expect(await readFile(path, "utf-8")).toBe("new");
37
+ expect((await stat(path)).mode & 0o777).toBe(0o600);
38
+ expect(await readdir(dir)).toEqual(["a.json"]);
39
+ });
40
+ });
@@ -0,0 +1,20 @@
1
+ import { chmod, rename, rm, writeFile } from "fs/promises";
2
+
3
+ /**
4
+ * writes a file readable by the owner only. Goes through a 0600 temp
5
+ * file and a rename, so the content is never visible with looser
6
+ * permissions (writing first and chmod-ing afterwards would leave an
7
+ * existing 0644 file readable until the chmod).
8
+ */
9
+ export const writePrivateFile = async (path: string, content: string) => {
10
+ const tmp = `${path}.${process.pid}.tmp`;
11
+ try {
12
+ await writeFile(tmp, content, { mode: 0o600 });
13
+ // the mode is only applied when the file is created
14
+ await chmod(tmp, 0o600);
15
+ await rename(tmp, path);
16
+ } catch (e) {
17
+ await rm(tmp, { force: true });
18
+ throw e;
19
+ }
20
+ };
@@ -142,16 +142,15 @@ export class BitwardenVault implements SecretsVault {
142
142
  const notes = stringify({ ...previous, ...secrets });
143
143
 
144
144
  if (existing) {
145
- await runBw(bwSession, [
146
- "edit",
147
- "item",
148
- existing.id,
145
+ await runBw(
146
+ bwSession,
147
+ ["edit", "item", existing.id],
149
148
  encodePayload({ ...existing, notes }),
150
- ]);
149
+ );
151
150
  } else {
152
- await runBw(bwSession, [
153
- "create",
154
- "item",
151
+ await runBw(
152
+ bwSession,
153
+ ["create", "item"],
155
154
  encodePayload({
156
155
  type: SECURE_NOTE_TYPE,
157
156
  secureNote: { type: 0 },
@@ -164,7 +163,7 @@ export class BitwardenVault implements SecretsVault {
164
163
  }
165
164
  : {}),
166
165
  }),
167
- ]);
166
+ );
168
167
  }
169
168
  }
170
169
  }
@@ -1,10 +1,12 @@
1
1
  import type { Config } from "@catladder/pipeline";
2
2
  import {
3
3
  getEnvironment,
4
+ getLocalAccessGcpSecretName,
4
5
  getSecretVarName,
5
6
  getVaultConfig,
6
7
  } from "@catladder/pipeline";
7
8
  import type { IO } from "../core/types";
9
+ import { getDeclaredLocalAccessEnvs } from "../localAccess/declared";
8
10
  import { BitwardenVault } from "./BitwardenVault";
9
11
  import type { CachedSecrets } from "./cache";
10
12
  import {
@@ -140,6 +142,19 @@ export class VaultManager {
140
142
  }
141
143
  }
142
144
 
145
+ /**
146
+ * updates the local cache only (the vault already holds the values),
147
+ * "local" env only
148
+ */
149
+ async cacheSecrets(
150
+ env: string,
151
+ componentName: string,
152
+ secrets: Record<string, string>,
153
+ ): Promise<void> {
154
+ if (env !== CACHED_ENV) return;
155
+ await updateSecretsCache(this.vault.id, env, componentName, secrets);
156
+ }
157
+
143
158
  /**
144
159
  * the subset of the vault secrets that may be cached: the declared
145
160
  * secrets of the "local" env, per component, as a hierarchy
@@ -165,6 +180,19 @@ export class VaultManager {
165
180
  }
166
181
 
167
182
  private async declaredSecretKeys(componentName: string): Promise<string[]> {
183
+ return [
184
+ ...(await this.declaredEnvSecretKeys(componentName)),
185
+ // the local access keys `project setup` provisions are not part
186
+ // of any env's vars, but belong to the cached local secrets
187
+ ...getDeclaredLocalAccessEnvs(this.config, componentName).map(
188
+ getLocalAccessGcpSecretName,
189
+ ),
190
+ ];
191
+ }
192
+
193
+ private async declaredEnvSecretKeys(
194
+ componentName: string,
195
+ ): Promise<string[]> {
168
196
  try {
169
197
  const { secretEnvVarKeys, jobOnlyVars } = await getEnvironment({
170
198
  config: this.config,
@@ -27,10 +27,10 @@ const bwContains = (item?: Record<string, unknown>) =>
27
27
 
28
28
  /** the notes of the `bw edit|create item` payload, parsed back */
29
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
- );
30
+ // the payload goes through stdin (not argv: visible in `ps`)
31
+ const [, args, input] = runBw.mock.calls[0];
32
+ expect(args).not.toContain(input);
33
+ const payload = JSON.parse(Buffer.from(input, "base64").toString("utf-8"));
34
34
  return parse(payload.notes);
35
35
  };
36
36
 
@@ -0,0 +1,37 @@
1
+ import { chmod, mkdtemp, mkdir, rm, stat, writeFile } from "fs/promises";
2
+ import { tmpdir } from "os";
3
+ import { join } from "path";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+
6
+ let root: string;
7
+ vi.mock("../../git/gitProjectInformation", () => ({
8
+ getProjectRootPath: async () => root,
9
+ }));
10
+
11
+ import { readSecretsCache, writeSecretsCache } from "../cache";
12
+
13
+ beforeEach(async () => {
14
+ root = await mkdtemp(join(tmpdir(), "cache-test-"));
15
+ });
16
+ afterEach(async () => {
17
+ await rm(root, { recursive: true, force: true });
18
+ });
19
+
20
+ describe("secrets cache", () => {
21
+ it("is written with mode 0600", async () => {
22
+ await writeSecretsCache("v", { local: { web: { KEY: "value" } } });
23
+ const mode = (await stat(join(root, ".catladder", "secrets.json"))).mode;
24
+ expect(mode & 0o777).toBe(0o600);
25
+ expect((await readSecretsCache())?.secrets.local.web.KEY).toBe("value");
26
+ });
27
+
28
+ it("tightens an existing world-readable cache", async () => {
29
+ const folder = join(root, ".catladder");
30
+ await mkdir(folder);
31
+ const path = join(folder, "secrets.json");
32
+ await writeFile(path, "{}");
33
+ await chmod(path, 0o644);
34
+ await writeSecretsCache("v", { local: { web: { KEY: "value" } } });
35
+ expect((await stat(path)).mode & 0o777).toBe(0o600);
36
+ });
37
+ });
package/src/vault/bw.ts CHANGED
@@ -87,21 +87,46 @@ export const getBwSession = async (
87
87
  };
88
88
 
89
89
  /**
90
- * runs a bw command with the given session and returns stdout
90
+ * runs a bw command with the given session and returns stdout.
91
+ *
92
+ * `input` is written to stdin: use it for payloads (e.g. the secrets of
93
+ * `bw create item`), which would otherwise be visible in `ps` and be
94
+ * echoed by node's "Command failed" error.
91
95
  */
92
96
  export const runBw = async (
93
97
  session: string,
94
98
  args: string[],
99
+ input?: string,
95
100
  ): Promise<string> => {
96
- const { stdout } = await execFile(
97
- "bw",
98
- [...args, "--raw", "--nointeraction"],
99
- {
100
- env: { ...process.env, BW_SESSION: session },
101
+ const fullArgs = [...args, "--raw", "--nointeraction"];
102
+ const env = { ...process.env, BW_SESSION: session };
103
+ if (input === undefined) {
104
+ const { stdout } = await execFile("bw", fullArgs, {
105
+ env,
101
106
  maxBuffer: 50 * 1024 * 1024,
102
- },
103
- );
104
- return stdout;
107
+ });
108
+ return stdout;
109
+ }
110
+ return new Promise((resolve, reject) => {
111
+ const child = spawn("bw", fullArgs, {
112
+ env,
113
+ stdio: ["pipe", "pipe", "pipe"],
114
+ });
115
+ let stdout = "";
116
+ let stderr = "";
117
+ child.stdout.on("data", (data) => (stdout += data.toString("utf-8")));
118
+ child.stderr.on("data", (data) => (stderr += data.toString("utf-8")));
119
+ child.on("error", reject);
120
+ child.on("close", (code) =>
121
+ code === 0
122
+ ? resolve(stdout)
123
+ : reject(new Error(`bw ${args[0]} failed: ${stderr.trim()}`)),
124
+ );
125
+ child.stdin.on("error", () => {
126
+ // bw exiting early closes the pipe, the close handler reports why
127
+ });
128
+ child.stdin.end(input);
129
+ });
105
130
  };
106
131
 
107
132
  export const runBwJson = async <T>(
@@ -1,5 +1,6 @@
1
1
  import { mkdir, readFile, writeFile } from "fs/promises";
2
2
  import { join } from "path";
3
+ import { writePrivateFile } from "../utils/writePrivateFile";
3
4
  import { getProjectRootPath } from "../git/gitProjectInformation";
4
5
 
5
6
  /**
@@ -47,7 +48,7 @@ export const writeSecretsCache = async (
47
48
  secrets: CachedSecrets,
48
49
  ): Promise<void> => {
49
50
  const folder = await getCacheFolder();
50
- await mkdir(folder, { recursive: true });
51
+ await mkdir(folder, { recursive: true, mode: 0o700 });
51
52
  // the folder ignores itself, so the cache can never be committed
52
53
  await writeFile(join(folder, ".gitignore"), "*\n");
53
54
  const cache: SecretsCache = {
@@ -55,7 +56,9 @@ export const writeSecretsCache = async (
55
56
  updatedAt: new Date().toISOString(),
56
57
  secrets,
57
58
  };
58
- await writeFile(await getCachePath(), JSON.stringify(cache, null, 2));
59
+ const path = await getCachePath();
60
+ // may hold key material (local access keys): owner only
61
+ await writePrivateFile(path, JSON.stringify(cache, null, 2));
59
62
  };
60
63
 
61
64
  /**