@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,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,83 @@
|
|
|
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
|
+
import { promptPassword } from "./promptPassword.js";
|
|
8
|
+
|
|
9
|
+
export const withDeploymentSecrets = async <T>(
|
|
10
|
+
root: string,
|
|
11
|
+
mode: string | undefined,
|
|
12
|
+
action: () => T | Promise<T>,
|
|
13
|
+
usePassword = false,
|
|
14
|
+
noninteractive = false,
|
|
15
|
+
): Promise<T> => {
|
|
16
|
+
if (!existsSync(join(root, ".arcnet/secrets.json"))) return action();
|
|
17
|
+
const config = readSecretsConfig(root);
|
|
18
|
+
if (
|
|
19
|
+
process.env.GITHUB_ACTIONS === "true" &&
|
|
20
|
+
(process.env.GITHUB_REPOSITORY !== config.repository ||
|
|
21
|
+
process.env.GITHUB_REF !== "refs/heads/main")
|
|
22
|
+
)
|
|
23
|
+
throw new Error(
|
|
24
|
+
"Secrets commands in CI require the configured repository main branch.",
|
|
25
|
+
);
|
|
26
|
+
if (usePassword && process.env.GITHUB_ACTIONS === "true")
|
|
27
|
+
throw new Error("Password decryption is local-only; CI must use file keys.");
|
|
28
|
+
const password = usePassword ? await promptPassword(noninteractive) : undefined;
|
|
29
|
+
const pool = await loadSecrets(config, root, process.env, false, password);
|
|
30
|
+
const selected = selectSecrets(pool, config, "deploy");
|
|
31
|
+
|
|
32
|
+
if (mode !== "vercel") {
|
|
33
|
+
const manifest = parse(readFileSync(join(root, "zap.yaml"), "utf8")) as {
|
|
34
|
+
deploy?: { services?: Record<string, { env?: string[] }> };
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const keys = Object.values(manifest.deploy?.services ?? {})
|
|
38
|
+
.flatMap((service) => service.env ?? [])
|
|
39
|
+
.filter((key) => !key.includes("="));
|
|
40
|
+
|
|
41
|
+
const app = Object.fromEntries(
|
|
42
|
+
keys
|
|
43
|
+
.filter((key) => Object.hasOwn(pool, key))
|
|
44
|
+
.map((key) => {
|
|
45
|
+
if (typeof pool[key] !== "string")
|
|
46
|
+
throw new Error(
|
|
47
|
+
`Invalid application value for ${key} (value omitted).`,
|
|
48
|
+
);
|
|
49
|
+
return [key, pool[key]];
|
|
50
|
+
}),
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
selected.ARCNET_ENV_JSON = JSON.stringify(app);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const removed = config.sources
|
|
57
|
+
.flatMap((source) => [
|
|
58
|
+
source.keyEnv,
|
|
59
|
+
...(source.tokenEnv ? [source.tokenEnv] : []),
|
|
60
|
+
])
|
|
61
|
+
.concat([
|
|
62
|
+
"DOCTRINE_ENCRYPTION_PASSWORD",
|
|
63
|
+
"DOCTRINE_TOKEN",
|
|
64
|
+
"DOCTRINE_RECIPIENT_SECRET",
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
const names = new Set([...removed, ...Object.keys(selected)]);
|
|
68
|
+
|
|
69
|
+
const previous = Object.fromEntries(
|
|
70
|
+
[...names].map((key) => [key, process.env[key]]),
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
for (const key of removed) delete process.env[key];
|
|
75
|
+
Object.assign(process.env, selected);
|
|
76
|
+
return await action();
|
|
77
|
+
} finally {
|
|
78
|
+
for (const key of names) {
|
|
79
|
+
if (previous[key] === undefined) delete process.env[key];
|
|
80
|
+
else process.env[key] = previous[key];
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { mkdirSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { stringify } from "yaml";
|
|
4
|
+
import { safeTargetPath } from "../safeTargetPath.js";
|
|
5
|
+
import type { SecretsConfig } from "./config.js";
|
|
6
|
+
import { wireSecretsWorkflows } from "./wireSecretsWorkflows.js";
|
|
7
|
+
|
|
8
|
+
export const writeSecretsWorkflow = (
|
|
9
|
+
config: SecretsConfig,
|
|
10
|
+
root: string,
|
|
11
|
+
configFile: string,
|
|
12
|
+
) => {
|
|
13
|
+
const path = safeTargetPath(
|
|
14
|
+
root,
|
|
15
|
+
".github/workflows/validate-arcnet-secrets.yaml",
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
const marker =
|
|
19
|
+
"# Generated by arcnet secrets; configure sources in .arcnet/secrets.json.\n";
|
|
20
|
+
|
|
21
|
+
if (existsSync(path) && !readFileSync(path, "utf8").startsWith(marker))
|
|
22
|
+
throw new Error(
|
|
23
|
+
"Existing validate-arcnet-secrets.yaml is not ArcNet-managed; move it before continuing.",
|
|
24
|
+
);
|
|
25
|
+
if (configFile !== ".arcnet/secrets.json")
|
|
26
|
+
throw new Error(
|
|
27
|
+
"Generated CI requires the standard .arcnet/secrets.json location.",
|
|
28
|
+
);
|
|
29
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
30
|
+
|
|
31
|
+
const env = Object.fromEntries(
|
|
32
|
+
config.sources
|
|
33
|
+
.flatMap((source) => [
|
|
34
|
+
source.keyEnv,
|
|
35
|
+
...(!source.snapshot && source.tokenEnv ? [source.tokenEnv] : []),
|
|
36
|
+
])
|
|
37
|
+
.map((key) => [key, `\u0024{{ secrets.${key} }}`]),
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
writeFileSync(
|
|
41
|
+
path,
|
|
42
|
+
marker +
|
|
43
|
+
stringify({
|
|
44
|
+
name: "Validate ArcNet secrets",
|
|
45
|
+
on: {
|
|
46
|
+
workflow_dispatch: {},
|
|
47
|
+
push: {
|
|
48
|
+
branches: ["main"],
|
|
49
|
+
paths: [
|
|
50
|
+
".arcnet/**",
|
|
51
|
+
".github/workflows/validate-arcnet-secrets.yaml",
|
|
52
|
+
"package.json",
|
|
53
|
+
"pnpm-lock.yaml",
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
permissions: { contents: "read" },
|
|
58
|
+
jobs: {
|
|
59
|
+
validate: {
|
|
60
|
+
if: `github.repository == '${config.repository}' && github.ref == 'refs/heads/main'`,
|
|
61
|
+
"runs-on": "ubuntu-latest",
|
|
62
|
+
"timeout-minutes": 10,
|
|
63
|
+
steps: [
|
|
64
|
+
{
|
|
65
|
+
uses: "actions/checkout@v4",
|
|
66
|
+
with: { "persist-credentials": false },
|
|
67
|
+
},
|
|
68
|
+
{ uses: "pnpm/action-setup@v4" },
|
|
69
|
+
{ uses: "actions/setup-node@v4", with: { "node-version": 22 } },
|
|
70
|
+
{ run: "pnpm install --frozen-lockfile --ignore-scripts" },
|
|
71
|
+
{
|
|
72
|
+
name: "Decrypt, merge and validate selected keys",
|
|
73
|
+
run: "pnpm exec arcnet env check",
|
|
74
|
+
env,
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
}),
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
wireSecretsWorkflows(dirname(path), env);
|
|
83
|
+
};
|