reposets 0.4.2 → 1.0.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 +87 -75
- package/bin/reposets.js +68 -17
- package/cli/commands/credentials.js +170 -49
- package/cli/commands/doctor.js +364 -91
- package/cli/commands/drift.js +48 -0
- package/cli/commands/history.js +203 -0
- package/cli/commands/init.js +110 -104
- package/cli/commands/list.js +60 -39
- package/cli/commands/nuke.js +127 -0
- package/cli/commands/sync.js +219 -59
- package/cli/commands/validate.js +57 -41
- package/cli/flags.js +36 -0
- package/cli/logger.js +48 -0
- package/index.d.ts +471 -1499
- package/index.js +3 -18
- package/lib/config-refs.js +76 -0
- package/lib/credential-labels.js +0 -0
- package/lib/fingerprint.js +52 -0
- package/lib/org-only.js +61 -0
- package/lib/schema-issues.js +50 -0
- package/package.json +11 -10
- package/schemas/annotations.js +81 -0
- package/schemas/common.js +83 -48
- package/schemas/config.js +214 -212
- package/schemas/credentials.js +190 -54
- package/schemas/environment.js +27 -21
- package/schemas/ruleset.js +235 -139
- package/services/ConfigFiles.js +126 -104
- package/services/CredentialResolver.js +97 -33
- package/services/OnePasswordClient.js +88 -16
- package/services/SyncLogger.js +107 -76
- package/store/AppliedState.js +0 -0
- package/store/RepoCache.js +86 -0
- package/store/SyncJournal.js +92 -0
- package/store/migrations.js +86 -0
- package/sync/SyncEngine.js +156 -0
- package/sync/decide.js +56 -0
- package/sync/phase.js +55 -0
- package/sync/phases/cleanup.js +220 -0
- package/sync/phases/code-scanning.js +187 -0
- package/sync/phases/environments.js +106 -0
- package/sync/phases/index.js +39 -0
- package/sync/phases/resource.js +149 -0
- package/sync/phases/rulesets.js +186 -0
- package/sync/phases/secrets.js +138 -0
- package/sync/phases/security.js +129 -0
- package/sync/phases/settings.js +274 -0
- package/sync/phases/variables.js +132 -0
- package/tsdoc-metadata.json +1 -1
- package/bin/reposets.d.ts +0 -1
- package/errors.js +0 -12
- package/lib/crypto.js +0 -27
- package/services/GitHubClient.js +0 -875
- package/services/SyncEngine.js +0 -580
package/index.js
CHANGED
|
@@ -1,19 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { CleanupSchema, CleanupScopeSchema, SecretGroupSchema, VariableGroupSchema } from "./schemas/common.js";
|
|
4
|
-
import { BypassActorSchema, ResolvedRefSchema, RulesetSchema, buildRulesetPayload } from "./schemas/ruleset.js";
|
|
5
|
-
import { ConfigSchema, GroupSchema, LogLevelSchema } from "./schemas/config.js";
|
|
6
|
-
import { CredentialProfileSchema, CredentialsSchema, ResolveSectionSchema } from "./schemas/credentials.js";
|
|
7
|
-
import { CONFIG_FILENAME, CREDENTIALS_FILENAME, ConfigFilesLive, ReposetsConfigFile, ReposetsCredentialsFile, makeConfigFilesLive, validateConfigRefs } from "./services/ConfigFiles.js";
|
|
8
|
-
import { OnePasswordClient, OnePasswordClientLive, OnePasswordClientTest } from "./services/OnePasswordClient.js";
|
|
9
|
-
import { CredentialResolver, CredentialResolverLive } from "./services/CredentialResolver.js";
|
|
10
|
-
import { GitHubClient, GitHubClientLive, GitHubClientTest } from "./services/GitHubClient.js";
|
|
11
|
-
import { SyncLogger, SyncLoggerLive } from "./services/SyncLogger.js";
|
|
12
|
-
import { SyncEngine, SyncEngineLive } from "./services/SyncEngine.js";
|
|
13
|
-
import { AppDirs, ConfigError as XdgConfigError, ConfigFile } from "xdg-effect";
|
|
1
|
+
import { ConfigSchema } from "./schemas/config.js";
|
|
2
|
+
import { CONFIG_FILENAME, ConfigFlagNotFound, ReposetsConfigFile, makeConfigFilesLive } from "./services/ConfigFiles.js";
|
|
14
3
|
|
|
15
|
-
|
|
16
|
-
/* v8 ignore stop */
|
|
17
|
-
|
|
18
|
-
//#endregion
|
|
19
|
-
export { AppDirs, BypassActorSchema, CONFIG_FILENAME, CREDENTIALS_FILENAME, CleanupSchema, CleanupScopeSchema, ConfigFile, ConfigFilesLive, ConfigSchema, CredentialProfileSchema, CredentialResolver, CredentialResolverLive, CredentialsSchema, GitHubApiError, GitHubClient, GitHubClientLive, GitHubClientTest, GroupSchema, LogLevelSchema, OnePasswordClient, OnePasswordClientLive, OnePasswordClientTest, OnePasswordError, ReposetsConfigFile, ReposetsCredentialsFile, ResolveError, ResolveSectionSchema, ResolvedRefSchema, RulesetSchema, SecretGroupSchema, SyncEngine, SyncEngineLive, SyncError, SyncLogger, SyncLoggerLive, VariableGroupSchema, XdgConfigError, buildRulesetPayload, encryptSecret, makeConfigFilesLive, validateConfigRefs };
|
|
4
|
+
export { CONFIG_FILENAME, ConfigFlagNotFound, ConfigSchema, ReposetsConfigFile, makeConfigFilesLive };
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
//#region src/lib/config-refs.ts
|
|
2
|
+
/**
|
|
3
|
+
* Every reference in `[groups.*]` that points at a section which is not defined.
|
|
4
|
+
*
|
|
5
|
+
* @remarks
|
|
6
|
+
* **This was lost in the v4 rebuild and is restored here.** v3 registered a
|
|
7
|
+
* `validateConfigRefs` callback on the config spec, so a dangling reference
|
|
8
|
+
* failed the load for every command. The rebuild dropped it and nothing took
|
|
9
|
+
* its place — while every phase kept skipping unresolvable references silently
|
|
10
|
+
* and *citing that callback by name* as the thing that owned the check.
|
|
11
|
+
*
|
|
12
|
+
* The result was the worst shape available: `settings = ["defualt"]` synced
|
|
13
|
+
* nothing, reported nothing, and `validate` printed `Valid:` and exited 0. The
|
|
14
|
+
* same failure as a misspelled `--repo` passing green, which is fixed
|
|
15
|
+
* elsewhere in this codebase for exactly the reason it matters here — a filter
|
|
16
|
+
* or a reference that silently matches nothing turns a destructive command into
|
|
17
|
+
* a no-op that looks like success.
|
|
18
|
+
*
|
|
19
|
+
* Checked where the reference is **used**, not where a section is defined: an
|
|
20
|
+
* unreferenced `[settings.*]` section is dead config, not an error, and a
|
|
21
|
+
* config may legitimately define sections some groups do not take.
|
|
22
|
+
*
|
|
23
|
+
* `defined` is carried on each finding so the report can show the near-misses.
|
|
24
|
+
* Nearly every one of these is a typo, and a typo is fixed by seeing the
|
|
25
|
+
* correct spelling rather than by being told the wrong one is wrong.
|
|
26
|
+
*
|
|
27
|
+
* @public
|
|
28
|
+
*/
|
|
29
|
+
const danglingReferences = (config) => {
|
|
30
|
+
const found = [];
|
|
31
|
+
const sections = {
|
|
32
|
+
settings: Object.keys(config.settings),
|
|
33
|
+
rulesets: Object.keys(config.rulesets),
|
|
34
|
+
environments: Object.keys(config.environments ?? {}),
|
|
35
|
+
security: Object.keys(config.security ?? {}),
|
|
36
|
+
code_scanning: Object.keys(config.code_scanning ?? {}),
|
|
37
|
+
secrets: Object.keys(config.secrets),
|
|
38
|
+
variables: Object.keys(config.variables)
|
|
39
|
+
};
|
|
40
|
+
for (const [groupName, group] of Object.entries(config.groups)) {
|
|
41
|
+
const check = (where, names, defined) => {
|
|
42
|
+
for (const name of names) {
|
|
43
|
+
if (defined.includes(name)) continue;
|
|
44
|
+
found.push({
|
|
45
|
+
group: groupName,
|
|
46
|
+
where,
|
|
47
|
+
name,
|
|
48
|
+
defined
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
check(`groups.${groupName}.settings`, group.settings ?? [], sections.settings);
|
|
53
|
+
check(`groups.${groupName}.rulesets`, group.rulesets ?? [], sections.rulesets);
|
|
54
|
+
check(`groups.${groupName}.environments`, group.environments ?? [], sections.environments);
|
|
55
|
+
check(`groups.${groupName}.security`, group.security ?? [], sections.security);
|
|
56
|
+
check(`groups.${groupName}.code_scanning`, group.code_scanning ?? [], sections.code_scanning);
|
|
57
|
+
for (const scope of [
|
|
58
|
+
"actions",
|
|
59
|
+
"dependabot",
|
|
60
|
+
"codespaces"
|
|
61
|
+
]) check(`groups.${groupName}.secrets.${scope}`, group.secrets?.[scope] ?? [], sections.secrets);
|
|
62
|
+
check(`groups.${groupName}.variables.actions`, group.variables?.actions ?? [], sections.variables);
|
|
63
|
+
for (const [environment, names] of Object.entries(group.secrets?.environments ?? {})) {
|
|
64
|
+
check(`groups.${groupName}.secrets.environments`, [environment], sections.environments);
|
|
65
|
+
check(`groups.${groupName}.secrets.environments.${environment}`, names, sections.secrets);
|
|
66
|
+
}
|
|
67
|
+
for (const [environment, names] of Object.entries(group.variables?.environments ?? {})) {
|
|
68
|
+
check(`groups.${groupName}.variables.environments`, [environment], sections.environments);
|
|
69
|
+
check(`groups.${groupName}.variables.environments.${environment}`, names, sections.variables);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return found;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
//#endregion
|
|
76
|
+
export { danglingReferences };
|
|
Binary file
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import blakejs from "blakejs";
|
|
2
|
+
|
|
3
|
+
//#region src/lib/fingerprint.ts
|
|
4
|
+
const { blake2bHex } = blakejs;
|
|
5
|
+
/**
|
|
6
|
+
* Serializes a value to JSON with object keys sorted at every depth.
|
|
7
|
+
*
|
|
8
|
+
* @remarks
|
|
9
|
+
* Two values that differ only in key order must hash identically, or every
|
|
10
|
+
* config reordering would read as drift. `JSON.stringify` preserves insertion
|
|
11
|
+
* order, so the sort has to be explicit.
|
|
12
|
+
*
|
|
13
|
+
* Arrays keep their order — element order is meaningful in this config (a
|
|
14
|
+
* ruleset's `rules`, an environment's reviewers), so reordering one is a real
|
|
15
|
+
* change and should register as drift.
|
|
16
|
+
*
|
|
17
|
+
* `undefined` is dropped from objects, matching `JSON.stringify`, so an absent
|
|
18
|
+
* key and an explicitly-undefined key fingerprint the same. That is correct
|
|
19
|
+
* here: both mean "not configured".
|
|
20
|
+
*/
|
|
21
|
+
const canonical = (value) => {
|
|
22
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
|
|
23
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
|
|
24
|
+
return `{${Object.entries(value).filter(([, v]) => v !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k, v]) => `${JSON.stringify(k)}:${canonical(v)}`).join(",")}}`;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* The digest length, in bytes. 32 gives a 64-character hex string.
|
|
28
|
+
*/
|
|
29
|
+
const DIGEST_BYTES = 32;
|
|
30
|
+
/**
|
|
31
|
+
* Fingerprints the resolved desired state of one resource.
|
|
32
|
+
*
|
|
33
|
+
* @remarks
|
|
34
|
+
* Pure, and deliberately not an `Effect` — it performs no I/O and cannot fail
|
|
35
|
+
* for any input reposets produces. The argument is always already-decoded config
|
|
36
|
+
* data, which by construction is acyclic (it came from TOML).
|
|
37
|
+
*
|
|
38
|
+
* The fingerprint records what reposets last *pushed*, so a later run can tell
|
|
39
|
+
* "the config changed" from "someone changed this in the GitHub UI".
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* ```ts
|
|
43
|
+
* fingerprint({ b: 1, a: 2 }) === fingerprint({ a: 2, b: 1 }) // key order is not drift
|
|
44
|
+
* fingerprint([1, 2]) !== fingerprint([2, 1]) // element order is
|
|
45
|
+
* ```
|
|
46
|
+
*
|
|
47
|
+
* @public
|
|
48
|
+
*/
|
|
49
|
+
const fingerprint = (value) => blake2bHex(canonical(value), void 0, DIGEST_BYTES);
|
|
50
|
+
|
|
51
|
+
//#endregion
|
|
52
|
+
export { fingerprint };
|
package/lib/org-only.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { profileOwner } from "../schemas/credentials.js";
|
|
2
|
+
|
|
3
|
+
//#region src/lib/org-only.ts
|
|
4
|
+
/**
|
|
5
|
+
* Organization-only constructs assigned to personal-account groups.
|
|
6
|
+
*
|
|
7
|
+
* @remarks
|
|
8
|
+
* **The whole point of declaring `username` or `org` on a profile.** Every check
|
|
9
|
+
* here previously needed the owner's type, and the only way to learn it was to
|
|
10
|
+
* ask GitHub — so `validate`, whose entire contract is that it touches no
|
|
11
|
+
* network, could not make them at all. They surfaced as a 422 mid-sync instead,
|
|
12
|
+
* after earlier repositories had already been written.
|
|
13
|
+
*
|
|
14
|
+
* Three constructs need an organization, and each is checked where it is
|
|
15
|
+
* *referenced* rather than where it is defined: a ruleset with a team bypass
|
|
16
|
+
* actor is perfectly valid sitting in `[rulesets.*]`, and only becomes an error
|
|
17
|
+
* when a personal-account group assigns it. Reporting the definition would name
|
|
18
|
+
* a section the user may share between groups and would have to leave alone.
|
|
19
|
+
*
|
|
20
|
+
* This does not replace the check against GitHub. A declaration can be wrong,
|
|
21
|
+
* so `sync` still verifies the owner's type before writing; this catches the
|
|
22
|
+
* config error earlier and without a token.
|
|
23
|
+
*
|
|
24
|
+
* @public
|
|
25
|
+
*/
|
|
26
|
+
const orgOnlyViolations = (config, credentials) => {
|
|
27
|
+
const violations = [];
|
|
28
|
+
for (const [groupName, group] of Object.entries(config.groups)) {
|
|
29
|
+
const profile = credentials.profiles[group.credentials];
|
|
30
|
+
if (profile === void 0) continue;
|
|
31
|
+
if (profileOwner(profile).ownerType !== "User") continue;
|
|
32
|
+
const add = (where, detail) => {
|
|
33
|
+
violations.push({
|
|
34
|
+
group: groupName,
|
|
35
|
+
profile: group.credentials,
|
|
36
|
+
where,
|
|
37
|
+
detail
|
|
38
|
+
});
|
|
39
|
+
};
|
|
40
|
+
for (const name of group.settings ?? []) {
|
|
41
|
+
const settings = config.settings[name];
|
|
42
|
+
if (settings === void 0) continue;
|
|
43
|
+
if (settings.secret_scanning_delegated_bypass !== void 0) add(`settings.${name}.secret_scanning_delegated_bypass`, "delegated bypass requires an organization");
|
|
44
|
+
if (settings.delegated_bypass_reviewers !== void 0) add(`settings.${name}.delegated_bypass_reviewers`, "bypass reviewers require an organization");
|
|
45
|
+
}
|
|
46
|
+
for (const name of group.rulesets ?? []) {
|
|
47
|
+
const ruleset = config.rulesets[name];
|
|
48
|
+
if (ruleset === void 0) continue;
|
|
49
|
+
for (const actor of ruleset.bypass_actors ?? []) if (actor.actor_type === "Team") add(`rulesets.${name}.bypass_actors`, "a Team bypass actor requires an organization");
|
|
50
|
+
}
|
|
51
|
+
for (const name of group.environments ?? []) {
|
|
52
|
+
const environment = config.environments?.[name];
|
|
53
|
+
if (environment === void 0) continue;
|
|
54
|
+
for (const reviewer of environment.reviewers ?? []) if (reviewer.type === "Team") add(`environments.${name}.reviewers`, "a Team reviewer requires an organization");
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return violations;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
//#endregion
|
|
61
|
+
export { orgOnlyViolations };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { SchemaIssue } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region src/lib/schema-issues.ts
|
|
4
|
+
/**
|
|
5
|
+
* Core's structured formatter: one entry per leaf, each with its property path.
|
|
6
|
+
*
|
|
7
|
+
* @remarks
|
|
8
|
+
* Built once. It is a pure function of the issue tree, and `Formatter` carries
|
|
9
|
+
* no state.
|
|
10
|
+
*/
|
|
11
|
+
const formatter = SchemaIssue.makeFormatterStandardSchemaV1({ leafHook: (issue) => issue._tag === "UnexpectedKey" ? "unknown key" : SchemaIssue.defaultLeafHook(issue) });
|
|
12
|
+
/**
|
|
13
|
+
* Render a `ConfigValidationError`'s `issue` as one line per rejected value.
|
|
14
|
+
*
|
|
15
|
+
* @remarks
|
|
16
|
+
* **No walker of our own.** Core already ships one:
|
|
17
|
+
* `SchemaIssue.makeFormatterStandardSchemaV1()` flattens the tree to
|
|
18
|
+
* `{ message, path }` entries, which is exactly the shape this needs.
|
|
19
|
+
* `defaultLeafHook` handles every leaf variant — `UnexpectedKey`,
|
|
20
|
+
* `InvalidType`, `MissingKey`, `Forbidden`, `OneOf` — so a wrong *type* renders
|
|
21
|
+
* as sensibly as an unknown key, and a variant added upstream is covered
|
|
22
|
+
* without a change here. Hand-rolling this against the excess-property shape
|
|
23
|
+
* alone would have got every other cause wrong.
|
|
24
|
+
*
|
|
25
|
+
* `issue` is declared `Schema.Defect`, so it arrives as `unknown`; the
|
|
26
|
+
* `isIssue` guard is what makes reading it safe. Anything that is not an issue
|
|
27
|
+
* tree yields no lines rather than a crash — a rendering helper on an error
|
|
28
|
+
* path must never become the reason a command dies.
|
|
29
|
+
*
|
|
30
|
+
* Nodes are never stringified. Each carries the entire AST inline, annotations
|
|
31
|
+
* included, so `String(node)` would dump the schema rather than describe the
|
|
32
|
+
* failure. Only `message` and `path` are read.
|
|
33
|
+
*
|
|
34
|
+
* @param issue - the `issue` field of a `ConfigValidationError`, or any value
|
|
35
|
+
* @returns one line per rejected value, deepest path last; empty if `issue` is
|
|
36
|
+
* not an issue tree
|
|
37
|
+
*
|
|
38
|
+
* @public
|
|
39
|
+
*/
|
|
40
|
+
const formatSchemaIssue = (issue) => {
|
|
41
|
+
if (!SchemaIssue.isIssue(issue)) return [];
|
|
42
|
+
const lines = formatter(issue).issues.map((entry) => {
|
|
43
|
+
const path = (entry.path ?? []).map(String).join(".");
|
|
44
|
+
return path === "" ? entry.message : `${entry.message} at ${path}`;
|
|
45
|
+
});
|
|
46
|
+
return [...new Set(lines)];
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
//#endregion
|
|
50
|
+
export { formatSchemaIssue };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "reposets",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "CLI tool to sync GitHub repo settings, secrets and rulesets across personal repositories",
|
|
6
6
|
"keywords": [
|
|
@@ -38,7 +38,8 @@
|
|
|
38
38
|
"exports": {
|
|
39
39
|
".": {
|
|
40
40
|
"types": "./index.d.ts",
|
|
41
|
-
"import": "./index.js"
|
|
41
|
+
"import": "./index.js",
|
|
42
|
+
"default": "./index.js"
|
|
42
43
|
},
|
|
43
44
|
"./package.json": "./package.json"
|
|
44
45
|
},
|
|
@@ -47,15 +48,15 @@
|
|
|
47
48
|
},
|
|
48
49
|
"dependencies": {
|
|
49
50
|
"@1password/sdk": "^0.4.0",
|
|
50
|
-
"@effect/
|
|
51
|
-
"@
|
|
52
|
-
"@
|
|
53
|
-
"@
|
|
51
|
+
"@effect/platform-node": "4.0.0-beta.107",
|
|
52
|
+
"@effected/app": "^0.10.0",
|
|
53
|
+
"@effected/config-file": "^0.4.0",
|
|
54
|
+
"@effected/github": "^0.4.0",
|
|
55
|
+
"@effected/store": "^0.3.0",
|
|
56
|
+
"@effected/toml": "^0.4.0",
|
|
57
|
+
"@effected/xdg": "^0.2.1",
|
|
54
58
|
"blakejs": "^1.2.1",
|
|
55
|
-
"effect": "
|
|
56
|
-
"smol-toml": ">=1.6.1",
|
|
57
|
-
"tweetnacl": "^1.0.3",
|
|
58
|
-
"xdg-effect": "^1.0.3"
|
|
59
|
+
"effect": "4.0.0-beta.107"
|
|
59
60
|
},
|
|
60
61
|
"engines": {
|
|
61
62
|
"node": ">=24.11.0"
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
//#region src/schemas/annotations.ts
|
|
2
|
+
/**
|
|
3
|
+
* Build the `x-taplo` annotation key from typed options.
|
|
4
|
+
*
|
|
5
|
+
* @remarks
|
|
6
|
+
* The v3 tree imported this from `xdg-effect`, which re-exported it from
|
|
7
|
+
* `json-schema-effect`. Both are gone and `@effected/schemastore` ships no
|
|
8
|
+
* replacement builder — what it ships is the other half: `KeywordFamilies`
|
|
9
|
+
* declares `x-taplo*` and `x-tombi-*` as carried families, and
|
|
10
|
+
* `StoreDocument.fromSchema` wires them into core's `includeAnnotationKey`
|
|
11
|
+
* predicate so they survive the Draft-07 lowering. So the mechanism is intact
|
|
12
|
+
* and only the two constructors needed rebuilding; they live here rather than
|
|
13
|
+
* being inlined at ~30 call sites, which is where the exact key spellings would
|
|
14
|
+
* otherwise drift.
|
|
15
|
+
*
|
|
16
|
+
* The other half of the change is where the result goes. v3 nested it under a
|
|
17
|
+
* `jsonSchema` annotation; v4 has no such annotation, and non-standard keys are
|
|
18
|
+
* written at the top level of `.annotate({ ... })` instead. Spread the result
|
|
19
|
+
* in: `.annotate({ ...taplo({ ... }), title: "..." })`.
|
|
20
|
+
*
|
|
21
|
+
* Taplo ignores `x-taplo` on a node that also carries `$ref`. That is a Taplo
|
|
22
|
+
* limitation and the helper does not work around it — the v3 helper did not
|
|
23
|
+
* either.
|
|
24
|
+
*
|
|
25
|
+
* @public
|
|
26
|
+
*/
|
|
27
|
+
const taplo = (options) => {
|
|
28
|
+
const out = {};
|
|
29
|
+
if (options.hidden !== void 0) out.hidden = options.hidden;
|
|
30
|
+
if (options.docs !== void 0) out.docs = options.docs;
|
|
31
|
+
if (options.links !== void 0) out.links = options.links;
|
|
32
|
+
if (options.initKeys !== void 0) out.initKeys = options.initKeys;
|
|
33
|
+
for (const [key, value] of Object.entries(options.custom ?? {})) out[key] = value;
|
|
34
|
+
return { "x-taplo": out };
|
|
35
|
+
};
|
|
36
|
+
/** The `TombiOptions` field → `x-tombi-*` key mapping, in the v3 helper's order. */
|
|
37
|
+
const tombiKeys = [
|
|
38
|
+
["additionalKeyLabel", "x-tombi-additional-key-label"],
|
|
39
|
+
["tableKeysOrder", "x-tombi-table-keys-order"],
|
|
40
|
+
["arrayValuesOrder", "x-tombi-array-values-order"],
|
|
41
|
+
["arrayValuesOrderBy", "x-tombi-array-values-order-by"],
|
|
42
|
+
["stringFormats", "x-tombi-string-formats"],
|
|
43
|
+
["tomlVersion", "x-tombi-toml-version"]
|
|
44
|
+
];
|
|
45
|
+
/**
|
|
46
|
+
* Build the `x-tombi-*` annotation keys from typed options.
|
|
47
|
+
*
|
|
48
|
+
* @remarks
|
|
49
|
+
* See {@link taplo} for why these helpers are local. Unlike `x-taplo`, tombi's
|
|
50
|
+
* options are separate top-level keys rather than members of one object, so the
|
|
51
|
+
* result usually spreads alongside other annotations:
|
|
52
|
+
* `.annotate({ ...tombi({ tableKeysOrder: "schema" }), title: "..." })`.
|
|
53
|
+
*
|
|
54
|
+
* @public
|
|
55
|
+
*/
|
|
56
|
+
const tombi = (options) => {
|
|
57
|
+
const out = {};
|
|
58
|
+
for (const [field, key] of tombiKeys) {
|
|
59
|
+
const value = options[field];
|
|
60
|
+
if (value !== void 0) out[key] = value;
|
|
61
|
+
}
|
|
62
|
+
for (const [key, value] of Object.entries(options.custom ?? {})) out[key] = value;
|
|
63
|
+
return out;
|
|
64
|
+
};
|
|
65
|
+
/**
|
|
66
|
+
* The URL of a page under the repository's `docs/`, for a `links.key`
|
|
67
|
+
* annotation.
|
|
68
|
+
*
|
|
69
|
+
* @remarks
|
|
70
|
+
* Every v3 call site spelled the same prefix by hand. Collapsing it here keeps
|
|
71
|
+
* the annotations from drifting apart when the docs move. It returns the URL
|
|
72
|
+
* rather than a finished `x-taplo` object on purpose: several call sites pass
|
|
73
|
+
* `initKeys` in the same `taplo()` call, and two separate `taplo()` results
|
|
74
|
+
* spread into one `annotate` would silently overwrite each other's `x-taplo`.
|
|
75
|
+
*
|
|
76
|
+
* @public
|
|
77
|
+
*/
|
|
78
|
+
const docs = (page) => `https://github.com/spencerbeggs/reposets/blob/main/docs/${page}`;
|
|
79
|
+
|
|
80
|
+
//#endregion
|
|
81
|
+
export { docs, taplo, tombi };
|
package/schemas/common.js
CHANGED
|
@@ -1,129 +1,164 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { Schema } from "effect";
|
|
1
|
+
import { docs, taplo, tombi } from "./annotations.js";
|
|
2
|
+
import { Effect, Schema } from "effect";
|
|
3
3
|
|
|
4
4
|
//#region src/schemas/common.ts
|
|
5
|
-
const ResourceFileKind = Schema.Struct({ file: Schema.Record({
|
|
6
|
-
|
|
7
|
-
value: Schema.String
|
|
8
|
-
}).annotations({
|
|
5
|
+
const ResourceFileKind = Schema.Struct({ file: Schema.Record(Schema.String, Schema.String).annotate({
|
|
6
|
+
...tombi({ additionalKeyLabel: "name" }),
|
|
9
7
|
title: "File entries",
|
|
10
|
-
description: "Named entries with file path values, resolved relative to config directory"
|
|
11
|
-
jsonSchema: tombi({ additionalKeyLabel: "name" })
|
|
8
|
+
description: "Named entries with file path values, resolved relative to config directory"
|
|
12
9
|
}) });
|
|
13
|
-
const ResourceValueKind = Schema.Struct({ value: Schema.Record({
|
|
14
|
-
|
|
15
|
-
value: Schema.Union(Schema.String, Schema.Record({
|
|
16
|
-
key: Schema.String,
|
|
17
|
-
value: Jsonifiable
|
|
18
|
-
}))
|
|
19
|
-
}).annotations({
|
|
10
|
+
const ResourceValueKind = Schema.Struct({ value: Schema.Record(Schema.String, Schema.Union([Schema.String, Schema.Record(Schema.String, Schema.Json)])).annotate({
|
|
11
|
+
...tombi({ additionalKeyLabel: "name" }),
|
|
20
12
|
title: "Value entries",
|
|
21
|
-
description: "Named entries with inline values. Strings used as-is, objects JSON-stringified."
|
|
22
|
-
jsonSchema: tombi({ additionalKeyLabel: "name" })
|
|
13
|
+
description: "Named entries with inline values. Strings used as-is, objects JSON-stringified."
|
|
23
14
|
}) });
|
|
24
|
-
const ResourceResolvedKind = Schema.Struct({ resolved: Schema.Record({
|
|
25
|
-
|
|
26
|
-
value: Schema.String
|
|
27
|
-
}).annotations({
|
|
15
|
+
const ResourceResolvedKind = Schema.Struct({ resolved: Schema.Record(Schema.String, Schema.String).annotate({
|
|
16
|
+
...tombi({ additionalKeyLabel: "name" }),
|
|
28
17
|
title: "Resolved entries",
|
|
29
|
-
description: "Named entries mapped to credential labels. Values come from the active credential profile."
|
|
30
|
-
jsonSchema: tombi({ additionalKeyLabel: "name" })
|
|
18
|
+
description: "Named entries mapped to credential labels. Values come from the active credential profile."
|
|
31
19
|
}) });
|
|
32
|
-
|
|
20
|
+
/**
|
|
21
|
+
* A group of secrets: exactly one of `file`, `value` or `resolved`.
|
|
22
|
+
*
|
|
23
|
+
* @public
|
|
24
|
+
*/
|
|
25
|
+
const SecretGroupSchema = Schema.Union([
|
|
26
|
+
ResourceFileKind,
|
|
27
|
+
ResourceValueKind,
|
|
28
|
+
ResourceResolvedKind
|
|
29
|
+
]).annotate({
|
|
30
|
+
...taplo({ links: { key: docs("05-secrets-and-variables.md") } }),
|
|
33
31
|
identifier: "SecretGroup",
|
|
34
32
|
title: "Secret group",
|
|
35
|
-
description: "A group of secrets. Must be exactly one kind: file, value, or resolved."
|
|
36
|
-
jsonSchema: taplo({ links: { key: "https://github.com/spencerbeggs/reposets/blob/main/docs/secrets-and-variables.md" } })
|
|
33
|
+
description: "A group of secrets. Must be exactly one kind: file, value, or resolved."
|
|
37
34
|
});
|
|
38
|
-
|
|
35
|
+
/**
|
|
36
|
+
* A group of variables: exactly one of `file`, `value` or `resolved`.
|
|
37
|
+
*
|
|
38
|
+
* @public
|
|
39
|
+
*/
|
|
40
|
+
const VariableGroupSchema = Schema.Union([
|
|
41
|
+
ResourceFileKind,
|
|
42
|
+
ResourceValueKind,
|
|
43
|
+
ResourceResolvedKind
|
|
44
|
+
]).annotate({
|
|
45
|
+
...taplo({ links: { key: docs("05-secrets-and-variables.md") } }),
|
|
39
46
|
identifier: "VariableGroup",
|
|
40
47
|
title: "Variable group",
|
|
41
|
-
description: "A group of variables. Must be exactly one kind: file, value, or resolved."
|
|
42
|
-
jsonSchema: taplo({ links: { key: "https://github.com/spencerbeggs/reposets/blob/main/docs/secrets-and-variables.md" } })
|
|
48
|
+
description: "A group of variables. Must be exactly one kind: file, value, or resolved."
|
|
43
49
|
});
|
|
44
|
-
|
|
50
|
+
/**
|
|
51
|
+
* Cleanup policy for one resource scope.
|
|
52
|
+
*
|
|
53
|
+
* @remarks
|
|
54
|
+
* Three-way on purpose: `false` disables cleanup, `true` deletes everything
|
|
55
|
+
* undeclared, and `{ preserve }` deletes everything undeclared except the named
|
|
56
|
+
* resources. Omitting the scope means `false` — leave it alone.
|
|
57
|
+
*
|
|
58
|
+
* @public
|
|
59
|
+
*/
|
|
60
|
+
const CleanupScopeSchema = Schema.Union([Schema.Boolean, Schema.Struct({ preserve: Schema.Array(Schema.String).annotate({
|
|
45
61
|
title: "Preserve list",
|
|
46
62
|
description: "Resource names that should never be deleted during cleanup",
|
|
47
63
|
examples: [["LEGACY_TOKEN", "DEPLOY_KEY"]]
|
|
48
|
-
}) })).
|
|
64
|
+
}) })]).annotate({
|
|
49
65
|
identifier: "CleanupScope",
|
|
50
66
|
title: "Cleanup scope",
|
|
51
67
|
description: "Controls cleanup for a single resource scope. false disables cleanup, true enables full cleanup, or specify names to preserve."
|
|
52
68
|
});
|
|
69
|
+
/**
|
|
70
|
+
* A cleanup scope that defaults to `false` when the key is absent.
|
|
71
|
+
*
|
|
72
|
+
* @remarks
|
|
73
|
+
* v3 wrote this as `Schema.optionalWith(CleanupScopeSchema, { default: () => false })`.
|
|
74
|
+
* v4 splits that into `optionalKey` on the encoded side plus a decoding default;
|
|
75
|
+
* `withDecodingDefaultKey` is the combinator that does both, and it takes the
|
|
76
|
+
* default as an `Effect` rather than a thunk.
|
|
77
|
+
*/
|
|
78
|
+
const cleanupScopeDefaultingToFalse = CleanupScopeSchema.pipe(Schema.withDecodingDefaultKey(Effect.succeed(false)));
|
|
53
79
|
const CleanupSecretsSchema = Schema.Struct({
|
|
54
|
-
actions:
|
|
80
|
+
actions: cleanupScopeDefaultingToFalse.annotate({
|
|
55
81
|
title: "Clean up Actions secrets",
|
|
56
82
|
description: "Delete Actions secrets not declared in any referenced secret group",
|
|
57
83
|
default: false
|
|
58
84
|
}),
|
|
59
|
-
dependabot:
|
|
85
|
+
dependabot: cleanupScopeDefaultingToFalse.annotate({
|
|
60
86
|
title: "Clean up Dependabot secrets",
|
|
61
87
|
description: "Delete Dependabot secrets not declared in any referenced secret group",
|
|
62
88
|
default: false
|
|
63
89
|
}),
|
|
64
|
-
codespaces:
|
|
90
|
+
codespaces: cleanupScopeDefaultingToFalse.annotate({
|
|
65
91
|
title: "Clean up Codespaces secrets",
|
|
66
92
|
description: "Delete Codespaces secrets not declared in any referenced secret group",
|
|
67
93
|
default: false
|
|
68
94
|
}),
|
|
69
|
-
environments:
|
|
95
|
+
environments: cleanupScopeDefaultingToFalse.annotate({
|
|
70
96
|
title: "Clean up environment secrets",
|
|
71
97
|
description: "Delete environment secrets not declared in any referenced secret group",
|
|
72
98
|
default: false
|
|
73
99
|
})
|
|
74
|
-
}).
|
|
100
|
+
}).annotate({
|
|
75
101
|
identifier: "CleanupSecrets",
|
|
76
102
|
title: "Secrets cleanup configuration",
|
|
77
103
|
description: "Controls deletion of secrets by scope (Actions, Dependabot, Codespaces, environments)."
|
|
78
104
|
});
|
|
79
105
|
const CleanupVariablesSchema = Schema.Struct({
|
|
80
|
-
actions:
|
|
106
|
+
actions: cleanupScopeDefaultingToFalse.annotate({
|
|
81
107
|
title: "Clean up Actions variables",
|
|
82
108
|
description: "Delete Actions variables not declared in any referenced variable group",
|
|
83
109
|
default: false
|
|
84
110
|
}),
|
|
85
|
-
environments:
|
|
111
|
+
environments: cleanupScopeDefaultingToFalse.annotate({
|
|
86
112
|
title: "Clean up environment variables",
|
|
87
113
|
description: "Delete environment variables not declared in any referenced variable group",
|
|
88
114
|
default: false
|
|
89
115
|
})
|
|
90
|
-
}).
|
|
116
|
+
}).annotate({
|
|
91
117
|
identifier: "CleanupVariables",
|
|
92
118
|
title: "Variables cleanup configuration",
|
|
93
119
|
description: "Controls deletion of variables by scope (Actions, environments)."
|
|
94
120
|
});
|
|
121
|
+
/**
|
|
122
|
+
* Per-group cleanup configuration.
|
|
123
|
+
*
|
|
124
|
+
* @remarks
|
|
125
|
+
* Everything defaults to disabled. Deleting resources a config does not declare
|
|
126
|
+
* is destructive and opt-in at every level.
|
|
127
|
+
*
|
|
128
|
+
* @public
|
|
129
|
+
*/
|
|
95
130
|
const CleanupSchema = Schema.Struct({
|
|
96
|
-
secrets: Schema.
|
|
131
|
+
secrets: CleanupSecretsSchema.pipe(Schema.withDecodingDefaultKey(Effect.succeed({
|
|
97
132
|
actions: false,
|
|
98
133
|
dependabot: false,
|
|
99
134
|
codespaces: false,
|
|
100
135
|
environments: false
|
|
101
|
-
})
|
|
136
|
+
}))).annotate({
|
|
102
137
|
title: "Secrets cleanup",
|
|
103
138
|
description: "Controls cleanup of secrets by scope"
|
|
104
139
|
}),
|
|
105
|
-
variables: Schema.
|
|
140
|
+
variables: CleanupVariablesSchema.pipe(Schema.withDecodingDefaultKey(Effect.succeed({
|
|
106
141
|
actions: false,
|
|
107
142
|
environments: false
|
|
108
|
-
})
|
|
143
|
+
}))).annotate({
|
|
109
144
|
title: "Variables cleanup",
|
|
110
145
|
description: "Controls cleanup of variables by scope"
|
|
111
146
|
}),
|
|
112
|
-
rulesets:
|
|
147
|
+
rulesets: cleanupScopeDefaultingToFalse.annotate({
|
|
113
148
|
title: "Clean up rulesets",
|
|
114
149
|
description: "Delete repository rulesets not declared in any referenced ruleset group",
|
|
115
150
|
default: false
|
|
116
151
|
}),
|
|
117
|
-
environments:
|
|
152
|
+
environments: cleanupScopeDefaultingToFalse.annotate({
|
|
118
153
|
title: "Clean up environments",
|
|
119
154
|
description: "Delete repository environments not declared in config",
|
|
120
155
|
default: false
|
|
121
156
|
})
|
|
122
|
-
}).
|
|
157
|
+
}).annotate({
|
|
158
|
+
...taplo({ links: { key: docs("08-cleanup.md") } }),
|
|
123
159
|
identifier: "Cleanup",
|
|
124
160
|
title: "Cleanup configuration",
|
|
125
|
-
description: "Controls deletion of resources not declared in config. All disabled by default."
|
|
126
|
-
jsonSchema: taplo({ links: { key: "https://github.com/spencerbeggs/reposets/blob/main/docs/cleanup.md" } })
|
|
161
|
+
description: "Controls deletion of resources not declared in config. All disabled by default."
|
|
127
162
|
});
|
|
128
163
|
|
|
129
164
|
//#endregion
|