@mp-lb/arcnet 0.0.0-stage → 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.
- package/README.md +48 -2
- package/dist/addDependency.d.ts +3 -0
- package/dist/addDependency.d.ts.map +1 -0
- package/dist/addDependency.js +29 -0
- package/dist/addDependency.js.map +1 -0
- package/dist/apiClient.d.ts +3 -0
- package/dist/apiClient.d.ts.map +1 -0
- package/dist/apiClient.js +39 -0
- package/dist/apiClient.js.map +1 -0
- package/dist/checkIdentity.d.ts +3 -0
- package/dist/checkIdentity.d.ts.map +1 -0
- package/dist/checkIdentity.js +9 -0
- package/dist/checkIdentity.js.map +1 -0
- package/dist/ciOutputs.d.ts +2 -0
- package/dist/ciOutputs.d.ts.map +1 -0
- package/dist/ciOutputs.js +31 -0
- package/dist/ciOutputs.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +9 -0
- package/dist/cli.js.map +1 -0
- package/dist/createCli.d.ts +3 -0
- package/dist/createCli.d.ts.map +1 -0
- package/dist/createCli.js +90 -0
- package/dist/createCli.js.map +1 -0
- package/dist/createProject.d.ts +26 -0
- package/dist/createProject.d.ts.map +1 -0
- package/dist/createProject.js +35 -0
- package/dist/createProject.js.map +1 -0
- package/dist/createSetupCommand.d.ts +3 -0
- package/dist/createSetupCommand.d.ts.map +1 -0
- package/dist/createSetupCommand.js +11 -0
- package/dist/createSetupCommand.js.map +1 -0
- package/dist/deploy.d.ts +3 -0
- package/dist/deploy.d.ts.map +1 -0
- package/dist/deploy.js +47 -0
- package/dist/deploy.js.map +1 -0
- package/dist/fail.d.ts +2 -0
- package/dist/fail.d.ts.map +1 -0
- package/dist/fail.js +4 -0
- package/dist/fail.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/inspectReferences.d.ts +3 -0
- package/dist/inspectReferences.d.ts.map +1 -0
- package/dist/inspectReferences.js +10 -0
- package/dist/inspectReferences.js.map +1 -0
- package/dist/inspectStaticTree.d.ts +2 -0
- package/dist/inspectStaticTree.d.ts.map +1 -0
- package/dist/inspectStaticTree.js +17 -0
- package/dist/inspectStaticTree.js.map +1 -0
- package/dist/packageInfo.d.ts +3 -0
- package/dist/packageInfo.d.ts.map +1 -0
- package/dist/packageInfo.js +9 -0
- package/dist/packageInfo.js.map +1 -0
- package/dist/parseSelection.d.ts +3 -0
- package/dist/parseSelection.d.ts.map +1 -0
- package/dist/parseSelection.js +13 -0
- package/dist/parseSelection.js.map +1 -0
- package/dist/parseSetupInput.d.ts +16 -0
- package/dist/parseSetupInput.d.ts.map +1 -0
- package/dist/parseSetupInput.js +34 -0
- package/dist/parseSetupInput.js.map +1 -0
- package/dist/readSelection.d.ts +15 -0
- package/dist/readSelection.d.ts.map +1 -0
- package/dist/readSelection.js +36 -0
- package/dist/readSelection.js.map +1 -0
- package/dist/renderPlan.d.ts +27 -0
- package/dist/renderPlan.d.ts.map +1 -0
- package/dist/renderPlan.js +25 -0
- package/dist/renderPlan.js.map +1 -0
- package/dist/renderResult.d.ts +5 -0
- package/dist/renderResult.d.ts.map +1 -0
- package/dist/renderResult.js +15 -0
- package/dist/renderResult.js.map +1 -0
- package/dist/resolveRepository.d.ts +2 -0
- package/dist/resolveRepository.d.ts.map +1 -0
- package/dist/resolveRepository.js +24 -0
- package/dist/resolveRepository.js.map +1 -0
- package/dist/runProcess.d.ts +2 -0
- package/dist/runProcess.d.ts.map +1 -0
- package/dist/runProcess.js +16 -0
- package/dist/runProcess.js.map +1 -0
- package/dist/runTerraform.d.ts +8 -0
- package/dist/runTerraform.d.ts.map +1 -0
- package/dist/runTerraform.js +111 -0
- package/dist/runTerraform.js.map +1 -0
- package/dist/runVercel.d.ts +34 -0
- package/dist/runVercel.d.ts.map +1 -0
- package/dist/runVercel.js +52 -0
- package/dist/runVercel.js.map +1 -0
- package/dist/safePath.d.ts +2 -0
- package/dist/safePath.d.ts.map +1 -0
- package/dist/safePath.js +35 -0
- package/dist/safePath.js.map +1 -0
- package/dist/safeTargetPath.d.ts +2 -0
- package/dist/safeTargetPath.d.ts.map +1 -0
- package/dist/safeTargetPath.js +29 -0
- package/dist/safeTargetPath.js.map +1 -0
- package/dist/schemas.d.ts +106 -0
- package/dist/schemas.d.ts.map +1 -0
- package/dist/schemas.js +85 -0
- package/dist/schemas.js.map +1 -0
- 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 +93 -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 +35 -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 +58 -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/dist/selectNetwork.d.ts +26 -0
- package/dist/selectNetwork.d.ts.map +1 -0
- package/dist/selectNetwork.js +48 -0
- package/dist/selectNetwork.js.map +1 -0
- package/dist/setup.d.ts +2 -0
- package/dist/setup.d.ts.map +1 -0
- package/dist/setup.js +192 -0
- package/dist/setup.js.map +1 -0
- package/dist/setupReport.d.ts +8 -0
- package/dist/setupReport.d.ts.map +1 -0
- package/dist/setupReport.js +2 -0
- package/dist/setupReport.js.map +1 -0
- package/dist/setupVercel.d.ts +9 -0
- package/dist/setupVercel.d.ts.map +1 -0
- package/dist/setupVercel.js +72 -0
- package/dist/setupVercel.js.map +1 -0
- package/dist/stageSite.d.ts +3 -0
- package/dist/stageSite.d.ts.map +1 -0
- package/dist/stageSite.js +31 -0
- package/dist/stageSite.js.map +1 -0
- package/dist/validateConfig.d.ts +3 -0
- package/dist/validateConfig.d.ts.map +1 -0
- package/dist/validateConfig.js +17 -0
- package/dist/validateConfig.js.map +1 -0
- package/docs/public/commands.md +132 -0
- package/docs/public/llms-full.txt +182 -0
- package/package.json +50 -3
- package/src/addDependency.ts +41 -0
- package/src/apiClient.ts +51 -0
- package/src/checkIdentity.ts +18 -0
- package/src/ciOutputs.ts +47 -0
- package/src/cli.ts +11 -0
- package/src/createCli.ts +128 -0
- package/src/createProject.ts +66 -0
- package/src/createSetupCommand.ts +16 -0
- package/src/deploy.ts +71 -0
- package/src/fail.ts +3 -0
- package/src/index.ts +2 -0
- package/src/inspectReferences.ts +20 -0
- package/src/inspectStaticTree.ts +19 -0
- package/src/packageInfo.ts +10 -0
- package/src/parseSelection.ts +22 -0
- package/src/parseSetupInput.ts +40 -0
- package/src/readSelection.ts +47 -0
- package/src/renderPlan.ts +27 -0
- package/src/renderResult.ts +18 -0
- package/src/resolveRepository.ts +38 -0
- package/src/runProcess.ts +26 -0
- package/src/runTerraform.ts +182 -0
- package/src/runVercel.ts +77 -0
- package/src/safePath.ts +43 -0
- package/src/safeTargetPath.ts +32 -0
- package/src/schemas.ts +126 -0
- package/src/secrets/assertNewSource.ts +40 -0
- package/src/secrets/config.ts +91 -0
- package/src/secrets/createSecretsCommands.ts +189 -0
- package/src/secrets/loadSecrets.ts +53 -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 +77 -0
- package/src/secrets/writeSecretsWorkflow.ts +83 -0
- package/src/selectNetwork.ts +73 -0
- package/src/setup.ts +277 -0
- package/src/setupReport.ts +11 -0
- package/src/setupVercel.ts +126 -0
- package/src/stageSite.ts +54 -0
- package/src/validateConfig.ts +22 -0
- package/templates/deploy-arc.yaml +73 -0
- package/templates/deploy-vercel.yaml +43 -0
- package/templates/network.yaml +105 -0
- package/templates/vercel-static/main.tf +128 -0
- package/templates/vercel-static/module.yaml +51 -0
package/src/runVercel.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { apiClient } from "./apiClient.js";
|
|
4
|
+
import { createProject } from "./createProject.js";
|
|
5
|
+
import { deploy } from "./deploy.js";
|
|
6
|
+
import { inspectReferences } from "./inspectReferences.js";
|
|
7
|
+
import { renderPlan } from "./renderPlan.js";
|
|
8
|
+
import { runProcess } from "./runProcess.js";
|
|
9
|
+
import { validateConfig } from "./validateConfig.js";
|
|
10
|
+
|
|
11
|
+
export const runVercel = async (
|
|
12
|
+
command: string,
|
|
13
|
+
root: string,
|
|
14
|
+
argument?: string,
|
|
15
|
+
machine = false,
|
|
16
|
+
) => {
|
|
17
|
+
const path = join(root, ".arcnet/project.json");
|
|
18
|
+
const original = readFileSync(path, "utf8");
|
|
19
|
+
const config = validateConfig(JSON.parse(original), root);
|
|
20
|
+
if (command === "check" || command === "plan")
|
|
21
|
+
return renderPlan(config, root);
|
|
22
|
+
if (command === "create" && process.env.GITHUB_ACTIONS === "true")
|
|
23
|
+
throw new Error(
|
|
24
|
+
"Project creation is a separate local operator step, never a CI side effect.",
|
|
25
|
+
);
|
|
26
|
+
if (["deploy", "preview"].includes(command) && !config.approved)
|
|
27
|
+
throw new Error(
|
|
28
|
+
"Deployment disabled (approved=false). No remote calls made.",
|
|
29
|
+
);
|
|
30
|
+
const request = apiClient(process.env.VERCEL_TOKEN);
|
|
31
|
+
|
|
32
|
+
if (command === "inspect") {
|
|
33
|
+
await inspectReferences(config, request);
|
|
34
|
+
return {
|
|
35
|
+
operation: command,
|
|
36
|
+
message:
|
|
37
|
+
"All existing project IDs, names and account scopes match. No settings or DNS changed.",
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (command === "create") {
|
|
42
|
+
if (!argument) throw new Error("Choose a configured site.");
|
|
43
|
+
const next = await createProject(config, argument, request);
|
|
44
|
+
if (next === config)
|
|
45
|
+
return {
|
|
46
|
+
operation: command,
|
|
47
|
+
message:
|
|
48
|
+
"Already an existing reference; identity verified, nothing created.",
|
|
49
|
+
};
|
|
50
|
+
if (readFileSync(path, "utf8") !== original)
|
|
51
|
+
throw new Error(
|
|
52
|
+
`Config changed during creation. Recover the created project ID ${next.sites[argument]!.projectId} manually; no local settings overwritten.`,
|
|
53
|
+
);
|
|
54
|
+
writeFileSync(`${path}.new`, JSON.stringify(next, null, 2) + "\n", {
|
|
55
|
+
flag: "wx",
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
renameSync(`${path}.new`, path);
|
|
59
|
+
return {
|
|
60
|
+
operation: command,
|
|
61
|
+
projectId: next.sites[argument]!.projectId,
|
|
62
|
+
message:
|
|
63
|
+
"Created project; review and commit project.json before CI. Deployment remains disabled.",
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
await deploy(
|
|
68
|
+
config,
|
|
69
|
+
root,
|
|
70
|
+
request,
|
|
71
|
+
(cmd, args, cwd, env) => runProcess(cmd, args, cwd, env, machine),
|
|
72
|
+
process.env,
|
|
73
|
+
command === "preview" ? "preview" : "production",
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
return { operation: command, message: "Selected static sites deployed." };
|
|
77
|
+
};
|
package/src/safePath.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { lstatSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
3
|
+
import { fail } from "./fail.js";
|
|
4
|
+
|
|
5
|
+
export const safePath = (root: string, name: string) => {
|
|
6
|
+
if (
|
|
7
|
+
typeof name !== "string" ||
|
|
8
|
+
!name ||
|
|
9
|
+
isAbsolute(name) ||
|
|
10
|
+
name.includes("\\")
|
|
11
|
+
)
|
|
12
|
+
fail("Expected a project-relative path.");
|
|
13
|
+
|
|
14
|
+
const target = resolve(root, name),
|
|
15
|
+
rel = relative(root, target);
|
|
16
|
+
|
|
17
|
+
if (
|
|
18
|
+
!rel ||
|
|
19
|
+
rel === ".." ||
|
|
20
|
+
rel.startsWith("../") ||
|
|
21
|
+
rel
|
|
22
|
+
.split("/")
|
|
23
|
+
.some((p) => [".git", "node_modules", ".arcnet"].includes(p)) ||
|
|
24
|
+
/^apps\/myshop(?:\/|$)/i.test(rel)
|
|
25
|
+
)
|
|
26
|
+
fail(`Unsafe or local-only artifact path: ${name}`);
|
|
27
|
+
let at = root;
|
|
28
|
+
|
|
29
|
+
for (const part of rel.split("/")) {
|
|
30
|
+
at = join(at, part);
|
|
31
|
+
let stat;
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
stat = lstatSync(at);
|
|
35
|
+
} catch (e) {
|
|
36
|
+
if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (stat?.isSymbolicLink()) fail(`Symlink not allowed: ${name}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return target;
|
|
43
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { lstatSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
3
|
+
import { fail } from "./fail.js";
|
|
4
|
+
|
|
5
|
+
export const safeTargetPath = (root: string, name: string) => {
|
|
6
|
+
const target = resolve(root, name);
|
|
7
|
+
if (
|
|
8
|
+
!name ||
|
|
9
|
+
isAbsolute(name) ||
|
|
10
|
+
relative(root, target) === ".." ||
|
|
11
|
+
relative(root, target).startsWith("../")
|
|
12
|
+
)
|
|
13
|
+
fail(`Path must stay within the project: ${name}`);
|
|
14
|
+
let at = root;
|
|
15
|
+
|
|
16
|
+
for (const part of relative(root, target).split("/")) {
|
|
17
|
+
at = join(at, part);
|
|
18
|
+
let stat;
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
stat = lstatSync(at);
|
|
22
|
+
} catch (error) {
|
|
23
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (stat?.isSymbolicLink()) fail(`Refusing symlink: ${name}`);
|
|
27
|
+
if (stat && at !== target && !stat.isDirectory())
|
|
28
|
+
fail(`Parent path is not a directory: ${name}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return target;
|
|
32
|
+
};
|
package/src/schemas.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
const projectName = z
|
|
4
|
+
.string()
|
|
5
|
+
.regex(/^[a-z0-9][a-z0-9-]{0,99}$/)
|
|
6
|
+
.refine((v) => !/REPLACE/i.test(v));
|
|
7
|
+
|
|
8
|
+
const accountSchema = z
|
|
9
|
+
.object({
|
|
10
|
+
type: z.enum(["team", "personal"]),
|
|
11
|
+
id: z
|
|
12
|
+
.string()
|
|
13
|
+
.regex(/^[A-Za-z0-9_]{3,128}$/)
|
|
14
|
+
.refine((v) => !/REPLACE/i.test(v)),
|
|
15
|
+
})
|
|
16
|
+
.strict()
|
|
17
|
+
.refine(
|
|
18
|
+
(a) => a.type !== "team" || a.id.startsWith("team_"),
|
|
19
|
+
"Supply the exact Vercel account ID and type.",
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
const siteSchema = z
|
|
23
|
+
.object({
|
|
24
|
+
mode: z.enum(["existing", "new"]),
|
|
25
|
+
projectId: z
|
|
26
|
+
.string()
|
|
27
|
+
.regex(/^prj_[A-Za-z0-9]+$/)
|
|
28
|
+
.refine((v) => !/REPLACE/i.test(v))
|
|
29
|
+
.optional(),
|
|
30
|
+
projectName,
|
|
31
|
+
creationApproved: z.boolean().optional(),
|
|
32
|
+
build: z
|
|
33
|
+
.array(
|
|
34
|
+
z
|
|
35
|
+
.string()
|
|
36
|
+
.min(1)
|
|
37
|
+
.refine(
|
|
38
|
+
(v) => !/myshop|[\n\r\0]/i.test(v),
|
|
39
|
+
"MyShop must remain local-only.",
|
|
40
|
+
),
|
|
41
|
+
)
|
|
42
|
+
.min(1)
|
|
43
|
+
.refine(
|
|
44
|
+
(v) => v[0] === "pnpm",
|
|
45
|
+
"Build must be an explicit pnpm argument array.",
|
|
46
|
+
),
|
|
47
|
+
output: z.string(),
|
|
48
|
+
outputConfig: z
|
|
49
|
+
.object({
|
|
50
|
+
version: z.literal(3),
|
|
51
|
+
routes: z.array(z.unknown()).optional(),
|
|
52
|
+
overrides: z.record(z.string(), z.unknown()).optional(),
|
|
53
|
+
})
|
|
54
|
+
.strict(),
|
|
55
|
+
})
|
|
56
|
+
.strict()
|
|
57
|
+
.refine(
|
|
58
|
+
(s) =>
|
|
59
|
+
s.mode === "existing"
|
|
60
|
+
? Boolean(s.projectId) && s.creationApproved === undefined
|
|
61
|
+
: s.projectId === undefined && typeof s.creationApproved === "boolean",
|
|
62
|
+
"Existing sites require projectId; new sites require creationApproved.",
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const sitesSchema = z
|
|
66
|
+
.record(
|
|
67
|
+
z
|
|
68
|
+
.string()
|
|
69
|
+
.regex(/^[a-z][a-z0-9-]*$/)
|
|
70
|
+
.refine((v) => !/myshop/i.test(v), "MyShop is local-only."),
|
|
71
|
+
siteSchema,
|
|
72
|
+
)
|
|
73
|
+
.refine((v) => Object.keys(v).length > 0, "Select at least one static site.");
|
|
74
|
+
|
|
75
|
+
export const vercelInputSchema = z
|
|
76
|
+
.object({
|
|
77
|
+
repository: z
|
|
78
|
+
.string()
|
|
79
|
+
.regex(/^[A-Za-z0-9][A-Za-z0-9-]*\/[A-Za-z0-9][A-Za-z0-9._-]*$/),
|
|
80
|
+
account: accountSchema,
|
|
81
|
+
sites: sitesSchema,
|
|
82
|
+
})
|
|
83
|
+
.strict();
|
|
84
|
+
export const vercelConfigSchema = vercelInputSchema
|
|
85
|
+
.extend({
|
|
86
|
+
version: z.literal(1),
|
|
87
|
+
mode: z.literal("vercel"),
|
|
88
|
+
slug: z.string().regex(/^[a-z][a-z0-9-]{0,22}$/),
|
|
89
|
+
approved: z.boolean(),
|
|
90
|
+
})
|
|
91
|
+
.strict();
|
|
92
|
+
export const vercelResponseSchema = z.object({
|
|
93
|
+
id: z.string().optional(),
|
|
94
|
+
name: z.string().optional(),
|
|
95
|
+
accountId: z.string().optional(),
|
|
96
|
+
user: z.object({ id: z.string() }).optional(),
|
|
97
|
+
});
|
|
98
|
+
export type VercelConfig = z.infer<typeof vercelConfigSchema>;
|
|
99
|
+
export type VercelSite = z.infer<typeof siteSchema>;
|
|
100
|
+
export type VercelAccount = z.infer<typeof accountSchema>;
|
|
101
|
+
export type VercelResponse = z.infer<typeof vercelResponseSchema>;
|
|
102
|
+
export type VercelRequest = (
|
|
103
|
+
method: string,
|
|
104
|
+
path: string,
|
|
105
|
+
account: VercelAccount,
|
|
106
|
+
body?: unknown,
|
|
107
|
+
) => Promise<VercelResponse | null>;
|
|
108
|
+
export type RunProcess = (
|
|
109
|
+
command: string,
|
|
110
|
+
args: string[],
|
|
111
|
+
cwd: string,
|
|
112
|
+
env: NodeJS.ProcessEnv,
|
|
113
|
+
) => void | Promise<void>;
|
|
114
|
+
export const terraformConfigSchema = z.object({
|
|
115
|
+
version: z.literal(1),
|
|
116
|
+
slug: z.string(),
|
|
117
|
+
repository: z.string(),
|
|
118
|
+
approved: z.boolean(),
|
|
119
|
+
services: z.array(z.string()),
|
|
120
|
+
workloadIdentityProvider: z.string(),
|
|
121
|
+
serviceAccount: z.string(),
|
|
122
|
+
});
|
|
123
|
+
export const envPoolSchema = z.record(
|
|
124
|
+
z.string().regex(/^[A-Z_][A-Z0-9_]*$/),
|
|
125
|
+
z.string(),
|
|
126
|
+
);
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { posix } from "node:path";
|
|
2
|
+
import type { SecretSource } from "./config.js";
|
|
3
|
+
import { secretCommand } from "./secretCommand.js";
|
|
4
|
+
|
|
5
|
+
// fm's write API can overwrite without --force, so its default is not create-only.
|
|
6
|
+
export const assertNewSource = (source: SecretSource, root: string) => {
|
|
7
|
+
const listing = secretCommand(
|
|
8
|
+
"fm",
|
|
9
|
+
[
|
|
10
|
+
"file",
|
|
11
|
+
"ls",
|
|
12
|
+
posix.dirname(source.path),
|
|
13
|
+
"--store",
|
|
14
|
+
source.store,
|
|
15
|
+
"--json",
|
|
16
|
+
],
|
|
17
|
+
root,
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
let names: string[];
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
names = listing
|
|
24
|
+
.trim()
|
|
25
|
+
.split("\n")
|
|
26
|
+
.filter(Boolean)
|
|
27
|
+
.map((line) => {
|
|
28
|
+
const entry = JSON.parse(line) as { message?: unknown };
|
|
29
|
+
if (typeof entry.message !== "string") throw new Error();
|
|
30
|
+
return entry.message;
|
|
31
|
+
});
|
|
32
|
+
} catch {
|
|
33
|
+
throw new Error("Cannot verify that the new Forum path is unused.");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (names.includes(posix.basename(source.path)))
|
|
37
|
+
throw new Error(
|
|
38
|
+
"Forum file already exists; use secrets add to attach it without replacing its contents.",
|
|
39
|
+
);
|
|
40
|
+
};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { safeTargetPath } from "../safeTargetPath.js";
|
|
4
|
+
|
|
5
|
+
export const envName = z.string().regex(/^[A-Z_][A-Z0-9_]*$/);
|
|
6
|
+
export const sourceSchema = z
|
|
7
|
+
.object({
|
|
8
|
+
name: z.string().regex(/^[a-z][a-z0-9-]*$/),
|
|
9
|
+
store: z.string().regex(/^[\w-]+\/[\w-]+$/),
|
|
10
|
+
path: z
|
|
11
|
+
.string()
|
|
12
|
+
.startsWith("/")
|
|
13
|
+
.endsWith(".enc")
|
|
14
|
+
.refine(
|
|
15
|
+
(path) =>
|
|
16
|
+
!/[\r\n\0]/.test(path) &&
|
|
17
|
+
path
|
|
18
|
+
.split("/")
|
|
19
|
+
.slice(1)
|
|
20
|
+
.every((part) => part && part !== "." && part !== ".."),
|
|
21
|
+
),
|
|
22
|
+
keyEnv: envName,
|
|
23
|
+
tokenEnv: envName.optional(),
|
|
24
|
+
snapshot: z
|
|
25
|
+
.object({
|
|
26
|
+
file: z.string(),
|
|
27
|
+
fileId: z.string().min(1),
|
|
28
|
+
revision: z.number().int().nonnegative(),
|
|
29
|
+
sha256: z.string().regex(/^[a-f0-9]{64}$/),
|
|
30
|
+
})
|
|
31
|
+
.strict()
|
|
32
|
+
.optional(),
|
|
33
|
+
})
|
|
34
|
+
.strict();
|
|
35
|
+
export const secretsConfigSchema = z
|
|
36
|
+
.object({
|
|
37
|
+
version: z.literal(1),
|
|
38
|
+
repository: z.string().regex(/^[\w.-]+\/[\w.-]+$/),
|
|
39
|
+
sources: z.array(sourceSchema).min(1),
|
|
40
|
+
consumers: z
|
|
41
|
+
.record(
|
|
42
|
+
z.string(),
|
|
43
|
+
z.record(
|
|
44
|
+
z.string().min(1),
|
|
45
|
+
z.union([envName, z.array(envName).min(1)]),
|
|
46
|
+
),
|
|
47
|
+
)
|
|
48
|
+
.default({}),
|
|
49
|
+
})
|
|
50
|
+
.strict()
|
|
51
|
+
.superRefine((config, ctx) => {
|
|
52
|
+
if (
|
|
53
|
+
new Set(config.sources.map((s) => s.name)).size !== config.sources.length
|
|
54
|
+
)
|
|
55
|
+
ctx.addIssue({ code: "custom", message: "Source names must be unique." });
|
|
56
|
+
if (
|
|
57
|
+
new Set(config.sources.map((s) => s.keyEnv)).size !==
|
|
58
|
+
config.sources.length
|
|
59
|
+
)
|
|
60
|
+
ctx.addIssue({
|
|
61
|
+
code: "custom",
|
|
62
|
+
message: "Each source needs its own key environment name.",
|
|
63
|
+
});
|
|
64
|
+
for (const mapping of Object.values(config.consumers))
|
|
65
|
+
for (const name of Object.keys(mapping))
|
|
66
|
+
if (
|
|
67
|
+
!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) &&
|
|
68
|
+
name !== "pnpm_config_//registry.npmjs.org/:_authToken"
|
|
69
|
+
)
|
|
70
|
+
ctx.addIssue({
|
|
71
|
+
code: "custom",
|
|
72
|
+
message: "Invalid output environment name.",
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
export type SecretsConfig = z.infer<typeof secretsConfigSchema>;
|
|
76
|
+
export type SecretSource = z.infer<typeof sourceSchema>;
|
|
77
|
+
|
|
78
|
+
export const readSecretsConfig = (
|
|
79
|
+
root: string,
|
|
80
|
+
file = ".arcnet/secrets.json",
|
|
81
|
+
): SecretsConfig => {
|
|
82
|
+
try {
|
|
83
|
+
return secretsConfigSchema.parse(
|
|
84
|
+
JSON.parse(readFileSync(safeTargetPath(root, file), "utf8")),
|
|
85
|
+
);
|
|
86
|
+
} catch {
|
|
87
|
+
throw new Error(
|
|
88
|
+
"Missing or invalid secrets configuration (values omitted).",
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
@@ -0,0 +1,189 @@
|
|
|
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
|
+
|
|
11
|
+
export const createSecretsCommands = (
|
|
12
|
+
cli: Command,
|
|
13
|
+
output: (value: unknown) => void,
|
|
14
|
+
) => {
|
|
15
|
+
const env = cli
|
|
16
|
+
.command("env")
|
|
17
|
+
.description(
|
|
18
|
+
"Load ordered encrypted Forum sources and select environment values.",
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
for (const action of ["check", "export", "run"] as const) {
|
|
22
|
+
const command = env
|
|
23
|
+
.command(action)
|
|
24
|
+
.description(
|
|
25
|
+
{
|
|
26
|
+
check: "Validate decryption and mappings; report key names only.",
|
|
27
|
+
export:
|
|
28
|
+
"Write selected plaintext JSON to stdout for an explicit pipeline.",
|
|
29
|
+
run: "Run a command with selected keys only; withhold child output.",
|
|
30
|
+
}[action],
|
|
31
|
+
)
|
|
32
|
+
.option(
|
|
33
|
+
"--config <file>",
|
|
34
|
+
"Secrets source configuration",
|
|
35
|
+
".arcnet/secrets.json",
|
|
36
|
+
)
|
|
37
|
+
.option("--consumer <name>", "Named mapping in the secrets configuration")
|
|
38
|
+
.option(
|
|
39
|
+
"--live",
|
|
40
|
+
"Fetch current ciphertext through the installed fm CLI instead of snapshots",
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
if (action === "run")
|
|
44
|
+
command.argument("<command...>", "Executable and arguments after --");
|
|
45
|
+
command.action(async (...args: unknown[]) => {
|
|
46
|
+
const options = command.opts<{
|
|
47
|
+
config: string;
|
|
48
|
+
consumer?: string;
|
|
49
|
+
live?: boolean;
|
|
50
|
+
}>();
|
|
51
|
+
|
|
52
|
+
const root = realpathSync(cli.opts().root);
|
|
53
|
+
const config = readSecretsConfig(root, options.config);
|
|
54
|
+
if (
|
|
55
|
+
process.env.GITHUB_ACTIONS === "true" &&
|
|
56
|
+
(process.env.GITHUB_REPOSITORY !== config.repository ||
|
|
57
|
+
process.env.GITHUB_REF !== "refs/heads/main")
|
|
58
|
+
)
|
|
59
|
+
throw new Error(
|
|
60
|
+
"Secrets commands in CI require the configured repository main branch.",
|
|
61
|
+
);
|
|
62
|
+
if (action !== "check" && !options.consumer)
|
|
63
|
+
throw new Error("Choose --consumer to explicitly select output keys.");
|
|
64
|
+
const values = await loadSecrets(config, root, process.env, options.live);
|
|
65
|
+
|
|
66
|
+
const names = options.consumer
|
|
67
|
+
? [options.consumer]
|
|
68
|
+
: Object.keys(config.consumers);
|
|
69
|
+
|
|
70
|
+
const selected = Object.fromEntries(
|
|
71
|
+
names.map((name) => [name, selectSecrets(values, config, name)]),
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
if (action === "export")
|
|
75
|
+
process.stdout.write(
|
|
76
|
+
JSON.stringify(selected[options.consumer!]) + "\n",
|
|
77
|
+
);
|
|
78
|
+
else if (action === "run")
|
|
79
|
+
output(
|
|
80
|
+
runWithSecrets(
|
|
81
|
+
args[0] as string[],
|
|
82
|
+
selected[options.consumer!]!,
|
|
83
|
+
root,
|
|
84
|
+
),
|
|
85
|
+
);
|
|
86
|
+
else
|
|
87
|
+
output({
|
|
88
|
+
operation: "env.check",
|
|
89
|
+
status: "passed",
|
|
90
|
+
sources: config.sources.map((source) => source.name),
|
|
91
|
+
consumers: Object.fromEntries(
|
|
92
|
+
Object.entries(selected).map(([name, value]) => [
|
|
93
|
+
name,
|
|
94
|
+
Object.keys(value),
|
|
95
|
+
]),
|
|
96
|
+
),
|
|
97
|
+
message:
|
|
98
|
+
"Decryption, merge and required-key checks passed; provider authorization was not tested.",
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const secrets = cli
|
|
104
|
+
.command("secrets")
|
|
105
|
+
.description(
|
|
106
|
+
"Attach Forum files, provision GitHub keys and refresh encrypted snapshots.",
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
for (const mode of ["add", "init"] as const) {
|
|
110
|
+
const command = secrets
|
|
111
|
+
.command(mode)
|
|
112
|
+
.description(
|
|
113
|
+
mode === "add"
|
|
114
|
+
? "Attach an existing Forum file and provision its CI key."
|
|
115
|
+
: "Create a Forum secrets file, prompt for your password and provision its CI key.",
|
|
116
|
+
)
|
|
117
|
+
.requiredOption(
|
|
118
|
+
"--name <name>",
|
|
119
|
+
"Unique source name; sources merge in installation order",
|
|
120
|
+
)
|
|
121
|
+
.requiredOption("--store <workspace/store>", "Forum store")
|
|
122
|
+
.requiredOption("--path <path>", "Encrypted file path in Forum")
|
|
123
|
+
.requiredOption(
|
|
124
|
+
"--key-env <name>",
|
|
125
|
+
"GitHub repository secret / environment variable for this file's key",
|
|
126
|
+
)
|
|
127
|
+
.requiredOption(
|
|
128
|
+
"--repository <owner/repo>",
|
|
129
|
+
"GitHub repository receiving the key",
|
|
130
|
+
)
|
|
131
|
+
.option(
|
|
132
|
+
"--config <file>",
|
|
133
|
+
"Secrets source configuration",
|
|
134
|
+
".arcnet/secrets.json",
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
if (mode === "add")
|
|
138
|
+
command.option(
|
|
139
|
+
"--existing-key",
|
|
140
|
+
"Reuse an existing GitHub secret without creating a recipient",
|
|
141
|
+
);
|
|
142
|
+
else
|
|
143
|
+
command.option(
|
|
144
|
+
"--input <file>",
|
|
145
|
+
"Initial plaintext JSON file; defaults to an empty object",
|
|
146
|
+
);
|
|
147
|
+
command.action(async () =>
|
|
148
|
+
output(
|
|
149
|
+
await provisionSource(
|
|
150
|
+
mode,
|
|
151
|
+
{
|
|
152
|
+
...command.opts<ProvisionOptions>(),
|
|
153
|
+
noninteractive: cli.opts().noninteractive,
|
|
154
|
+
},
|
|
155
|
+
realpathSync(cli.opts().root),
|
|
156
|
+
command.opts().config,
|
|
157
|
+
),
|
|
158
|
+
),
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
secrets
|
|
163
|
+
.command("sync")
|
|
164
|
+
.description(
|
|
165
|
+
"Refresh encrypted snapshots from Forum and regenerate validation CI; no decryption.",
|
|
166
|
+
)
|
|
167
|
+
.option(
|
|
168
|
+
"--config <file>",
|
|
169
|
+
"Secrets source configuration",
|
|
170
|
+
".arcnet/secrets.json",
|
|
171
|
+
)
|
|
172
|
+
.action((options: { config: string }) => {
|
|
173
|
+
const root = realpathSync(cli.opts().root);
|
|
174
|
+
|
|
175
|
+
const result = syncSecrets(
|
|
176
|
+
readSecretsConfig(root, options.config),
|
|
177
|
+
root,
|
|
178
|
+
options.config,
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
writeSecretsWorkflow(
|
|
182
|
+
readSecretsConfig(root, options.config),
|
|
183
|
+
root,
|
|
184
|
+
options.config,
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
output(result);
|
|
188
|
+
});
|
|
189
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { decryptForumEncryptedFileText } from "@mp-lb/forum-secrets";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { safeTargetPath } from "../safeTargetPath.js";
|
|
5
|
+
import type { SecretsConfig } from "./config.js";
|
|
6
|
+
import { readRemoteSource } from "./readRemoteSource.js";
|
|
7
|
+
|
|
8
|
+
export const loadSecrets = async (
|
|
9
|
+
config: SecretsConfig,
|
|
10
|
+
root: string,
|
|
11
|
+
environment = process.env,
|
|
12
|
+
live = false,
|
|
13
|
+
) => {
|
|
14
|
+
let values: Record<string, unknown> = {};
|
|
15
|
+
|
|
16
|
+
for (const source of config.sources) {
|
|
17
|
+
const key = environment[source.keyEnv];
|
|
18
|
+
if (!key)
|
|
19
|
+
throw new Error(`Missing decryption credential: ${source.keyEnv}.`);
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
const text =
|
|
23
|
+
!live && source.snapshot
|
|
24
|
+
? readFileSync(safeTargetPath(root, source.snapshot.file), "utf8")
|
|
25
|
+
: readRemoteSource(source, root, environment).text;
|
|
26
|
+
|
|
27
|
+
if (
|
|
28
|
+
!live &&
|
|
29
|
+
source.snapshot &&
|
|
30
|
+
createHash("sha256").update(text).digest("hex") !==
|
|
31
|
+
source.snapshot.sha256
|
|
32
|
+
)
|
|
33
|
+
throw new Error("Snapshot integrity mismatch");
|
|
34
|
+
|
|
35
|
+
const decoded: unknown = JSON.parse(
|
|
36
|
+
await decryptForumEncryptedFileText({
|
|
37
|
+
encryptedText: text,
|
|
38
|
+
recipientSecret: key,
|
|
39
|
+
}),
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
if (!decoded || Array.isArray(decoded) || typeof decoded !== "object")
|
|
43
|
+
throw new Error("Not an object");
|
|
44
|
+
values = { ...values, ...decoded };
|
|
45
|
+
} catch {
|
|
46
|
+
throw new Error(
|
|
47
|
+
`Source ${source.name} failed to load or decrypt; check its key and snapshot integrity (values omitted).`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return values;
|
|
53
|
+
};
|
|
@@ -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
|
+
};
|