@takodotid/circuit 0.43.1 → 1.0.0-rc.2
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 +115 -37
- package/package.json +21 -58
- package/src/checks/across.ts +402 -0
- package/src/checks/built-in.ts +256 -0
- package/src/checks/device.ts +436 -0
- package/src/checks/index.ts +53 -0
- package/src/cli/advisories.ts +108 -0
- package/src/cli/app.ts +71 -0
- package/src/cli/editor-types.generated.ts +30 -0
- package/src/cli/editor-types.ts +69 -0
- package/src/cli/format.ts +124 -0
- package/src/cli/git-hooks.ts +79 -0
- package/src/cli/index.ts +350 -1170
- package/src/cli/migrate.ts +554 -0
- package/src/cli/project-commands.ts +656 -0
- package/src/cli/releases.ts +374 -0
- package/src/cli/remember.ts +339 -0
- package/src/cli/rename.ts +27 -33
- package/src/cli/secrets-password.ts +60 -0
- package/src/cli/secrets.ts +258 -0
- package/src/cli/setup.ts +300 -0
- package/src/cli/signing.ts +23 -0
- package/src/cli/text-editor.ts +268 -0
- package/src/cli/ui.ts +298 -0
- package/src/cli/version.ts +2 -0
- package/src/cli/versions.ts +158 -0
- package/src/config/address.ts +102 -0
- package/src/config/circuit.d.ts +30 -0
- package/src/config/common.ts +65 -0
- package/src/config/communities.ts +479 -0
- package/src/config/define.ts +684 -0
- package/src/config/device.ts +164 -0
- package/src/config/firewall.ts +150 -0
- package/src/config/index.ts +22 -0
- package/src/{schema → config}/interfaces.ts +121 -85
- package/src/config/keys.ts +434 -0
- package/src/config/management.ts +61 -0
- package/src/config/merge.ts +335 -0
- package/src/config/models.ts +70 -0
- package/src/config/monitoring.ts +64 -0
- package/src/config/network.ts +180 -0
- package/src/config/presets.ts +149 -0
- package/src/config/routing.ts +401 -0
- package/src/config/services.ts +51 -0
- package/src/config/switching.ts +84 -0
- package/src/config/system.ts +103 -0
- package/src/{schema → config}/time-zones.ts +1 -1
- package/src/core/address.ts +1 -1
- package/src/core/cabling.ts +8 -43
- package/src/core/graph.ts +40 -0
- package/src/{integrations → core}/registries.ts +5 -3
- package/src/core/secrets-file.ts +539 -0
- package/src/core/secrets.ts +83 -64
- package/src/core/topology.ts +103 -146
- package/src/core/utils.ts +12 -1
- package/src/model.ts +83 -0
- package/src/platforms/derive.ts +64 -37
- package/src/platforms/finding.ts +9 -0
- package/src/platforms/fortios/firewall.ts +76 -64
- package/src/platforms/fortios/index.ts +189 -125
- package/src/platforms/fortios/names.ts +51 -18
- package/src/platforms/fortios/render.ts +69 -62
- package/src/platforms/fortios/routing.ts +89 -58
- package/src/platforms/fortios/vdoms.ts +47 -16
- package/src/platforms/junos/TRAPS.md +2 -2
- package/src/platforms/junos/bgp.ts +63 -42
- package/src/platforms/junos/index.ts +122 -89
- package/src/platforms/junos/render.ts +139 -104
- package/src/platforms/platform.ts +10 -8
- package/src/platforms/raisecom-ros/TRAPS.md +1 -1
- package/src/platforms/raisecom-ros/index.ts +89 -70
- package/src/platforms/raisecom-ros/render.ts +49 -45
- package/src/platforms/routeros/TRAPS.md +3 -3
- package/src/platforms/routeros/index.ts +121 -60
- package/src/platforms/routeros/render.ts +214 -195
- package/src/platforms/vrp/TRAPS.md +1 -1
- package/src/platforms/vrp/index.ts +74 -50
- package/src/platforms/vrp/render.ts +67 -56
- package/src/project/project.ts +725 -0
- package/src/studio/app/env.d.ts +9 -0
- package/src/studio/app/index.html +18 -0
- package/src/studio/app/src/App.vue +95 -0
- package/src/studio/app/src/components/AddressesView.vue +38 -0
- package/src/studio/app/src/components/CablesView.vue +29 -0
- package/src/studio/app/src/components/CompareView.vue +160 -0
- package/src/studio/app/src/components/DataTable.vue +49 -0
- package/src/studio/app/src/components/DeviceView.vue +237 -0
- package/src/studio/app/src/components/DevicesView.vue +32 -0
- package/src/studio/app/src/components/Faceplate.vue +75 -0
- package/src/studio/app/src/components/GraphView.vue +282 -0
- package/src/studio/app/src/components/LineDiff.vue +24 -0
- package/src/studio/app/src/components/ProblemsView.vue +42 -0
- package/src/studio/app/src/components/SearchView.vue +72 -0
- package/src/studio/app/src/components/SecretsView.vue +112 -0
- package/src/studio/app/src/components/SessionsView.vue +35 -0
- package/src/studio/app/src/components/Settings.vue +95 -0
- package/src/studio/app/src/components/ToolsView.vue +118 -0
- package/src/studio/app/src/components/VlansView.vue +50 -0
- package/src/studio/app/src/lib/csv.ts +19 -0
- package/src/studio/{public/diff.js → app/src/lib/diff.ts} +40 -35
- package/src/studio/app/src/lib/format.ts +38 -0
- package/src/studio/app/src/lib/net.ts +68 -0
- package/src/studio/app/src/lib/secrets-crypto.ts +35 -0
- package/src/studio/app/src/lib/store.ts +71 -0
- package/src/studio/app/src/lib/tools.ts +217 -0
- package/src/studio/app/src/main.ts +9 -0
- package/src/studio/{public → app/src}/style.css +14 -4
- package/src/studio/app/tsconfig.json +17 -0
- package/src/studio/app/vite.config.ts +16 -0
- package/src/studio/assets.generated.ts +3 -0
- package/src/studio/data.ts +96 -188
- package/src/studio/git.ts +27 -43
- package/src/studio/server.ts +196 -107
- package/src/studio/types.ts +141 -0
- package/src/transport/console.ts +3 -3
- package/src/transport/ssh.ts +1 -1
- package/dist/chunks/index-3b343xva.js +0 -177
- package/dist/chunks/index-6wb41q67.js +0 -62
- package/dist/chunks/index-n7w3tg5y.js +0 -149
- package/dist/chunks/index-yzw86rc8.js +0 -921
- package/dist/chunks/new-w3zys5y4.js +0 -172
- package/dist/chunks/server-cxr9ynxc.js +0 -259
- package/dist/cli/index.js +0 -12922
- package/dist/helpers/index.js +0 -881
- package/dist/index.js +0 -28
- package/dist/types/core/address-space.d.ts +0 -62
- package/dist/types/core/address.d.ts +0 -44
- package/dist/types/core/cabling.d.ts +0 -41
- package/dist/types/core/cross-checks.d.ts +0 -28
- package/dist/types/core/define.d.ts +0 -79
- package/dist/types/core/project-files.d.ts +0 -12
- package/dist/types/core/schema-docs.d.ts +0 -25
- package/dist/types/core/secrets.d.ts +0 -62
- package/dist/types/core/topology.d.ts +0 -96
- package/dist/types/core/utils.d.ts +0 -10
- package/dist/types/core/validator.d.ts +0 -23
- package/dist/types/helpers/bgp.d.ts +0 -195
- package/dist/types/helpers/cables.d.ts +0 -69
- package/dist/types/helpers/checks.d.ts +0 -36
- package/dist/types/helpers/exchange.d.ts +0 -55
- package/dist/types/helpers/filters.d.ts +0 -51
- package/dist/types/helpers/index.d.ts +0 -14
- package/dist/types/helpers/route-server.d.ts +0 -52
- package/dist/types/helpers/upstreams.d.ts +0 -192
- package/dist/types/index.d.ts +0 -10
- package/dist/types/integrations/librenms.d.ts +0 -60
- package/dist/types/integrations/netbox.d.ts +0 -89
- package/dist/types/integrations/peeringdb.d.ts +0 -24
- package/dist/types/integrations/prometheus.d.ts +0 -24
- package/dist/types/integrations/registries.d.ts +0 -8
- package/dist/types/platforms/bgp-wait.d.ts +0 -24
- package/dist/types/platforms/catalog.d.ts +0 -109
- package/dist/types/platforms/derive.d.ts +0 -17
- package/dist/types/platforms/fortios/firewall.d.ts +0 -47
- package/dist/types/platforms/fortios/index.d.ts +0 -140
- package/dist/types/platforms/fortios/models.d.ts +0 -14
- package/dist/types/platforms/fortios/names.d.ts +0 -52
- package/dist/types/platforms/fortios/plan.d.ts +0 -114
- package/dist/types/platforms/fortios/render.d.ts +0 -77
- package/dist/types/platforms/fortios/routing.d.ts +0 -56
- package/dist/types/platforms/fortios/tree.d.ts +0 -37
- package/dist/types/platforms/fortios/vdoms.d.ts +0 -50
- package/dist/types/platforms/index.d.ts +0 -13
- package/dist/types/platforms/junos/bgp.d.ts +0 -44
- package/dist/types/platforms/junos/index.d.ts +0 -114
- package/dist/types/platforms/junos/models.d.ts +0 -14
- package/dist/types/platforms/junos/render.d.ts +0 -154
- package/dist/types/platforms/line-diff.d.ts +0 -67
- package/dist/types/platforms/platform.d.ts +0 -135
- package/dist/types/platforms/raisecom-ros/index.d.ts +0 -71
- package/dist/types/platforms/raisecom-ros/models.d.ts +0 -14
- package/dist/types/platforms/raisecom-ros/render.d.ts +0 -41
- package/dist/types/platforms/routeros/export.d.ts +0 -59
- package/dist/types/platforms/routeros/index.d.ts +0 -116
- package/dist/types/platforms/routeros/models.d.ts +0 -18
- package/dist/types/platforms/routeros/plan.d.ts +0 -58
- package/dist/types/platforms/routeros/render.d.ts +0 -250
- package/dist/types/platforms/vrp/index.d.ts +0 -83
- package/dist/types/platforms/vrp/models.d.ts +0 -12
- package/dist/types/platforms/vrp/render.d.ts +0 -96
- package/dist/types/schema/common.d.ts +0 -30
- package/dist/types/schema/device.d.ts +0 -126
- package/dist/types/schema/firewall.d.ts +0 -131
- package/dist/types/schema/index.d.ts +0 -8
- package/dist/types/schema/interfaces.d.ts +0 -205
- package/dist/types/schema/policy.d.ts +0 -107
- package/dist/types/schema/routing.d.ts +0 -161
- package/dist/types/schema/system.d.ts +0 -198
- package/dist/types/schema/time-zones.d.ts +0 -2
- package/dist/types/transport/console.d.ts +0 -115
- package/dist/types/transport/path-check.d.ts +0 -32
- package/dist/types/transport/ssh.d.ts +0 -27
- package/src/cli/graph-format.ts +0 -91
- package/src/cli/new.ts +0 -234
- package/src/core/address-space.ts +0 -320
- package/src/core/cross-checks.ts +0 -369
- package/src/core/define.ts +0 -124
- package/src/core/project-files.ts +0 -35
- package/src/core/schema-docs.ts +0 -174
- package/src/core/validator.ts +0 -508
- package/src/helpers/bgp.ts +0 -473
- package/src/helpers/cables.ts +0 -74
- package/src/helpers/checks.ts +0 -201
- package/src/helpers/exchange.ts +0 -123
- package/src/helpers/filters.ts +0 -107
- package/src/helpers/index.ts +0 -25
- package/src/helpers/route-server.ts +0 -212
- package/src/helpers/upstreams.ts +0 -369
- package/src/index.ts +0 -13
- package/src/integrations/librenms.ts +0 -291
- package/src/integrations/netbox.ts +0 -620
- package/src/integrations/peeringdb.ts +0 -173
- package/src/integrations/prometheus.ts +0 -90
- package/src/schema/common.ts +0 -41
- package/src/schema/device.ts +0 -145
- package/src/schema/firewall.ts +0 -140
- package/src/schema/index.ts +0 -8
- package/src/schema/policy.ts +0 -115
- package/src/schema/routing.ts +0 -173
- package/src/schema/system.ts +0 -203
- package/src/studio/public/app.js +0 -186
- package/src/studio/public/compare.js +0 -250
- package/src/studio/public/dom.js +0 -138
- package/src/studio/public/evaluate.js +0 -687
- package/src/studio/public/export.js +0 -130
- package/src/studio/public/graph.js +0 -1541
- package/src/studio/public/index.html +0 -83
- package/src/studio/public/net.js +0 -134
- package/src/studio/public/tables.js +0 -1289
- package/src/studio/public/tools.js +0 -610
- package/templates/colocation/checks.ts +0 -53
- package/templates/colocation/circuit.config.ts +0 -9
- package/templates/colocation/edge-01.ts +0 -133
- package/templates/colocation/routing.ts +0 -13
- package/templates/colocation/tenancy.ts +0 -76
- package/templates/colocation/tenants/acme.ts +0 -19
- package/templates/colocation/tenants/index.ts +0 -6
- package/templates/colocation/tor-01.ts +0 -33
- package/templates/edge-router/checks.ts +0 -20
- package/templates/edge-router/circuit.config.ts +0 -8
- package/templates/edge-router/edge-01.ts +0 -110
- package/templates/edge-router/routing.ts +0 -13
- package/templates/project/AGENTS.md +0 -51
- package/templates/project/CLAUDE.md +0 -1
- package/templates/project/README.md +0 -16
- package/templates/project/gitignore +0 -5
- package/templates/project/tsconfig.json +0 -13
- package/templates/single-site/checks.ts +0 -26
- package/templates/single-site/circuit.config.ts +0 -8
- package/templates/single-site/router.ts +0 -86
- package/templates/single-site/site.ts +0 -18
- package/templates/single-site/switch.ts +0 -34
- /package/src/studio/{public → app/public}/favicon.svg +0 -0
- /package/src/studio/{public → app/public}/special-gothic-latin-ext.woff2 +0 -0
- /package/src/studio/{public → app/public}/special-gothic-latin.woff2 +0 -0
- /package/src/studio/{public → app/public}/special-gothic-license.txt +0 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// `circuit secrets`: a network's secrets file, each value encrypted. Alone, in a terminal, it opens the secrets to look through and change them; its commands list them, read one, set, delete, encrypt what was typed in plain text, and change the password.
|
|
2
|
+
|
|
3
|
+
import { type SecretKind, OnePassword, SecretsFile } from "../core/secrets-file";
|
|
4
|
+
import { Secrets } from "../core/secrets";
|
|
5
|
+
import { ProjectError, readProject, secretsFileOf } from "../project/project";
|
|
6
|
+
import { isAbsolute, normalize, relative, resolve, sep } from "node:path";
|
|
7
|
+
import { ProjectCommands } from "./project-commands";
|
|
8
|
+
import { Setup } from "./setup";
|
|
9
|
+
import { SecretsPassword } from "./secrets-password";
|
|
10
|
+
import { TextEditor } from "./text-editor";
|
|
11
|
+
import { Ui } from "./ui";
|
|
12
|
+
|
|
13
|
+
export class SecretCommands {
|
|
14
|
+
/** A name a secret can take: letters, digits and `_`, not starting with a digit. */
|
|
15
|
+
private static readonly NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
16
|
+
|
|
17
|
+
private constructor(
|
|
18
|
+
readonly root: string,
|
|
19
|
+
private readonly file: SecretsFile,
|
|
20
|
+
/** The secrets the config names that live in this file. */
|
|
21
|
+
private readonly needed: readonly string[]
|
|
22
|
+
) {}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The secrets file to work on: the one `--file` names, relative to where the command runs, or `.circuit/secrets`. A project whose
|
|
26
|
+
* networks keep their secrets elsewhere has to name the file. Reading the project says which secrets the config needs; a project
|
|
27
|
+
* that can't be read still works on the file named.
|
|
28
|
+
*/
|
|
29
|
+
static async open(file: string | undefined): Promise<SecretCommands> {
|
|
30
|
+
const root = ProjectCommands.find() ?? Ui.fail("No Circuit project here: no circuit.ts in this folder or above it.");
|
|
31
|
+
Setup.prepare(root);
|
|
32
|
+
Secrets.useRoot(root);
|
|
33
|
+
|
|
34
|
+
const name = file === undefined ? SecretsFile.DEFAULT : SecretCommands.inProject(root, file);
|
|
35
|
+
const needed = new Set<string>();
|
|
36
|
+
try {
|
|
37
|
+
const read = await Ui.step("Reading the project", () => readProject(root));
|
|
38
|
+
const files = [...new Set(read.networks.map(secretsFileOf))].map(SecretCommands.normal);
|
|
39
|
+
const using = read.networks.filter((network) => SecretCommands.normal(secretsFileOf(network)) === SecretCommands.normal(name));
|
|
40
|
+
|
|
41
|
+
if (!using.length && files.length) {
|
|
42
|
+
if (file === undefined) Ui.fail(`This project keeps its secrets in ${files.join(", ")}. Name the file: --file ${files[0]}`);
|
|
43
|
+
Ui.warn(`No network uses ${name}. The project's secrets files: ${files.join(", ")}`);
|
|
44
|
+
}
|
|
45
|
+
for (const network of using)
|
|
46
|
+
for (const reference of Secrets.referencesIn([network.devices, network.settings.integrations])) needed.add(reference);
|
|
47
|
+
} catch (error) {
|
|
48
|
+
if (!(error instanceof ProjectError)) throw error;
|
|
49
|
+
Ui.warn("The project has errors, so this cannot say which secrets the config needs. Run circuit validate.");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
Secrets.useFile(name);
|
|
53
|
+
return new SecretCommands(root, Secrets.file(), [...needed].filter((each) => !each.startsWith("file:")).sort());
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A path as the project writes it: relative to its root, with forward slashes. */
|
|
57
|
+
private static normal(path: string): string {
|
|
58
|
+
return normalize(path).split(sep).join("/");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** `--file`, given relative to where the command runs, as a path inside the project. */
|
|
62
|
+
private static inProject(root: string, file: string): string {
|
|
63
|
+
const path = relative(root, resolve(file));
|
|
64
|
+
if (!path || path.startsWith("..") || isAbsolute(path)) Ui.fail(`${file} is not inside the project, ${root}.`);
|
|
65
|
+
return SecretCommands.normal(path);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private static checkName(name: string): string {
|
|
69
|
+
if (!SecretCommands.NAME.test(name) || name === SecretsFile.PASSWORD)
|
|
70
|
+
Ui.fail(`${name} is not a name a secret can take: letters, digits and _, such as EDGE_01_PASSWORD.`);
|
|
71
|
+
return name;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private static describe(kind: SecretKind | "missing", value = ""): string {
|
|
75
|
+
if (kind === "encrypted") return Ui.green("encrypted");
|
|
76
|
+
if (kind === "1password") return `${Ui.green("1Password")} ${Ui.dim(value)}`;
|
|
77
|
+
if (kind === "empty") return Ui.yellow("empty");
|
|
78
|
+
if (kind === "missing") return Ui.red("missing, the config needs it");
|
|
79
|
+
return Ui.red("NOT ENCRYPTED");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Every name, the ones the config needs but the file lacks too, each with how it is kept. */
|
|
83
|
+
private rows(): { name: string; kind: SecretKind | "missing"; value: string }[] {
|
|
84
|
+
const values = this.file.values();
|
|
85
|
+
const names = [...new Set([...this.file.names(), ...this.needed])].sort();
|
|
86
|
+
return names.map((name) => {
|
|
87
|
+
const value = values[name];
|
|
88
|
+
return value === undefined ? { name, kind: "missing", value: "" } : { name, kind: SecretsFile.kindOf(value), value };
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private passwordLine(): string {
|
|
93
|
+
return `Password of ${this.file.name}: ${this.file.passwordLocation() ?? "typed when Circuit asks"}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Every secret, how it is kept, and the ones the config needs that the file lacks. Values are never shown. */
|
|
97
|
+
list(): void {
|
|
98
|
+
const rows = this.rows();
|
|
99
|
+
|
|
100
|
+
if (!rows.length) console.log(`No secrets yet. Add one with ${SecretsFile.command(this.file.name, "set NAME")}.`);
|
|
101
|
+
else
|
|
102
|
+
console.log(
|
|
103
|
+
Ui.table(
|
|
104
|
+
[{ title: "Name" }, { title: "How it is kept" }],
|
|
105
|
+
rows.map((row) => [
|
|
106
|
+
row.name,
|
|
107
|
+
process.env[row.name] ? Ui.dim("from the environment") : SecretCommands.describe(row.kind, row.value),
|
|
108
|
+
])
|
|
109
|
+
)
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
console.log(`\n${Ui.dim(this.passwordLine())}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Print one value, decrypted. */
|
|
116
|
+
async get(name: string | undefined): Promise<void> {
|
|
117
|
+
const chosen = name ?? Ui.fail("Name the secret, such as circuit secrets get EDGE_01_PASSWORD.");
|
|
118
|
+
if (!(chosen in this.file.values()) || chosen === SecretsFile.PASSWORD) Ui.fail(`${chosen} is not in ${this.file.name}.`);
|
|
119
|
+
|
|
120
|
+
if (SecretsFile.isEncrypted(this.file.values()[chosen]!)) await SecretsPassword.unlock(this.file);
|
|
121
|
+
try {
|
|
122
|
+
process.stdout.write(`${this.file.reveal(chosen)}\n`);
|
|
123
|
+
} catch (error) {
|
|
124
|
+
Ui.fail((error as Error).message);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Set a value, encrypted. A value not given is typed without showing it, so it stays out of the shell's history. */
|
|
129
|
+
async set(name: string | undefined, value: string | undefined): Promise<void> {
|
|
130
|
+
const chosen = SecretCommands.checkName(
|
|
131
|
+
name ?? (Ui.interactive ? await Ui.text("Name", { placeholder: "EDGE_01_PASSWORD" }) : Ui.fail("Name the secret."))
|
|
132
|
+
);
|
|
133
|
+
const given =
|
|
134
|
+
value ??
|
|
135
|
+
(Ui.interactive
|
|
136
|
+
? await Ui.secret(`Value of ${chosen} (an op:// reference keeps it in 1Password)`)
|
|
137
|
+
: Ui.fail("Give the value."));
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
if (!given.startsWith("op://")) await SecretsPassword.ready(this.file);
|
|
141
|
+
this.file.set(chosen, given);
|
|
142
|
+
} catch (error) {
|
|
143
|
+
Ui.fail((error as Error).message);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
Ui.done(given.startsWith("op://") ? `${chosen} points at 1Password.` : `${chosen} is set, encrypted, in ${this.file.name}.`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async delete(name: string | undefined, confirmed: boolean): Promise<void> {
|
|
150
|
+
const chosen = name ?? Ui.fail("Name the secret to delete.");
|
|
151
|
+
if (!(chosen in this.file.values()) || chosen === SecretsFile.PASSWORD) Ui.fail(`${chosen} is not in ${this.file.name}.`);
|
|
152
|
+
|
|
153
|
+
if (!confirmed) {
|
|
154
|
+
if (!Ui.interactive) Ui.fail("Not deleted. Without a person to answer, add --confirm.");
|
|
155
|
+
if (!(await Ui.confirm(`Delete ${chosen} from ${this.file.name}?`))) return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
this.file.remove(chosen);
|
|
159
|
+
Ui.done(`${chosen} is deleted.`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Encrypt every value written in plain text, such as one typed into the file by hand. */
|
|
163
|
+
async encrypt(): Promise<void> {
|
|
164
|
+
if (!this.file.plaintext().length) {
|
|
165
|
+
Ui.done("Every value is already encrypted.");
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
await SecretsPassword.ready(this.file);
|
|
171
|
+
Ui.done(`Encrypted ${this.file.encryptAll().join(", ")}.`);
|
|
172
|
+
} catch (error) {
|
|
173
|
+
Ui.fail((error as Error).message);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Change the password, or say where Circuit reads it in 1Password, or stop reading it there. */
|
|
178
|
+
async password(flags: { onePassword?: string; forgetOnePassword?: boolean; confirm?: boolean }): Promise<void> {
|
|
179
|
+
if (flags.forgetOnePassword) {
|
|
180
|
+
this.file.setPasswordReference(undefined);
|
|
181
|
+
Ui.done(`Circuit no longer reads the password of ${this.file.name} from 1Password. It asks for it instead.`);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (flags.onePassword !== undefined) {
|
|
186
|
+
const reference = flags.onePassword;
|
|
187
|
+
if (!reference.startsWith("op://")) Ui.fail("Give a 1Password reference, op://vault/item/field.");
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
const password = OnePassword.read(reference, "The password");
|
|
191
|
+
if (this.file.hasEncrypted()) this.file.unlock(password, reference);
|
|
192
|
+
this.file.setPasswordReference(reference);
|
|
193
|
+
} catch (error) {
|
|
194
|
+
Ui.fail((error as Error).message);
|
|
195
|
+
}
|
|
196
|
+
Ui.done(`Circuit reads the password of ${this.file.name} from ${reference}. Commit ${this.file.name}.`);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (!this.file.hasEncrypted()) {
|
|
201
|
+
await SecretsPassword.ready(this.file);
|
|
202
|
+
Ui.done(`${this.file.name} has its password. Add a secret with ${SecretsFile.command(this.file.name, "set NAME")}.`);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (!flags.confirm) {
|
|
207
|
+
if (!Ui.interactive) Ui.fail("Not changed. Without a person to answer, add --confirm.");
|
|
208
|
+
Ui.note(
|
|
209
|
+
"Every value is encrypted again with the new password, and the old one stops working.\nEveryone on the team needs the new one, unless it is kept in 1Password.\nThis changes no password on a device: to change one, set it with circuit secrets set, then apply.",
|
|
210
|
+
"Change the password"
|
|
211
|
+
);
|
|
212
|
+
if (!(await Ui.confirm("Change it now?"))) return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
await SecretsPassword.unlock(this.file);
|
|
216
|
+
const reference = this.file.values()[SecretsFile.PASSWORD];
|
|
217
|
+
const next = Ui.interactive
|
|
218
|
+
? await SecretsPassword.choose(this.file, "the new password")
|
|
219
|
+
: process.env.CIRCUIT_SECRETS_NEW_PASSWORD;
|
|
220
|
+
if (!next) Ui.fail("Without a person to answer, give the new password in CIRCUIT_SECRETS_NEW_PASSWORD.");
|
|
221
|
+
|
|
222
|
+
try {
|
|
223
|
+
await Ui.step("Encrypting every value again", async () => this.file.changePassword(next));
|
|
224
|
+
} catch (error) {
|
|
225
|
+
Ui.fail((error as Error).message);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
Ui.done(`Changed. Commit ${this.file.name}.`);
|
|
229
|
+
if (reference) Ui.warn(`Put the new password in 1Password at ${reference} now: it still holds the old one.`);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Every secret, decrypted, in a text editor on the whole screen: change, add or delete lines, Ctrl+S to save, each value encrypted again. Nothing is written to disk in plain text. */
|
|
233
|
+
async edit(): Promise<void> {
|
|
234
|
+
if (!Ui.interactive) Ui.fail(`The editor needs a terminal. Use ${SecretsFile.command(this.file.name, "set NAME")} instead.`);
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
await SecretsPassword.ready(this.file);
|
|
238
|
+
} catch (error) {
|
|
239
|
+
Ui.fail((error as Error).message);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const missing = this.needed.filter((name) => !(name in this.file.values()));
|
|
243
|
+
await TextEditor.open({
|
|
244
|
+
title: `Secrets: ${this.file.name}`,
|
|
245
|
+
text: this.file.editable(),
|
|
246
|
+
...(missing.length ? { note: `The config needs ${missing.join(", ")}: add a line NAME=value for each.` } : {}),
|
|
247
|
+
save: (edited) => {
|
|
248
|
+
try {
|
|
249
|
+
return this.file.saveEditable(edited);
|
|
250
|
+
} catch (error) {
|
|
251
|
+
return (error as Error).message;
|
|
252
|
+
}
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
Ui.done(`${this.file.name}: ${this.file.names().length} secret(s), each encrypted. Commit it.`);
|
|
257
|
+
}
|
|
258
|
+
}
|
package/src/cli/setup.ts
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
// A new project, started by the installer or from the menu: the folder, its files, git and its hook, and the editor's types. Also what Circuit keeps current in a project each time it runs in it.
|
|
2
|
+
|
|
3
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
4
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { join, resolve } from "node:path";
|
|
6
|
+
import { SecretsFile } from "../core/secrets-file";
|
|
7
|
+
import { EditorTypes } from "./editor-types";
|
|
8
|
+
import { GitHooks } from "./git-hooks";
|
|
9
|
+
import { Ui } from "./ui";
|
|
10
|
+
import { Format } from "./format";
|
|
11
|
+
import { VERSION } from "./version";
|
|
12
|
+
import { Versions } from "./versions";
|
|
13
|
+
|
|
14
|
+
export const GENERATED_README = `# Do not edit
|
|
15
|
+
|
|
16
|
+
Circuit writes everything in this folder. Don't edit it by hand: change the project's own files, or use the command named below.
|
|
17
|
+
|
|
18
|
+
- \`version\`: the Circuit version this project runs. \`circuit update\` changes it. (Not in a full install: \`package.json\` pins the version.)
|
|
19
|
+
- \`secrets\`: the project's secrets, each value encrypted, so it is committed. Change them with \`circuit secrets\`.
|
|
20
|
+
- \`types/\`: what your editor reads to complete and check the project's files. Written again on each update. (Not in a full install: the types come from the package in \`node_modules\`.)
|
|
21
|
+
- \`hooks/\`: the git hook that checks each commit: every secret encrypted, and a project that validates.
|
|
22
|
+
- \`cache/\`: temporary, ignored by git.
|
|
23
|
+
`;
|
|
24
|
+
|
|
25
|
+
/** The notice in `.circuit`. */
|
|
26
|
+
export const NOTICE = "DO_NOT_EDIT.md";
|
|
27
|
+
|
|
28
|
+
export const GITIGNORE = `node_modules/
|
|
29
|
+
.circuit/cache/
|
|
30
|
+
`;
|
|
31
|
+
|
|
32
|
+
/** What a new project is set up with. */
|
|
33
|
+
export type ProjectSetup = {
|
|
34
|
+
folder: string;
|
|
35
|
+
/** Simple: the binary alone. Full: Circuit as a package in the project, its version pinned in package.json. */
|
|
36
|
+
kind: "simple" | "full";
|
|
37
|
+
asn: number;
|
|
38
|
+
git: boolean;
|
|
39
|
+
openInEditor: boolean;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export class Setup {
|
|
43
|
+
/** The package a full install adds to the project. */
|
|
44
|
+
static readonly PACKAGE = "@takodotid/circuit";
|
|
45
|
+
|
|
46
|
+
private static installed(command: string): boolean {
|
|
47
|
+
return !spawnSync(command, ["--version"], { encoding: "utf8", shell: process.platform === "win32" }).error;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Circuit installed in the project as a package, by a full install: the file to run it from. */
|
|
51
|
+
static localCircuit(root: string): string | undefined {
|
|
52
|
+
const path = join(root, "node_modules", ...Setup.PACKAGE.split("/"), "src", "cli", "index.ts");
|
|
53
|
+
return existsSync(path) ? path : undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** What Circuit keeps current in a project, each time it runs in it: the editor's types, the git hook, and a warning for a secret in plain text. */
|
|
57
|
+
static prepare(root: string): void {
|
|
58
|
+
const full = Boolean(Setup.pinned(root));
|
|
59
|
+
|
|
60
|
+
// A full install's editor reads the types from the package in node_modules.
|
|
61
|
+
if (!full && !EditorTypes.current(root)) {
|
|
62
|
+
EditorTypes.write(root);
|
|
63
|
+
Ui.done(`Wrote the editor types for Circuit ${VERSION}.`);
|
|
64
|
+
}
|
|
65
|
+
Setup.writeNotice(root);
|
|
66
|
+
|
|
67
|
+
const hooks = GitHooks.install(root);
|
|
68
|
+
if (hooks) Ui.done(`${hooks[0]!.toUpperCase()}${hooks.slice(1)}.`);
|
|
69
|
+
|
|
70
|
+
if (!full && !Versions.pinned(root)) {
|
|
71
|
+
Versions.pin(root, VERSION);
|
|
72
|
+
Ui.done(`Pinned this project to Circuit ${VERSION} in ${Versions.FILE}. Commit it.`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const plain = SecretsFile.at(root).plaintext();
|
|
76
|
+
if (plain.length)
|
|
77
|
+
Ui.warn(`${SecretsFile.DEFAULT} holds ${plain.join(", ")} in plain text. Run circuit secrets encrypt before you commit.`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Ask how to set up a new project, then set it up. */
|
|
81
|
+
static async ask(folder?: string, chosen?: ProjectSetup["kind"]): Promise<ProjectSetup> {
|
|
82
|
+
Ui.intro("A new Circuit project");
|
|
83
|
+
|
|
84
|
+
const name =
|
|
85
|
+
folder ??
|
|
86
|
+
(await Ui.text("Project folder", {
|
|
87
|
+
// An empty folder takes the project itself; elsewhere, a new folder in it.
|
|
88
|
+
initial: readdirSync(process.cwd()).length ? "network" : ".",
|
|
89
|
+
check: (value) => {
|
|
90
|
+
if (!value.trim()) return "Type a folder name.";
|
|
91
|
+
const path = resolve(process.cwd(), value.trim());
|
|
92
|
+
return existsSync(path) && readdirSync(path).length ? "That folder already holds files. Pick another." : undefined;
|
|
93
|
+
},
|
|
94
|
+
}));
|
|
95
|
+
|
|
96
|
+
const kind =
|
|
97
|
+
chosen ??
|
|
98
|
+
(await Ui.select<ProjectSetup["kind"]>("How should this project run Circuit?", [
|
|
99
|
+
{ value: "simple", label: "Simple (recommended)", hint: "the circuit app, its version pinned in .circuit/version" },
|
|
100
|
+
{ value: "full", label: "Full", hint: "Circuit as a package in the project, run with bun circuit" },
|
|
101
|
+
]));
|
|
102
|
+
|
|
103
|
+
if (kind === "full") {
|
|
104
|
+
await Ui.select("Runtime", [
|
|
105
|
+
{ value: "bun", label: "Bun (recommended)" },
|
|
106
|
+
{ value: "node", label: "Node.js and npm", hint: "coming later", disabled: true },
|
|
107
|
+
{ value: "deno", label: "Deno", hint: "coming later", disabled: true },
|
|
108
|
+
]);
|
|
109
|
+
if (!Setup.installed("bun")) await Setup.offerBun();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const asn = Number(
|
|
113
|
+
await Ui.text("Your network's AS number", {
|
|
114
|
+
initial: "64500",
|
|
115
|
+
check: (value) => (/^\d+$/.test(value.trim()) ? undefined : "Type a number, such as 64500. Change it later in circuit.ts."),
|
|
116
|
+
})
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
let git = false;
|
|
120
|
+
if (GitHooks.gitInstalled()) git = await Ui.confirm("Start a git repository, so each change is kept and can be reviewed?", true);
|
|
121
|
+
else Ui.warn("git is not installed, so the project is not a git repository. Install git, then run git init in the folder.");
|
|
122
|
+
|
|
123
|
+
const openInEditor = Setup.installed("code") && (await Ui.confirm("Open the project in VS Code?", true));
|
|
124
|
+
|
|
125
|
+
return { folder: name.trim(), kind, asn, git, openInEditor };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Install Bun with its own installer, when the person agrees. */
|
|
129
|
+
private static async offerBun(): Promise<void> {
|
|
130
|
+
if (!(await Ui.confirm("Bun is not installed. Install it now, with Bun's own installer from bun.sh?", true))) {
|
|
131
|
+
Ui.fail("A full install needs Bun. Install it from https://bun.sh, or pick Simple.");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const windows = process.platform === "win32";
|
|
135
|
+
const result = windows
|
|
136
|
+
? spawnSync("powershell", ["-NoProfile", "-Command", "irm bun.sh/install.ps1 | iex"], { stdio: "inherit" })
|
|
137
|
+
: spawnSync("sh", ["-c", "curl -fsSL https://bun.sh/install | bash"], { stdio: "inherit" });
|
|
138
|
+
if (result.status !== 0) Ui.fail("Bun's installer did not finish. Install Bun from https://bun.sh, then run this again.");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Write the project's files, then git, the package and the editor, as `setup` says. */
|
|
142
|
+
static async create(setup: ProjectSetup): Promise<string> {
|
|
143
|
+
const root = resolve(process.cwd(), setup.folder);
|
|
144
|
+
|
|
145
|
+
await Ui.step("Writing the project", async () => {
|
|
146
|
+
mkdirSync(join(root, "sites"), { recursive: true });
|
|
147
|
+
mkdirSync(join(root, "tenants"), { recursive: true });
|
|
148
|
+
|
|
149
|
+
writeFileSync(
|
|
150
|
+
join(root, "circuit.ts"),
|
|
151
|
+
`// The network: its AS, its sites, and the checks Circuit runs on it. Each device is a file under sites/<site>/.
|
|
152
|
+
|
|
153
|
+
export const as${setup.asn} = circuit({
|
|
154
|
+
asn: ${setup.asn},
|
|
155
|
+
// Each site by its name, such as "jakarta-1": { description: "Jakarta, rack 12" }. Its devices go in sites/jakarta-1/.
|
|
156
|
+
sites: {},
|
|
157
|
+
checks: {
|
|
158
|
+
exports_end_in_reject: true,
|
|
159
|
+
imports_end_in_accept_or_reject: true,
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
`
|
|
163
|
+
);
|
|
164
|
+
writeFileSync(
|
|
165
|
+
join(root, "sites", "README.md"),
|
|
166
|
+
"One folder per site, named as in circuit.ts, with one file per device: sites/<site>/<device>.ts.\n"
|
|
167
|
+
);
|
|
168
|
+
writeFileSync(
|
|
169
|
+
join(root, "tenants", "README.md"),
|
|
170
|
+
"What tenants share with your devices, one file each, merged in `tenants/index.ts`.\n"
|
|
171
|
+
);
|
|
172
|
+
writeFileSync(join(root, ".gitignore"), GITIGNORE);
|
|
173
|
+
writeFileSync(join(root, "tsconfig.json"), setup.kind === "full" ? EditorTypes.TSCONFIG_FULL : EditorTypes.TSCONFIG);
|
|
174
|
+
Setup.writeEditor(root);
|
|
175
|
+
if (setup.kind === "simple") Versions.pin(root, VERSION);
|
|
176
|
+
if (setup.kind === "simple") EditorTypes.write(root);
|
|
177
|
+
Setup.writeNotice(root);
|
|
178
|
+
|
|
179
|
+
if (setup.kind === "full") {
|
|
180
|
+
writeFileSync(
|
|
181
|
+
join(root, "package.json"),
|
|
182
|
+
`${JSON.stringify({ name: setup.folder.toLowerCase().replace(/[^a-z0-9-]+/g, "-"), private: true, devDependencies: {} }, null, 4)}\n`
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
if (setup.kind === "full") {
|
|
188
|
+
await Ui.step(`Adding Circuit ${VERSION} to the project with Bun`, async () => {
|
|
189
|
+
const result = spawnSync("bun", ["add", "--dev", "--exact", `${Setup.PACKAGE}@${VERSION}`], {
|
|
190
|
+
cwd: root,
|
|
191
|
+
encoding: "utf8",
|
|
192
|
+
shell: process.platform === "win32",
|
|
193
|
+
});
|
|
194
|
+
if (result.status !== 0) throw new Error(`bun add failed: ${(result.stderr || result.stdout).trim()}`);
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (setup.git) {
|
|
199
|
+
await Ui.step("Starting the git repository", async () => {
|
|
200
|
+
GitHooks.init(root);
|
|
201
|
+
GitHooks.install(root);
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (setup.openInEditor) spawn("code", [root], { detached: true, stdio: "ignore", shell: process.platform === "win32" }).unref();
|
|
206
|
+
|
|
207
|
+
return root;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** The steps after a new project, for the person to follow. */
|
|
211
|
+
static nextSteps(root: string, setup: ProjectSetup): string {
|
|
212
|
+
const run = setup.kind === "full" ? "bun circuit" : "circuit";
|
|
213
|
+
return [
|
|
214
|
+
...(setup.folder === "." ? [] : [`cd ${setup.folder}`]),
|
|
215
|
+
"Add a site in circuit.ts, then a device in sites/<site>/<device>.ts. Your editor completes each field.",
|
|
216
|
+
`${run} secrets your secrets, such as a device password, each encrypted`,
|
|
217
|
+
`${run} validate check the project`,
|
|
218
|
+
`${run} diff <device> what would change on the device`,
|
|
219
|
+
`${run} apply <device> make the device match`,
|
|
220
|
+
"",
|
|
221
|
+
`Read more at https://circuit.tako.id. Project: ${root}`,
|
|
222
|
+
].join("\n");
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** The whole of a new project: ask, create, and say what is next. */
|
|
226
|
+
static async run(folder: string | undefined, options: { existing: "ready" | "refuse"; kind?: ProjectSetup["kind"] }): Promise<void> {
|
|
227
|
+
// In a project already, such as a fresh clone: set it up as it is, with no questions.
|
|
228
|
+
const here = resolve(process.cwd(), folder ?? ".");
|
|
229
|
+
if (existsSync(join(here, "circuit.ts"))) {
|
|
230
|
+
if (options.existing === "refuse") Ui.fail(`${here} holds a project already. Pick another folder.`);
|
|
231
|
+
await Setup.ready(here);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (!Ui.interactive) Ui.fail("Starting a project asks a few questions, so it needs a terminal.");
|
|
236
|
+
|
|
237
|
+
const setup = await Setup.ask(folder, options.kind);
|
|
238
|
+
const root = await Setup.create(setup);
|
|
239
|
+
|
|
240
|
+
Ui.note(Setup.nextSteps(root, setup), "Next");
|
|
241
|
+
Ui.outro("The project is ready.");
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Get a project Circuit did not start here ready: its editor types, its git hook, and, in a full install, its packages. */
|
|
245
|
+
static async ready(root: string): Promise<void> {
|
|
246
|
+
Setup.prepare(root);
|
|
247
|
+
|
|
248
|
+
if (Setup.pinned(root)) {
|
|
249
|
+
await Ui.step("Installing the project's packages with Bun", async () => {
|
|
250
|
+
const result = spawnSync("bun", ["install"], { cwd: root, encoding: "utf8", shell: process.platform === "win32" });
|
|
251
|
+
if (result.status !== 0) throw new Error(`bun install failed: ${(result.stderr || result.stdout).trim()}`);
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
Ui.done(`${root} is ready. Run circuit in it.`);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** `.circuit/DO_NOT_EDIT.md`, in place of the README an older Circuit wrote there. */
|
|
259
|
+
static writeNotice(root: string): void {
|
|
260
|
+
mkdirSync(join(root, ".circuit"), { recursive: true });
|
|
261
|
+
const path = join(root, ".circuit", NOTICE);
|
|
262
|
+
if (!existsSync(path) || readFileSync(path, "utf8") !== GENERATED_README) writeFileSync(path, GENERATED_README);
|
|
263
|
+
rmSync(join(root, ".circuit", "README.md"), { force: true });
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** The editor's settings: the style in `.editorconfig`, which the Prettier extension and `circuit fmt` both read, and format on save in VS Code. A file the project has already is kept. */
|
|
267
|
+
static writeEditor(root: string): void {
|
|
268
|
+
const json = (value: unknown) => `${JSON.stringify(value, null, 4)}\n`;
|
|
269
|
+
const files: Record<string, string> = {
|
|
270
|
+
".editorconfig": Format.EDITORCONFIG,
|
|
271
|
+
".vscode/settings.json": json({
|
|
272
|
+
"editor.formatOnSave": true,
|
|
273
|
+
"prettier.useEditorConfig": true,
|
|
274
|
+
"[typescript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" },
|
|
275
|
+
"[json]": { "editor.defaultFormatter": "esbenp.prettier-vscode" },
|
|
276
|
+
"files.eol": "\n",
|
|
277
|
+
}),
|
|
278
|
+
".vscode/extensions.json": json({ recommendations: ["esbenp.prettier-vscode"] }),
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
for (const [file, text] of Object.entries(files)) {
|
|
282
|
+
const path = join(root, file);
|
|
283
|
+
if (existsSync(path)) continue;
|
|
284
|
+
mkdirSync(join(path, ".."), { recursive: true });
|
|
285
|
+
writeFileSync(path, text);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** The version pinned in a full install's package.json, if the project has one. */
|
|
290
|
+
static pinned(root: string): string | undefined {
|
|
291
|
+
const path = join(root, "package.json");
|
|
292
|
+
if (!existsSync(path)) return undefined;
|
|
293
|
+
|
|
294
|
+
const manifest = JSON.parse(readFileSync(path, "utf8")) as {
|
|
295
|
+
devDependencies?: Record<string, string>;
|
|
296
|
+
dependencies?: Record<string, string>;
|
|
297
|
+
};
|
|
298
|
+
return manifest.devDependencies?.[Setup.PACKAGE] ?? manifest.dependencies?.[Setup.PACKAGE];
|
|
299
|
+
}
|
|
300
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Signed releases. The release workflow signs each release's SHA256SUMS with Circuit's release key (Ed25519), kept as the repository secret RELEASE_SIGNING_KEY, and attaches the signature, SHA256SUMS.sig. Circuit downloads a binary only when SHA256SUMS carries a signature by one of the keys below and the binary matches it, so a release changed by anyone without the key is refused. A new key is added here, beside the old one, before releases are signed with it.
|
|
2
|
+
|
|
3
|
+
import { createPublicKey, verify } from "node:crypto";
|
|
4
|
+
|
|
5
|
+
export class Signing {
|
|
6
|
+
/** The public keys a release may be signed with. */
|
|
7
|
+
static readonly KEYS: readonly string[] = [
|
|
8
|
+
// Made 2026-11-03.
|
|
9
|
+
"-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAEVi8tuMkrBMhyI241mBUtTQO7ndoVRjG7LBMQbnOyEw=\n-----END PUBLIC KEY-----\n",
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
/** Whether `signature`, base64, is one of the release keys' over `data`. */
|
|
13
|
+
static verify(data: Buffer, signature: string, keys: readonly string[] = Signing.KEYS): boolean {
|
|
14
|
+
const bytes = Buffer.from(signature.trim(), "base64");
|
|
15
|
+
return keys.some((key) => {
|
|
16
|
+
try {
|
|
17
|
+
return verify(null, data, createPublicKey(key), bytes);
|
|
18
|
+
} catch {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
}
|