@mp-lb/arcnet 0.1.0 → 0.2.0

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 (78) hide show
  1. package/README.md +33 -4
  2. package/dist/createCli.d.ts.map +1 -1
  3. package/dist/createCli.js +12 -5
  4. package/dist/createCli.js.map +1 -1
  5. package/dist/secrets/assertNewSource.d.ts +3 -0
  6. package/dist/secrets/assertNewSource.d.ts.map +1 -0
  7. package/dist/secrets/assertNewSource.js +32 -0
  8. package/dist/secrets/assertNewSource.js.map +1 -0
  9. package/dist/secrets/config.d.ts +37 -0
  10. package/dist/secrets/config.d.ts.map +1 -0
  11. package/dist/secrets/config.js +67 -0
  12. package/dist/secrets/config.js.map +1 -0
  13. package/dist/secrets/createSecretsCommands.d.ts +3 -0
  14. package/dist/secrets/createSecretsCommands.d.ts.map +1 -0
  15. package/dist/secrets/createSecretsCommands.js +93 -0
  16. package/dist/secrets/createSecretsCommands.js.map +1 -0
  17. package/dist/secrets/loadSecrets.d.ts +3 -0
  18. package/dist/secrets/loadSecrets.d.ts.map +1 -0
  19. package/dist/secrets/loadSecrets.js +35 -0
  20. package/dist/secrets/loadSecrets.js.map +1 -0
  21. package/dist/secrets/promptPassword.d.ts +2 -0
  22. package/dist/secrets/promptPassword.d.ts.map +1 -0
  23. package/dist/secrets/promptPassword.js +42 -0
  24. package/dist/secrets/promptPassword.js.map +1 -0
  25. package/dist/secrets/provisionSource.d.ts +25 -0
  26. package/dist/secrets/provisionSource.d.ts.map +1 -0
  27. package/dist/secrets/provisionSource.js +141 -0
  28. package/dist/secrets/provisionSource.js.map +1 -0
  29. package/dist/secrets/readRemoteSource.d.ts +7 -0
  30. package/dist/secrets/readRemoteSource.d.ts.map +1 -0
  31. package/dist/secrets/readRemoteSource.js +32 -0
  32. package/dist/secrets/readRemoteSource.js.map +1 -0
  33. package/dist/secrets/runWithSecrets.d.ts +6 -0
  34. package/dist/secrets/runWithSecrets.d.ts.map +1 -0
  35. package/dist/secrets/runWithSecrets.js +59 -0
  36. package/dist/secrets/runWithSecrets.js.map +1 -0
  37. package/dist/secrets/secretCommand.d.ts +2 -0
  38. package/dist/secrets/secretCommand.d.ts.map +1 -0
  39. package/dist/secrets/secretCommand.js +17 -0
  40. package/dist/secrets/secretCommand.js.map +1 -0
  41. package/dist/secrets/selectSecrets.d.ts +3 -0
  42. package/dist/secrets/selectSecrets.d.ts.map +1 -0
  43. package/dist/secrets/selectSecrets.js +18 -0
  44. package/dist/secrets/selectSecrets.js.map +1 -0
  45. package/dist/secrets/syncSecrets.d.ts +10 -0
  46. package/dist/secrets/syncSecrets.d.ts.map +1 -0
  47. package/dist/secrets/syncSecrets.js +43 -0
  48. package/dist/secrets/syncSecrets.js.map +1 -0
  49. package/dist/secrets/wireSecretsWorkflows.d.ts +2 -0
  50. package/dist/secrets/wireSecretsWorkflows.d.ts.map +1 -0
  51. package/dist/secrets/wireSecretsWorkflows.js +48 -0
  52. package/dist/secrets/wireSecretsWorkflows.js.map +1 -0
  53. package/dist/secrets/withDeploymentSecrets.d.ts +2 -0
  54. package/dist/secrets/withDeploymentSecrets.d.ts.map +1 -0
  55. package/dist/secrets/withDeploymentSecrets.js +58 -0
  56. package/dist/secrets/withDeploymentSecrets.js.map +1 -0
  57. package/dist/secrets/writeSecretsWorkflow.d.ts +3 -0
  58. package/dist/secrets/writeSecretsWorkflow.d.ts.map +1 -0
  59. package/dist/secrets/writeSecretsWorkflow.js +60 -0
  60. package/dist/secrets/writeSecretsWorkflow.js.map +1 -0
  61. package/docs/public/commands.md +74 -0
  62. package/docs/public/llms-full.txt +107 -4
  63. package/package.json +2 -1
  64. package/src/createCli.ts +18 -8
  65. package/src/secrets/assertNewSource.ts +40 -0
  66. package/src/secrets/config.ts +91 -0
  67. package/src/secrets/createSecretsCommands.ts +189 -0
  68. package/src/secrets/loadSecrets.ts +53 -0
  69. package/src/secrets/promptPassword.ts +46 -0
  70. package/src/secrets/provisionSource.ts +238 -0
  71. package/src/secrets/readRemoteSource.ts +73 -0
  72. package/src/secrets/runWithSecrets.ts +71 -0
  73. package/src/secrets/secretCommand.ts +26 -0
  74. package/src/secrets/selectSecrets.ts +33 -0
  75. package/src/secrets/syncSecrets.ts +60 -0
  76. package/src/secrets/wireSecretsWorkflows.ts +57 -0
  77. package/src/secrets/withDeploymentSecrets.ts +77 -0
  78. package/src/secrets/writeSecretsWorkflow.ts +83 -0
@@ -0,0 +1,46 @@
1
+ export const promptPassword = async (
2
+ noninteractive = false,
3
+ ): Promise<string> => {
4
+ if (process.env.DOCTRINE_ENCRYPTION_PASSWORD)
5
+ return process.env.DOCTRINE_ENCRYPTION_PASSWORD;
6
+ if (noninteractive || !process.stdin.isTTY || !process.stderr.isTTY)
7
+ throw new Error(
8
+ "Run this command in your terminal to enter the Forum encryption password, or set DOCTRINE_ENCRYPTION_PASSWORD.",
9
+ );
10
+ return new Promise((resolve, reject) => {
11
+ let value = "";
12
+ const input = process.stdin;
13
+ const wasRaw = input.isRaw;
14
+
15
+ const finish = (cancel = false) => {
16
+ input.off("data", read);
17
+ input.setRawMode(wasRaw);
18
+ input.pause();
19
+ process.stderr.write("\n");
20
+ if (cancel || !value) reject(new Error("Password entry cancelled."));
21
+ else resolve(value);
22
+ };
23
+
24
+ const read = (chunk: Buffer) => {
25
+ for (const character of chunk.toString("utf8")) {
26
+ if (character === "\u0003") {
27
+ finish(true);
28
+ return;
29
+ }
30
+
31
+ if (character === "\r" || character === "\n") {
32
+ finish();
33
+ return;
34
+ }
35
+
36
+ if (character === "\u007f") value = [...value].slice(0, -1).join("");
37
+ else value += character;
38
+ }
39
+ };
40
+
41
+ process.stderr.write("Forum encryption password: ");
42
+ input.setRawMode(true);
43
+ input.on("data", read);
44
+ input.resume();
45
+ });
46
+ };
@@ -0,0 +1,238 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import {
3
+ existsSync,
4
+ mkdtempSync,
5
+ readFileSync,
6
+ rmSync,
7
+ writeFileSync,
8
+ } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import { safeTargetPath } from "../safeTargetPath.js";
12
+ import {
13
+ readSecretsConfig,
14
+ secretsConfigSchema,
15
+ sourceSchema,
16
+ } from "./config.js";
17
+ import { promptPassword } from "./promptPassword.js";
18
+ import { secretCommand } from "./secretCommand.js";
19
+ import { readRemoteSource } from "./readRemoteSource.js";
20
+ import { syncSecrets } from "./syncSecrets.js";
21
+ import { writeSecretsWorkflow } from "./writeSecretsWorkflow.js";
22
+ import { assertNewSource } from "./assertNewSource.js";
23
+
24
+ export interface ProvisionOptions {
25
+ name: string;
26
+ store: string;
27
+ path: string;
28
+ keyEnv: string;
29
+ repository: string;
30
+ existingKey?: boolean;
31
+ input?: string;
32
+ noninteractive?: boolean;
33
+ }
34
+
35
+ export const provisionSource = async (
36
+ mode: "add" | "init",
37
+ options: ProvisionOptions,
38
+ root: string,
39
+ configFile: string,
40
+ ) => {
41
+ if (configFile !== ".arcnet/secrets.json")
42
+ throw new Error(
43
+ "Provisioning uses .arcnet/secrets.json so generated CI has a fixed entry point.",
44
+ );
45
+ if (process.env.GITHUB_ACTIONS === "true")
46
+ throw new Error(
47
+ "Provision secrets from an authorized local terminal, not CI.",
48
+ );
49
+
50
+ const source = sourceSchema.parse({
51
+ name: options.name,
52
+ store: options.store,
53
+ path: options.path.startsWith("/") ? options.path : `/${options.path}`,
54
+ keyEnv: options.keyEnv,
55
+ });
56
+
57
+ const configPath = safeTargetPath(root, configFile);
58
+
59
+ const config = existsSync(configPath)
60
+ ? readSecretsConfig(root, configFile)
61
+ : {
62
+ version: 1 as const,
63
+ repository: options.repository,
64
+ sources: [],
65
+ consumers: {},
66
+ };
67
+
68
+ if (config.repository !== options.repository)
69
+ throw new Error("Repository differs from secrets configuration.");
70
+ const prior = config.sources.find((entry) => entry.name === source.name);
71
+
72
+ if (prior) {
73
+ if (
74
+ prior.store !== source.store ||
75
+ prior.path !== source.path ||
76
+ prior.keyEnv !== source.keyEnv
77
+ )
78
+ throw new Error("Source name already has different settings.");
79
+ return {
80
+ operation: `secrets.${mode}`,
81
+ status: "unchanged",
82
+ message:
83
+ "Source is already configured; use secrets sync to refresh snapshots.",
84
+ };
85
+ }
86
+
87
+ const next = secretsConfigSchema.parse({
88
+ ...config,
89
+ sources: [...config.sources, source],
90
+ });
91
+
92
+ const known = JSON.parse(
93
+ secretCommand(
94
+ "gh",
95
+ ["secret", "list", "--repo", config.repository, "--json", "name"],
96
+ root,
97
+ ),
98
+ ) as { name: string }[];
99
+
100
+ if (
101
+ options.existingKey &&
102
+ !known.some((entry) => entry.name === source.keyEnv)
103
+ )
104
+ throw new Error("The named GitHub repository secret does not exist.");
105
+ if (
106
+ !options.existingKey &&
107
+ known.some((entry) => entry.name === source.keyEnv)
108
+ )
109
+ throw new Error(
110
+ "GitHub secret already exists; use --existing-key to reuse it without overwriting.",
111
+ );
112
+ if (mode === "init" && options.existingKey)
113
+ throw new Error(
114
+ "A new encrypted file needs a new recipient; omit --existing-key.",
115
+ );
116
+ const original = mode === "add" ? readRemoteSource(source, root) : undefined;
117
+ if (mode === "init") assertNewSource(source, root);
118
+ const temporary = mkdtempSync(join(tmpdir(), "arcnet-secrets-"));
119
+
120
+ try {
121
+ if (!options.existingKey) {
122
+ const password = await promptPassword(options.noninteractive);
123
+ const env = { ...process.env, DOCTRINE_ENCRYPTION_PASSWORD: password };
124
+ const ciphertext = join(temporary, "source.json.enc");
125
+ if (mode === "init") {
126
+ const plaintext = options.input
127
+ ? readFileSync(options.input, "utf8")
128
+ : "{}";
129
+
130
+ try {
131
+ const data: unknown = JSON.parse(plaintext);
132
+ if (!data || Array.isArray(data) || typeof data !== "object")
133
+ throw new Error();
134
+ } catch {
135
+ throw new Error(
136
+ "Initial secrets must be a JSON object (values omitted).",
137
+ );
138
+ }
139
+
140
+ const input = join(temporary, "input.json");
141
+ writeFileSync(input, plaintext, { mode: 0o600 });
142
+ writeFileSync(
143
+ ciphertext,
144
+ secretCommand(
145
+ "fm",
146
+ ["encrypt", "--file", input],
147
+ root,
148
+ undefined,
149
+ env,
150
+ ),
151
+ { mode: 0o600 },
152
+ );
153
+ } else writeFileSync(ciphertext, original!.text, { mode: 0o600 });
154
+
155
+ const recipientText = secretCommand(
156
+ "fm",
157
+ [
158
+ "encrypt-add-recipient",
159
+ "--file",
160
+ ciphertext,
161
+ "--name",
162
+ `arcnet-${config.repository.replace("/", "-")}-${source.name}-${randomUUID()}`,
163
+ "--json",
164
+ ],
165
+ root,
166
+ undefined,
167
+ env,
168
+ );
169
+
170
+ let recipient: { secret: string };
171
+
172
+ try {
173
+ recipient = JSON.parse(recipientText) as { secret: string };
174
+ } catch {
175
+ throw new Error(
176
+ "Forum returned an invalid recipient response (value omitted).",
177
+ );
178
+ }
179
+
180
+ if (
181
+ typeof recipient.secret !== "string" ||
182
+ !recipient.secret.startsWith("doctrine-recipient-v1_")
183
+ )
184
+ throw new Error("Forum did not return a valid recipient key.");
185
+
186
+ if (original) {
187
+ const current = readRemoteSource(source, root);
188
+ if (
189
+ current.fileId !== original.fileId ||
190
+ current.revision !== original.revision
191
+ )
192
+ throw new Error(
193
+ "Forum source changed during setup; retry before writing.",
194
+ );
195
+ }
196
+
197
+ if (mode === "init") assertNewSource(source, root);
198
+ secretCommand(
199
+ "fm",
200
+ [
201
+ "file",
202
+ "write",
203
+ source.path,
204
+ "--store",
205
+ source.store,
206
+ "--file",
207
+ ciphertext,
208
+ ],
209
+ root,
210
+ undefined,
211
+ env,
212
+ );
213
+
214
+ try {
215
+ secretCommand(
216
+ "gh",
217
+ ["secret", "set", source.keyEnv, "--repo", config.repository],
218
+ root,
219
+ recipient.secret,
220
+ );
221
+ } catch {
222
+ throw new Error(
223
+ "Forum recipient was saved, but GitHub secret upload failed. Rerun setup to issue another recipient; remove the unused recipient in Forum later.",
224
+ );
225
+ }
226
+ }
227
+
228
+ const result = syncSecrets(next, root, configFile);
229
+ writeSecretsWorkflow(readSecretsConfig(root, configFile), root, configFile);
230
+ return {
231
+ ...result,
232
+ operation: `secrets.${mode}`,
233
+ githubSecret: source.keyEnv,
234
+ };
235
+ } finally {
236
+ rmSync(temporary, { recursive: true, force: true });
237
+ }
238
+ };
@@ -0,0 +1,73 @@
1
+ import { z } from "zod";
2
+ import type { SecretSource } from "./config.js";
3
+ import { secretCommand } from "./secretCommand.js";
4
+
5
+ const infoSchema = z.object({
6
+ id: z.string(),
7
+ revisionNumber: z.number().int(),
8
+ path: z.string(),
9
+ type: z.literal("text"),
10
+ });
11
+
12
+ export const readRemoteSource = (
13
+ source: SecretSource,
14
+ root: string,
15
+ environment = process.env,
16
+ ) => {
17
+ const env = { ...environment };
18
+
19
+ if (source.tokenEnv) {
20
+ if (!env[source.tokenEnv])
21
+ throw new Error(`Missing Forum access credential: ${source.tokenEnv}.`);
22
+ env.DOCTRINE_TOKEN = env[source.tokenEnv];
23
+ }
24
+
25
+ const args = [source.path, "--store", source.store];
26
+
27
+ try {
28
+ const before = infoSchema.parse(
29
+ JSON.parse(
30
+ secretCommand(
31
+ "fm",
32
+ ["file", "info", ...args, "--json"],
33
+ root,
34
+ undefined,
35
+ env,
36
+ ),
37
+ ),
38
+ );
39
+
40
+ const text = secretCommand(
41
+ "fm",
42
+ ["file", "read", ...args],
43
+ root,
44
+ undefined,
45
+ env,
46
+ );
47
+
48
+ const after = infoSchema.parse(
49
+ JSON.parse(
50
+ secretCommand(
51
+ "fm",
52
+ ["file", "info", ...args, "--json"],
53
+ root,
54
+ undefined,
55
+ env,
56
+ ),
57
+ ),
58
+ );
59
+
60
+ if (
61
+ before.id !== after.id ||
62
+ before.revisionNumber !== after.revisionNumber ||
63
+ after.path !== source.path ||
64
+ (source.snapshot && after.id !== source.snapshot.fileId)
65
+ )
66
+ throw new Error("Source changed");
67
+ return { text, fileId: after.id, revision: after.revisionNumber };
68
+ } catch {
69
+ throw new Error(
70
+ `Cannot read stable Forum source ${source.name}; check access, identity and concurrent edits.`,
71
+ );
72
+ }
73
+ };
@@ -0,0 +1,71 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ export const runWithSecrets = (
7
+ command: string[],
8
+ values: Record<string, string>,
9
+ root: string,
10
+ environment = process.env,
11
+ ) => {
12
+ if (!command.length) throw new Error("Supply a command after --.");
13
+
14
+ const inherited = [
15
+ "PATH",
16
+ "SystemRoot",
17
+ "LANG",
18
+ "CI",
19
+ "GITHUB_ACTIONS",
20
+ "GITHUB_REPOSITORY",
21
+ "GITHUB_REF",
22
+ "GITHUB_SHA",
23
+ "NPM_CONFIG_USERCONFIG",
24
+ "npm_config_userconfig",
25
+ ];
26
+
27
+ const temporary = mkdtempSync(join(tmpdir(), "arcnet-env-"));
28
+
29
+ try {
30
+ const env = {
31
+ ...Object.fromEntries(
32
+ inherited
33
+ .filter((key) => environment[key] !== undefined)
34
+ .map((key) => [key, environment[key]]),
35
+ ),
36
+ ...values,
37
+ HOME: temporary,
38
+ TMPDIR: temporary,
39
+ TMP: temporary,
40
+ TEMP: temporary,
41
+ XDG_CACHE_HOME: join(temporary, "cache"),
42
+ NPM_CONFIG_CACHE: join(temporary, "npm-cache"),
43
+ NPM_CONFIG_LOGS_MAX: "0",
44
+ NO_UPDATE_NOTIFIER: "1",
45
+ };
46
+
47
+ const result = spawnSync(command[0]!, command.slice(1), {
48
+ cwd: root,
49
+ env,
50
+ stdio: ["ignore", "pipe", "pipe"],
51
+ maxBuffer: 16 * 1024 * 1024,
52
+ });
53
+
54
+ if (result.error || result.status !== 0)
55
+ throw new Error(
56
+ "Credential-consuming command failed; output withheld to protect secrets.",
57
+ );
58
+ return {
59
+ operation: "env.run",
60
+ status: "passed",
61
+ keys: Object.keys(values),
62
+ };
63
+ } finally {
64
+ rmSync(temporary, {
65
+ recursive: true,
66
+ force: true,
67
+ maxRetries: 5,
68
+ retryDelay: 200,
69
+ });
70
+ }
71
+ };
@@ -0,0 +1,26 @@
1
+ import { spawnSync } from "node:child_process";
2
+
3
+ // Never include subprocess output in errors: it may contain plaintext or keys.
4
+ export const secretCommand = (
5
+ command: string,
6
+ args: string[],
7
+ root: string,
8
+ input?: string,
9
+ env = process.env,
10
+ ): string => {
11
+ const result = spawnSync(command, args, {
12
+ cwd: root,
13
+ env,
14
+ input,
15
+ encoding: "utf8",
16
+ stdio: ["pipe", "pipe", "pipe"],
17
+ maxBuffer: 16 * 1024 * 1024,
18
+ timeout: 120000,
19
+ });
20
+
21
+ if (result.error || result.status !== 0)
22
+ throw new Error(
23
+ `${command} ${args[0] ?? ""} failed; output withheld to protect credentials. Check authentication and permissions.`,
24
+ );
25
+ return result.stdout;
26
+ };
@@ -0,0 +1,33 @@
1
+ import type { SecretsConfig } from "./config.js";
2
+
3
+ export const selectSecrets = (
4
+ values: Record<string, unknown>,
5
+ config: SecretsConfig,
6
+ consumer: string,
7
+ ): Record<string, string> => {
8
+ const mapping = config.consumers[consumer];
9
+ if (!mapping) throw new Error(`Unknown secrets consumer: ${consumer}.`);
10
+ return Object.fromEntries(
11
+ Object.entries(mapping).map(([output, input]) => {
12
+ const aliases = Array.isArray(input) ? input : [input];
13
+
14
+ const found = aliases
15
+ .filter((name) => Object.hasOwn(values, name))
16
+ .map((name) => values[name]);
17
+
18
+ if (
19
+ !found.length ||
20
+ found.some(
21
+ (value) =>
22
+ typeof value !== "string" || !value.trim() || value.includes("\0"),
23
+ )
24
+ )
25
+ throw new Error(
26
+ `Missing or invalid required secret for ${output} (values omitted).`,
27
+ );
28
+ if (new Set(found).size !== 1)
29
+ throw new Error(`Conflicting aliases for ${output} (values omitted).`);
30
+ return [output, found[0] as string];
31
+ }),
32
+ );
33
+ };
@@ -0,0 +1,60 @@
1
+ import { parseForumEncryptedFilePayload } from "@mp-lb/forum-secrets";
2
+ import { createHash } from "node:crypto";
3
+ import { mkdirSync, renameSync, writeFileSync } from "node:fs";
4
+ import { dirname } from "node:path";
5
+ import { safeTargetPath } from "../safeTargetPath.js";
6
+ import type { SecretsConfig } from "./config.js";
7
+ import { readRemoteSource } from "./readRemoteSource.js";
8
+
9
+ export const syncSecrets = (
10
+ config: SecretsConfig,
11
+ root: string,
12
+ file: string,
13
+ ) => {
14
+ const sources = config.sources.map((source) => {
15
+ const remote = readRemoteSource(source, root);
16
+
17
+ try {
18
+ parseForumEncryptedFilePayload(remote.text);
19
+ } catch {
20
+ throw new Error(
21
+ `Source ${source.name} is not a supported encrypted file.`,
22
+ );
23
+ }
24
+
25
+ const sha256 = createHash("sha256").update(remote.text).digest("hex");
26
+
27
+ // Content-addressed snapshots keep the previous config valid if interrupted.
28
+ const snapshot = {
29
+ file: `.arcnet/secrets/${source.name}-${sha256.slice(0, 16)}.json.enc`,
30
+ fileId: remote.fileId,
31
+ revision: remote.revision,
32
+ sha256,
33
+ };
34
+
35
+ const target = safeTargetPath(root, snapshot.file);
36
+ mkdirSync(dirname(target), { recursive: true });
37
+ writeFileSync(target, remote.text, { mode: 0o600 });
38
+ return { ...source, snapshot };
39
+ });
40
+
41
+ const target = safeTargetPath(root, file);
42
+ mkdirSync(dirname(target), { recursive: true });
43
+ const temporary = safeTargetPath(root, `${file}.tmp`);
44
+ writeFileSync(
45
+ temporary,
46
+ JSON.stringify({ ...config, sources }, null, 2) + "\n",
47
+ { mode: 0o600 },
48
+ );
49
+
50
+ renameSync(temporary, target);
51
+ return {
52
+ operation: "secrets.sync",
53
+ sources: sources.map((s) => ({
54
+ name: s.name,
55
+ revision: s.snapshot.revision,
56
+ })),
57
+ message:
58
+ "Review and commit the encrypted snapshots and configuration together.",
59
+ };
60
+ };
@@ -0,0 +1,57 @@
1
+ import { readdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { isMap, isSeq, parseDocument } from "yaml";
4
+
5
+ export const wireSecretsWorkflows = (
6
+ folder: string,
7
+ env: Record<string, string>,
8
+ ) => {
9
+ for (const file of readdirSync(folder).filter((name) =>
10
+ /\.ya?ml$/.test(name),
11
+ )) {
12
+ const path = join(folder, file);
13
+ const document = parseDocument(readFileSync(path, "utf8"));
14
+ if (document.errors.length)
15
+ throw new Error(`Cannot update malformed workflow: ${file}.`);
16
+ const jobs = document.get("jobs");
17
+ if (!isMap(jobs)) continue;
18
+ let changed = false;
19
+
20
+ for (const pair of jobs.items) {
21
+ if (!isMap(pair.value)) continue;
22
+ const steps = pair.value.get("steps");
23
+ if (!isSeq(steps)) continue;
24
+
25
+ for (const step of steps.items) {
26
+ if (!isMap(step)) continue;
27
+ const run = step.get("run");
28
+ if (
29
+ typeof run !== "string" ||
30
+ !/\barcnet\s+(?:env\s+(?:check|run|export)|deploy|preview|inspect|plan)\b/.test(
31
+ run,
32
+ )
33
+ )
34
+ continue;
35
+ if (step.has("env") && !isMap(step.get("env")))
36
+ throw new Error(`Workflow step env must be a mapping: ${file}.`);
37
+ if (!step.has("env")) step.set("env", document.createNode({}));
38
+ const mapping = step.get("env");
39
+ if (!isMap(mapping)) continue;
40
+
41
+ for (const [name, value] of Object.entries(env)) {
42
+ if (mapping.has(name) && mapping.get(name) !== value)
43
+ throw new Error(
44
+ `Workflow has a conflicting source-key mapping: ${file} / ${name}.`,
45
+ );
46
+
47
+ if (!mapping.has(name)) {
48
+ mapping.set(name, value);
49
+ changed = true;
50
+ }
51
+ }
52
+ }
53
+ }
54
+
55
+ if (changed) writeFileSync(path, document.toString());
56
+ }
57
+ };
@@ -0,0 +1,77 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { parse } from "yaml";
4
+ import { readSecretsConfig } from "./config.js";
5
+ import { loadSecrets } from "./loadSecrets.js";
6
+ import { selectSecrets } from "./selectSecrets.js";
7
+
8
+ export const withDeploymentSecrets = async <T>(
9
+ root: string,
10
+ mode: string | undefined,
11
+ action: () => T | Promise<T>,
12
+ ): Promise<T> => {
13
+ if (!existsSync(join(root, ".arcnet/secrets.json"))) return action();
14
+ const config = readSecretsConfig(root);
15
+ if (
16
+ process.env.GITHUB_ACTIONS === "true" &&
17
+ (process.env.GITHUB_REPOSITORY !== config.repository ||
18
+ process.env.GITHUB_REF !== "refs/heads/main")
19
+ )
20
+ throw new Error(
21
+ "Secrets commands in CI require the configured repository main branch.",
22
+ );
23
+ const pool = await loadSecrets(config, root);
24
+ const selected = selectSecrets(pool, config, "deploy");
25
+
26
+ if (mode !== "vercel") {
27
+ const manifest = parse(readFileSync(join(root, "zap.yaml"), "utf8")) as {
28
+ deploy?: { services?: Record<string, { env?: string[] }> };
29
+ };
30
+
31
+ const keys = Object.values(manifest.deploy?.services ?? {})
32
+ .flatMap((service) => service.env ?? [])
33
+ .filter((key) => !key.includes("="));
34
+
35
+ const app = Object.fromEntries(
36
+ keys
37
+ .filter((key) => Object.hasOwn(pool, key))
38
+ .map((key) => {
39
+ if (typeof pool[key] !== "string")
40
+ throw new Error(
41
+ `Invalid application value for ${key} (value omitted).`,
42
+ );
43
+ return [key, pool[key]];
44
+ }),
45
+ );
46
+
47
+ selected.ARCNET_ENV_JSON = JSON.stringify(app);
48
+ }
49
+
50
+ const removed = config.sources
51
+ .flatMap((source) => [
52
+ source.keyEnv,
53
+ ...(source.tokenEnv ? [source.tokenEnv] : []),
54
+ ])
55
+ .concat([
56
+ "DOCTRINE_ENCRYPTION_PASSWORD",
57
+ "DOCTRINE_TOKEN",
58
+ "DOCTRINE_RECIPIENT_SECRET",
59
+ ]);
60
+
61
+ const names = new Set([...removed, ...Object.keys(selected)]);
62
+
63
+ const previous = Object.fromEntries(
64
+ [...names].map((key) => [key, process.env[key]]),
65
+ );
66
+
67
+ try {
68
+ for (const key of removed) delete process.env[key];
69
+ Object.assign(process.env, selected);
70
+ return await action();
71
+ } finally {
72
+ for (const key of names) {
73
+ if (previous[key] === undefined) delete process.env[key];
74
+ else process.env[key] = previous[key];
75
+ }
76
+ }
77
+ };