@mp-lb/arcnet 0.1.0 → 0.2.1
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.
- package/README.md +37 -4
- package/dist/createCli.d.ts.map +1 -1
- package/dist/createCli.js +14 -5
- package/dist/createCli.js.map +1 -1
- package/dist/secrets/assertNewSource.d.ts +3 -0
- package/dist/secrets/assertNewSource.d.ts.map +1 -0
- package/dist/secrets/assertNewSource.js +32 -0
- package/dist/secrets/assertNewSource.js.map +1 -0
- package/dist/secrets/config.d.ts +37 -0
- package/dist/secrets/config.d.ts.map +1 -0
- package/dist/secrets/config.js +67 -0
- package/dist/secrets/config.js.map +1 -0
- package/dist/secrets/createSecretsCommands.d.ts +3 -0
- package/dist/secrets/createSecretsCommands.d.ts.map +1 -0
- package/dist/secrets/createSecretsCommands.js +100 -0
- package/dist/secrets/createSecretsCommands.js.map +1 -0
- package/dist/secrets/loadSecrets.d.ts +3 -0
- package/dist/secrets/loadSecrets.d.ts.map +1 -0
- package/dist/secrets/loadSecrets.js +53 -0
- package/dist/secrets/loadSecrets.js.map +1 -0
- package/dist/secrets/promptPassword.d.ts +2 -0
- package/dist/secrets/promptPassword.d.ts.map +1 -0
- package/dist/secrets/promptPassword.js +42 -0
- package/dist/secrets/promptPassword.js.map +1 -0
- package/dist/secrets/provisionSource.d.ts +25 -0
- package/dist/secrets/provisionSource.d.ts.map +1 -0
- package/dist/secrets/provisionSource.js +141 -0
- package/dist/secrets/provisionSource.js.map +1 -0
- package/dist/secrets/readRemoteSource.d.ts +7 -0
- package/dist/secrets/readRemoteSource.d.ts.map +1 -0
- package/dist/secrets/readRemoteSource.js +32 -0
- package/dist/secrets/readRemoteSource.js.map +1 -0
- package/dist/secrets/runWithSecrets.d.ts +6 -0
- package/dist/secrets/runWithSecrets.d.ts.map +1 -0
- package/dist/secrets/runWithSecrets.js +59 -0
- package/dist/secrets/runWithSecrets.js.map +1 -0
- package/dist/secrets/secretCommand.d.ts +2 -0
- package/dist/secrets/secretCommand.d.ts.map +1 -0
- package/dist/secrets/secretCommand.js +17 -0
- package/dist/secrets/secretCommand.js.map +1 -0
- package/dist/secrets/selectSecrets.d.ts +3 -0
- package/dist/secrets/selectSecrets.d.ts.map +1 -0
- package/dist/secrets/selectSecrets.js +18 -0
- package/dist/secrets/selectSecrets.js.map +1 -0
- package/dist/secrets/syncSecrets.d.ts +10 -0
- package/dist/secrets/syncSecrets.d.ts.map +1 -0
- package/dist/secrets/syncSecrets.js +43 -0
- package/dist/secrets/syncSecrets.js.map +1 -0
- package/dist/secrets/wireSecretsWorkflows.d.ts +2 -0
- package/dist/secrets/wireSecretsWorkflows.d.ts.map +1 -0
- package/dist/secrets/wireSecretsWorkflows.js +48 -0
- package/dist/secrets/wireSecretsWorkflows.js.map +1 -0
- package/dist/secrets/withDeploymentSecrets.d.ts +2 -0
- package/dist/secrets/withDeploymentSecrets.d.ts.map +1 -0
- package/dist/secrets/withDeploymentSecrets.js +62 -0
- package/dist/secrets/withDeploymentSecrets.js.map +1 -0
- package/dist/secrets/writeSecretsWorkflow.d.ts +3 -0
- package/dist/secrets/writeSecretsWorkflow.d.ts.map +1 -0
- package/dist/secrets/writeSecretsWorkflow.js +60 -0
- package/dist/secrets/writeSecretsWorkflow.js.map +1 -0
- package/docs/public/commands.md +85 -0
- package/docs/public/llms-full.txt +122 -4
- package/package.json +2 -1
- package/src/createCli.ts +29 -8
- package/src/secrets/assertNewSource.ts +40 -0
- package/src/secrets/config.ts +91 -0
- package/src/secrets/createSecretsCommands.ts +206 -0
- package/src/secrets/loadSecrets.ts +69 -0
- package/src/secrets/promptPassword.ts +46 -0
- package/src/secrets/provisionSource.ts +238 -0
- package/src/secrets/readRemoteSource.ts +73 -0
- package/src/secrets/runWithSecrets.ts +71 -0
- package/src/secrets/secretCommand.ts +26 -0
- package/src/secrets/selectSecrets.ts +33 -0
- package/src/secrets/syncSecrets.ts +60 -0
- package/src/secrets/wireSecretsWorkflows.ts +57 -0
- package/src/secrets/withDeploymentSecrets.ts +83 -0
- package/src/secrets/writeSecretsWorkflow.ts +83 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
3
|
+
import { readSecretsConfig } from "./config.js";
|
|
4
|
+
import { loadSecrets } from "./loadSecrets.js";
|
|
5
|
+
import { provisionSource, type ProvisionOptions } from "./provisionSource.js";
|
|
6
|
+
import { runWithSecrets } from "./runWithSecrets.js";
|
|
7
|
+
import { selectSecrets } from "./selectSecrets.js";
|
|
8
|
+
import { syncSecrets } from "./syncSecrets.js";
|
|
9
|
+
import { writeSecretsWorkflow } from "./writeSecretsWorkflow.js";
|
|
10
|
+
import { promptPassword } from "./promptPassword.js";
|
|
11
|
+
|
|
12
|
+
export const createSecretsCommands = (
|
|
13
|
+
cli: Command,
|
|
14
|
+
output: (value: unknown) => void,
|
|
15
|
+
) => {
|
|
16
|
+
const env = cli
|
|
17
|
+
.command("env")
|
|
18
|
+
.description(
|
|
19
|
+
"Load ordered encrypted Forum sources and select environment values.",
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
for (const action of ["check", "export", "run"] as const) {
|
|
23
|
+
const command = env
|
|
24
|
+
.command(action)
|
|
25
|
+
.description(
|
|
26
|
+
{
|
|
27
|
+
check: "Validate decryption and mappings; report key names only.",
|
|
28
|
+
export:
|
|
29
|
+
"Write selected plaintext JSON to stdout for an explicit pipeline.",
|
|
30
|
+
run: "Run a command with selected keys only; withhold child output.",
|
|
31
|
+
}[action],
|
|
32
|
+
)
|
|
33
|
+
.option(
|
|
34
|
+
"--config <file>",
|
|
35
|
+
"Secrets source configuration",
|
|
36
|
+
".arcnet/secrets.json",
|
|
37
|
+
)
|
|
38
|
+
.option("--consumer <name>", "Named mapping in the secrets configuration")
|
|
39
|
+
.option(
|
|
40
|
+
"--live",
|
|
41
|
+
"Fetch current ciphertext through the installed fm CLI instead of snapshots",
|
|
42
|
+
)
|
|
43
|
+
.option(
|
|
44
|
+
"--password",
|
|
45
|
+
"Prompt for the Forum encryption password locally instead of using file keys",
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
if (action === "run")
|
|
49
|
+
command.argument("<command...>", "Executable and arguments after --");
|
|
50
|
+
command.action(async (...args: unknown[]) => {
|
|
51
|
+
const options = command.opts<{
|
|
52
|
+
config: string;
|
|
53
|
+
consumer?: string;
|
|
54
|
+
live?: boolean;
|
|
55
|
+
password?: boolean;
|
|
56
|
+
}>();
|
|
57
|
+
|
|
58
|
+
const root = realpathSync(cli.opts().root);
|
|
59
|
+
const config = readSecretsConfig(root, options.config);
|
|
60
|
+
if (
|
|
61
|
+
process.env.GITHUB_ACTIONS === "true" &&
|
|
62
|
+
(process.env.GITHUB_REPOSITORY !== config.repository ||
|
|
63
|
+
process.env.GITHUB_REF !== "refs/heads/main")
|
|
64
|
+
)
|
|
65
|
+
throw new Error(
|
|
66
|
+
"Secrets commands in CI require the configured repository main branch.",
|
|
67
|
+
);
|
|
68
|
+
if (options.password && process.env.GITHUB_ACTIONS === "true")
|
|
69
|
+
throw new Error("Password decryption is local-only; CI must use file keys.");
|
|
70
|
+
if (action !== "check" && !options.consumer)
|
|
71
|
+
throw new Error("Choose --consumer to explicitly select output keys.");
|
|
72
|
+
const password = options.password
|
|
73
|
+
? await promptPassword(Boolean(cli.opts().noninteractive))
|
|
74
|
+
: undefined;
|
|
75
|
+
const values = await loadSecrets(
|
|
76
|
+
config,
|
|
77
|
+
root,
|
|
78
|
+
process.env,
|
|
79
|
+
options.live,
|
|
80
|
+
password,
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const names = options.consumer
|
|
84
|
+
? [options.consumer]
|
|
85
|
+
: Object.keys(config.consumers);
|
|
86
|
+
|
|
87
|
+
const selected = Object.fromEntries(
|
|
88
|
+
names.map((name) => [name, selectSecrets(values, config, name)]),
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
if (action === "export")
|
|
92
|
+
process.stdout.write(
|
|
93
|
+
JSON.stringify(selected[options.consumer!]) + "\n",
|
|
94
|
+
);
|
|
95
|
+
else if (action === "run")
|
|
96
|
+
output(
|
|
97
|
+
runWithSecrets(
|
|
98
|
+
args[0] as string[],
|
|
99
|
+
selected[options.consumer!]!,
|
|
100
|
+
root,
|
|
101
|
+
),
|
|
102
|
+
);
|
|
103
|
+
else
|
|
104
|
+
output({
|
|
105
|
+
operation: "env.check",
|
|
106
|
+
status: "passed",
|
|
107
|
+
sources: config.sources.map((source) => source.name),
|
|
108
|
+
consumers: Object.fromEntries(
|
|
109
|
+
Object.entries(selected).map(([name, value]) => [
|
|
110
|
+
name,
|
|
111
|
+
Object.keys(value),
|
|
112
|
+
]),
|
|
113
|
+
),
|
|
114
|
+
message:
|
|
115
|
+
"Decryption, merge and required-key checks passed; provider authorization was not tested.",
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const secrets = cli
|
|
121
|
+
.command("secrets")
|
|
122
|
+
.description(
|
|
123
|
+
"Attach Forum files, provision GitHub keys and refresh encrypted snapshots.",
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
for (const mode of ["add", "init"] as const) {
|
|
127
|
+
const command = secrets
|
|
128
|
+
.command(mode)
|
|
129
|
+
.description(
|
|
130
|
+
mode === "add"
|
|
131
|
+
? "Attach an existing Forum file and provision its CI key."
|
|
132
|
+
: "Create a Forum secrets file, prompt for your password and provision its CI key.",
|
|
133
|
+
)
|
|
134
|
+
.requiredOption(
|
|
135
|
+
"--name <name>",
|
|
136
|
+
"Unique source name; sources merge in installation order",
|
|
137
|
+
)
|
|
138
|
+
.requiredOption("--store <workspace/store>", "Forum store")
|
|
139
|
+
.requiredOption("--path <path>", "Encrypted file path in Forum")
|
|
140
|
+
.requiredOption(
|
|
141
|
+
"--key-env <name>",
|
|
142
|
+
"GitHub repository secret / environment variable for this file's key",
|
|
143
|
+
)
|
|
144
|
+
.requiredOption(
|
|
145
|
+
"--repository <owner/repo>",
|
|
146
|
+
"GitHub repository receiving the key",
|
|
147
|
+
)
|
|
148
|
+
.option(
|
|
149
|
+
"--config <file>",
|
|
150
|
+
"Secrets source configuration",
|
|
151
|
+
".arcnet/secrets.json",
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
if (mode === "add")
|
|
155
|
+
command.option(
|
|
156
|
+
"--existing-key",
|
|
157
|
+
"Reuse an existing GitHub secret without creating a recipient",
|
|
158
|
+
);
|
|
159
|
+
else
|
|
160
|
+
command.option(
|
|
161
|
+
"--input <file>",
|
|
162
|
+
"Initial plaintext JSON file; defaults to an empty object",
|
|
163
|
+
);
|
|
164
|
+
command.action(async () =>
|
|
165
|
+
output(
|
|
166
|
+
await provisionSource(
|
|
167
|
+
mode,
|
|
168
|
+
{
|
|
169
|
+
...command.opts<ProvisionOptions>(),
|
|
170
|
+
noninteractive: cli.opts().noninteractive,
|
|
171
|
+
},
|
|
172
|
+
realpathSync(cli.opts().root),
|
|
173
|
+
command.opts().config,
|
|
174
|
+
),
|
|
175
|
+
),
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
secrets
|
|
180
|
+
.command("sync")
|
|
181
|
+
.description(
|
|
182
|
+
"Refresh encrypted snapshots from Forum and regenerate validation CI; no decryption.",
|
|
183
|
+
)
|
|
184
|
+
.option(
|
|
185
|
+
"--config <file>",
|
|
186
|
+
"Secrets source configuration",
|
|
187
|
+
".arcnet/secrets.json",
|
|
188
|
+
)
|
|
189
|
+
.action((options: { config: string }) => {
|
|
190
|
+
const root = realpathSync(cli.opts().root);
|
|
191
|
+
|
|
192
|
+
const result = syncSecrets(
|
|
193
|
+
readSecretsConfig(root, options.config),
|
|
194
|
+
root,
|
|
195
|
+
options.config,
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
writeSecretsWorkflow(
|
|
199
|
+
readSecretsConfig(root, options.config),
|
|
200
|
+
root,
|
|
201
|
+
options.config,
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
output(result);
|
|
205
|
+
});
|
|
206
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { decryptForumEncryptedFileText } from "@mp-lb/forum-secrets";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { safeTargetPath } from "../safeTargetPath.js";
|
|
7
|
+
import type { SecretsConfig } from "./config.js";
|
|
8
|
+
import { readRemoteSource } from "./readRemoteSource.js";
|
|
9
|
+
import { secretCommand } from "./secretCommand.js";
|
|
10
|
+
|
|
11
|
+
export const loadSecrets = async (
|
|
12
|
+
config: SecretsConfig,
|
|
13
|
+
root: string,
|
|
14
|
+
environment = process.env,
|
|
15
|
+
live = false,
|
|
16
|
+
password?: string,
|
|
17
|
+
) => {
|
|
18
|
+
let values: Record<string, unknown> = {};
|
|
19
|
+
|
|
20
|
+
for (const source of config.sources) {
|
|
21
|
+
const key = environment[source.keyEnv];
|
|
22
|
+
if (!key && !password)
|
|
23
|
+
throw new Error(`Missing decryption credential: ${source.keyEnv}.`);
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const text =
|
|
27
|
+
!live && source.snapshot
|
|
28
|
+
? readFileSync(safeTargetPath(root, source.snapshot.file), "utf8")
|
|
29
|
+
: readRemoteSource(source, root, environment).text;
|
|
30
|
+
|
|
31
|
+
if (
|
|
32
|
+
!live &&
|
|
33
|
+
source.snapshot &&
|
|
34
|
+
createHash("sha256").update(text).digest("hex") !==
|
|
35
|
+
source.snapshot.sha256
|
|
36
|
+
)
|
|
37
|
+
throw new Error("Snapshot integrity mismatch");
|
|
38
|
+
|
|
39
|
+
let plaintext: string;
|
|
40
|
+
if (password) {
|
|
41
|
+
const temporary = mkdtempSync(join(tmpdir(), "arcnet-decrypt-"));
|
|
42
|
+
try {
|
|
43
|
+
const file = join(temporary, "source.json.enc");
|
|
44
|
+
writeFileSync(file, text, { mode: 0o600 });
|
|
45
|
+
plaintext = secretCommand("fm", ["decrypt", "--file", file], root,
|
|
46
|
+
undefined, { ...environment, DOCTRINE_ENCRYPTION_PASSWORD: password });
|
|
47
|
+
} finally {
|
|
48
|
+
rmSync(temporary, { recursive: true, force: true });
|
|
49
|
+
}
|
|
50
|
+
} else {
|
|
51
|
+
plaintext = await decryptForumEncryptedFileText({
|
|
52
|
+
encryptedText: text,
|
|
53
|
+
recipientSecret: key!,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
const decoded: unknown = JSON.parse(plaintext);
|
|
57
|
+
|
|
58
|
+
if (!decoded || Array.isArray(decoded) || typeof decoded !== "object")
|
|
59
|
+
throw new Error("Not an object");
|
|
60
|
+
values = { ...values, ...decoded };
|
|
61
|
+
} catch {
|
|
62
|
+
throw new Error(
|
|
63
|
+
`Source ${source.name} failed to load or decrypt; check its key and snapshot integrity (values omitted).`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return values;
|
|
69
|
+
};
|
|
@@ -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
|
+
};
|