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
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { SyncJournal } from "../../store/SyncJournal.js";
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
import { Command, Flag } from "effect/unstable/cli";
|
|
4
|
+
|
|
5
|
+
//#region src/cli/commands/history.ts
|
|
6
|
+
const limitFlag = Flag.integer("limit").pipe(Flag.withDescription("How many runs to show, newest first"), Flag.withDefault(20));
|
|
7
|
+
const repoFlag = Flag.string("repo").pipe(Flag.withDescription("Only runs that touched this repository, as \"owner/name\""), Flag.optional);
|
|
8
|
+
/**
|
|
9
|
+
* `2026-08-12 22:14` — enough to tell two runs apart, short enough to align.
|
|
10
|
+
*
|
|
11
|
+
* @remarks
|
|
12
|
+
* The journal stores ISO-8601 UTC. Seconds are dropped because the column
|
|
13
|
+
* exists to answer "which run was this", not to time anything; the run id is
|
|
14
|
+
* the exact handle.
|
|
15
|
+
*/
|
|
16
|
+
const formatWhen = (iso) => iso.replace("T", " ").slice(0, 16);
|
|
17
|
+
/** How long a run took, when it finished. */
|
|
18
|
+
const formatDuration = (summary) => {
|
|
19
|
+
if (summary.finishedAt === null) return "—";
|
|
20
|
+
const millis = Date.parse(summary.finishedAt) - Date.parse(summary.startedAt);
|
|
21
|
+
if (Number.isNaN(millis) || millis < 0) return "—";
|
|
22
|
+
if (millis < 1e3) return `${millis}ms`;
|
|
23
|
+
const seconds = millis / 1e3;
|
|
24
|
+
return seconds < 60 ? `${seconds.toFixed(1)}s` : `${Math.floor(seconds / 60)}m${String(Math.round(seconds % 60)).padStart(2, "0")}s`;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* What became of a run.
|
|
28
|
+
*
|
|
29
|
+
* @remarks
|
|
30
|
+
* A run with no `outcome` never reached `finishRun` — the process died mid-run
|
|
31
|
+
* — which is a different and more alarming thing than a recorded failure, so it
|
|
32
|
+
* gets its own word rather than being folded into "failed" or shown as blank.
|
|
33
|
+
*/
|
|
34
|
+
const formatOutcome = (summary) => {
|
|
35
|
+
if (summary.outcome !== null) return summary.outcome;
|
|
36
|
+
return summary.finishedAt === null ? "interrupted" : "unknown";
|
|
37
|
+
};
|
|
38
|
+
/** Pad, but never truncate — a long group name is worth more than the alignment. */
|
|
39
|
+
const pad = (value, width) => value.padEnd(width);
|
|
40
|
+
/**
|
|
41
|
+
* Render the table, sizing each column to its widest cell.
|
|
42
|
+
*
|
|
43
|
+
* @remarks
|
|
44
|
+
* Computed rather than fixed because group names and repository names are
|
|
45
|
+
* user-supplied and unbounded. The `CliLogger` emits each line verbatim, so
|
|
46
|
+
* alignment computed here survives to the terminal.
|
|
47
|
+
*/
|
|
48
|
+
const renderRows = (summaries) => {
|
|
49
|
+
const all = [{
|
|
50
|
+
id: "RUN",
|
|
51
|
+
when: "WHEN (UTC)",
|
|
52
|
+
outcome: "OUTCOME",
|
|
53
|
+
mode: "MODE",
|
|
54
|
+
group: "GROUP",
|
|
55
|
+
changes: "CHANGES",
|
|
56
|
+
took: "TOOK"
|
|
57
|
+
}, ...summaries.map((summary) => ({
|
|
58
|
+
id: summary.id.slice(0, 8),
|
|
59
|
+
when: formatWhen(summary.startedAt),
|
|
60
|
+
outcome: formatOutcome(summary),
|
|
61
|
+
mode: summary.dryRun === 1 ? "dry-run" : "applied",
|
|
62
|
+
group: summary.group ?? "(all)",
|
|
63
|
+
changes: String(summary.changes),
|
|
64
|
+
took: formatDuration(summary)
|
|
65
|
+
}))];
|
|
66
|
+
const width = (key) => Math.max(...all.map((row) => row[key].length));
|
|
67
|
+
return all.map((row) => `${pad(row.id, width("id"))} ${pad(row.when, width("when"))} ${pad(row.outcome, width("outcome"))} ${pad(row.mode, width("mode"))} ${pad(row.group, width("group"))} ${row.changes.padStart(width("changes"))} ${row.took}`);
|
|
68
|
+
};
|
|
69
|
+
/**
|
|
70
|
+
* `reposets history` — what previous runs did.
|
|
71
|
+
*
|
|
72
|
+
* @remarks
|
|
73
|
+
* New with the journal; there is no v3 equivalent. Newest first, because the
|
|
74
|
+
* question is almost always "what happened just now".
|
|
75
|
+
*
|
|
76
|
+
* `--repo` filters to runs that **touched** that repository, which is what the
|
|
77
|
+
* journal's query does — it joins through the recorded changes rather than
|
|
78
|
+
* asking which group the repository belonged to. A run that was configured for
|
|
79
|
+
* a repository but changed nothing in it does not appear, and that is the
|
|
80
|
+
* useful reading: this is a record of what happened, not of what was intended.
|
|
81
|
+
*
|
|
82
|
+
* @public
|
|
83
|
+
*/
|
|
84
|
+
const historyHandler = (input) => Effect.gen(function* () {
|
|
85
|
+
const summaries = yield* (yield* SyncJournal).history({
|
|
86
|
+
limit: input.limit,
|
|
87
|
+
...input.repo === void 0 ? {} : { repo: input.repo }
|
|
88
|
+
});
|
|
89
|
+
if (summaries.length === 0) {
|
|
90
|
+
yield* Effect.log(input.repo === void 0 ? "No runs recorded yet. The journal fills in as you sync." : `No recorded run has touched ${input.repo}.`);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
for (const line of renderRows(summaries)) yield* Effect.log(line);
|
|
94
|
+
if (summaries.length === input.limit) {
|
|
95
|
+
yield* Effect.log("");
|
|
96
|
+
yield* Effect.log(`Showing the most recent ${input.limit}. Pass --limit for more.`);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
/**
|
|
100
|
+
* The present-tense form of a stored action, for a dry run.
|
|
101
|
+
*
|
|
102
|
+
* @remarks
|
|
103
|
+
* `unchanged` and `drift-overwritten` are **observations, not operations** — a
|
|
104
|
+
* dry run observed them just as truly as a real one would, so neither takes a
|
|
105
|
+
* `would `. Only the three verbs that describe a write are rewritten.
|
|
106
|
+
*/
|
|
107
|
+
const wouldForm = (action) => action === "created" ? "create" : action === "updated" ? "update" : action === "deleted" ? "delete" : action;
|
|
108
|
+
/**
|
|
109
|
+
* `reposets history show <run>` — every resource one run touched.
|
|
110
|
+
*
|
|
111
|
+
* @remarks
|
|
112
|
+
* The journal has recorded these from the start and nothing surfaced them, so
|
|
113
|
+
* `12 changes` was a number with no way to ask *which twelve*.
|
|
114
|
+
*
|
|
115
|
+
* The id may be given in full or as a unique prefix, because nobody is going to
|
|
116
|
+
* retype a UUID from a table.
|
|
117
|
+
*/
|
|
118
|
+
const showHandler = (prefix) => Effect.gen(function* () {
|
|
119
|
+
const journal = yield* SyncJournal;
|
|
120
|
+
const matches = (yield* journal.history({ limit: 1e3 }).pipe(Effect.orElseSucceed(() => []))).filter((run) => run.id.startsWith(prefix));
|
|
121
|
+
if (matches.length === 0) {
|
|
122
|
+
yield* Effect.logError(`No run matches '${prefix}'.`);
|
|
123
|
+
yield* Effect.sync(() => {
|
|
124
|
+
process.exitCode = 1;
|
|
125
|
+
});
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (matches.length > 1) {
|
|
129
|
+
yield* Effect.logError(`'${prefix}' matches ${matches.length} runs. Use more of the id:`);
|
|
130
|
+
for (const run of matches.slice(0, 5)) yield* Effect.logError(` ${run.id} ${formatWhen(run.startedAt)}`);
|
|
131
|
+
yield* Effect.sync(() => {
|
|
132
|
+
process.exitCode = 1;
|
|
133
|
+
});
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const run = matches[0];
|
|
137
|
+
const changes = yield* journal.changesFor(run.id).pipe(Effect.orElseSucceed(() => []));
|
|
138
|
+
yield* Effect.log(`run ${run.id}`);
|
|
139
|
+
yield* Effect.log(` ${formatWhen(run.startedAt)} · ${run.dryRun === 1 ? "dry-run" : "applied"} · ${run.outcome ?? "unfinished"} · ${formatDuration(run)}`);
|
|
140
|
+
yield* Effect.log("");
|
|
141
|
+
if (run.error !== null && run.error !== "") {
|
|
142
|
+
yield* Effect.log(` error: ${run.error}`);
|
|
143
|
+
yield* Effect.log("");
|
|
144
|
+
}
|
|
145
|
+
if (changes.length === 0) {
|
|
146
|
+
yield* Effect.log(run.error !== null && run.error !== "" ? " No resources changed." : " No resources changed (nothing to do).");
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const byRepo = /* @__PURE__ */ new Map();
|
|
150
|
+
for (const change of changes) {
|
|
151
|
+
const existing = byRepo.get(change.repo);
|
|
152
|
+
if (existing === void 0) byRepo.set(change.repo, [change]);
|
|
153
|
+
else existing.push(change);
|
|
154
|
+
}
|
|
155
|
+
for (const [repo, records] of byRepo) {
|
|
156
|
+
yield* Effect.log(` ${repo}`);
|
|
157
|
+
for (const record of records) {
|
|
158
|
+
const detail = record.detail === void 0 || record.detail === null ? "" : ` — ${record.detail}`;
|
|
159
|
+
const what = record.kind === record.name ? record.kind : `${record.kind} ${record.name}`;
|
|
160
|
+
const action = run.dryRun === 1 ? `would ${wouldForm(record.action)}` : record.action;
|
|
161
|
+
yield* Effect.log(` ${action.padEnd(18)} ${what}${detail}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
/**
|
|
166
|
+
* `reposets history prune|clear` — the journal, and only the journal.
|
|
167
|
+
*
|
|
168
|
+
* @remarks
|
|
169
|
+
* **These do not touch applied state or the cache**, and that is the whole
|
|
170
|
+
* design. The same database holds the fingerprints drift detection compares
|
|
171
|
+
* against; deleting those does not tidy anything, it disarms the check, so the
|
|
172
|
+
* next run reports a first sync where an out-of-band edit actually happened.
|
|
173
|
+
* A command that quietly did both would be the most dangerous thing in this CLI.
|
|
174
|
+
*/
|
|
175
|
+
const pruneHandler = (keep) => Effect.gen(function* () {
|
|
176
|
+
const removed = yield* (yield* SyncJournal).prune(keep).pipe(Effect.orElseSucceed(() => 0));
|
|
177
|
+
yield* Effect.log(removed === 0 ? `Nothing to prune; ${keep} or fewer runs are recorded.` : `Pruned ${removed} run${removed === 1 ? "" : "s"}, keeping the newest ${keep}.`);
|
|
178
|
+
yield* Effect.log("Applied state and the cache are untouched — drift detection still works.");
|
|
179
|
+
});
|
|
180
|
+
const clearHandler = () => Effect.gen(function* () {
|
|
181
|
+
const removed = yield* (yield* SyncJournal).clear().pipe(Effect.orElseSucceed(() => 0));
|
|
182
|
+
yield* Effect.log(`Cleared ${removed} run${removed === 1 ? "" : "s"} from the journal.`);
|
|
183
|
+
yield* Effect.log("Applied state and the cache are untouched — drift detection still works.");
|
|
184
|
+
});
|
|
185
|
+
/**
|
|
186
|
+
* `reposets history`.
|
|
187
|
+
*
|
|
188
|
+
* @public
|
|
189
|
+
*/
|
|
190
|
+
const historyCommand = Command.make("history", {
|
|
191
|
+
limit: limitFlag,
|
|
192
|
+
repo: repoFlag
|
|
193
|
+
}, ({ limit, repo }) => historyHandler({
|
|
194
|
+
limit,
|
|
195
|
+
repo: repo._tag === "Some" ? repo.value : void 0
|
|
196
|
+
})).pipe(Command.withDescription("Show what previous sync runs did, newest first"), Command.withSubcommands([
|
|
197
|
+
Command.make("show", { run: Flag.string("run").pipe(Flag.withDescription("A run id, or a unique prefix")) }, ({ run }) => showHandler(run)).pipe(Command.withDescription("Show every resource one run touched")),
|
|
198
|
+
Command.make("prune", { keep: Flag.integer("keep").pipe(Flag.withDescription("How many of the newest runs to keep"), Flag.withDefault(50)) }, ({ keep }) => pruneHandler(keep)).pipe(Command.withDescription("Delete all but the newest runs from the journal")),
|
|
199
|
+
Command.make("clear", {}, () => clearHandler()).pipe(Command.withDescription("Delete every run from the journal"))
|
|
200
|
+
]));
|
|
201
|
+
|
|
202
|
+
//#endregion
|
|
203
|
+
export { clearHandler, historyCommand, historyHandler, pruneHandler, showHandler };
|
package/cli/commands/init.js
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import { Command, Options } from "@effect/cli";
|
|
1
|
+
import { CONFIG_FILENAME, CREDENTIALS_FILENAME } from "../../services/ConfigFiles.js";
|
|
2
|
+
import { Effect, FileSystem, Path } from "effect";
|
|
3
|
+
import { Command, Flag } from "effect/unstable/cli";
|
|
4
|
+
import { AppDirs } from "@effected/xdg";
|
|
5
|
+
import { cwd } from "node:process";
|
|
7
6
|
|
|
8
7
|
//#region src/cli/commands/init.ts
|
|
9
|
-
const
|
|
8
|
+
const projectFlag = Flag.boolean("project").pipe(Flag.withDescription("Scaffold into the current directory instead of the XDG config directory"));
|
|
10
9
|
const CONFIG_TEMPLATE = `# reposets configuration
|
|
11
10
|
# See: https://github.com/spencerbeggs/reposets
|
|
12
11
|
|
|
13
|
-
#
|
|
14
|
-
#
|
|
12
|
+
# The owner is NOT set here. It belongs to the credential profile a group
|
|
13
|
+
# names, because a token authenticates as an identity — see
|
|
14
|
+
# reposets.credentials.toml.
|
|
15
15
|
|
|
16
16
|
# --- Settings groups ---
|
|
17
17
|
# [settings.defaults]
|
|
@@ -20,128 +20,134 @@ const CONFIG_TEMPLATE = `# reposets configuration
|
|
|
20
20
|
# delete_branch_on_merge = true
|
|
21
21
|
|
|
22
22
|
# --- Secret groups ---
|
|
23
|
-
#
|
|
23
|
+
# A secret group is exactly one kind: file, value, or resolved.
|
|
24
24
|
#
|
|
25
25
|
# [secrets.from-files.file]
|
|
26
26
|
# APP_KEY = "./private/app-key"
|
|
27
27
|
#
|
|
28
28
|
# [secrets.inline.value]
|
|
29
|
-
#
|
|
29
|
+
# NON_SECRET = "safe-to-commit"
|
|
30
30
|
#
|
|
31
31
|
# [secrets.from-creds.resolved]
|
|
32
|
-
# NPM_TOKEN = "MY_NPM_TOKEN"
|
|
32
|
+
# NPM_TOKEN = "MY_NPM_TOKEN" # a label from the credentials file's [resolve]
|
|
33
33
|
|
|
34
34
|
# --- Variable groups ---
|
|
35
35
|
# [variables.turbo.value]
|
|
36
36
|
# DO_NOT_TRACK = "1"
|
|
37
|
-
# TURBO_TELEMETRY_DISABLED = "1"
|
|
38
|
-
#
|
|
39
|
-
# [variables.bot.resolved]
|
|
40
|
-
# APP_BOT_NAME = "MY_BOT_NAME"
|
|
41
37
|
|
|
42
38
|
# --- Rulesets ---
|
|
43
39
|
# [rulesets.default-branch]
|
|
44
40
|
# name = "default-branch"
|
|
41
|
+
# type = "branch"
|
|
45
42
|
# enforcement = "active"
|
|
46
|
-
#
|
|
47
|
-
#
|
|
48
|
-
#
|
|
49
|
-
# include = ["~DEFAULT_BRANCH"]
|
|
50
|
-
# exclude = []
|
|
51
|
-
#
|
|
52
|
-
# [[rulesets.default-branch.rules]]
|
|
53
|
-
# type = "deletion"
|
|
43
|
+
# targets = "default"
|
|
44
|
+
# deletion = true
|
|
45
|
+
# required_signatures = true
|
|
54
46
|
|
|
55
|
-
# ---
|
|
56
|
-
#
|
|
57
|
-
#
|
|
58
|
-
#
|
|
59
|
-
# skipped on personal accounts.
|
|
60
|
-
#
|
|
61
|
-
# [settings.defaults.security_and_analysis]
|
|
62
|
-
# secret_scanning = "enabled"
|
|
63
|
-
# secret_scanning_push_protection = "enabled"
|
|
64
|
-
# dependabot_security_updates = "enabled"
|
|
65
|
-
|
|
66
|
-
# --- Security feature toggles ---
|
|
67
|
-
# Dedicated PUT/DELETE endpoints; omit a key to leave it untouched.
|
|
68
|
-
#
|
|
69
|
-
# [security.oss-defaults]
|
|
70
|
-
# vulnerability_alerts = true
|
|
71
|
-
# automated_security_fixes = true
|
|
72
|
-
# private_vulnerability_reporting = true
|
|
73
|
-
|
|
74
|
-
# --- CodeQL default setup ---
|
|
75
|
-
# Applies via PATCH /repos/{o}/{r}/code-scanning/default-setup.
|
|
76
|
-
# Languages not detected in the repo are skipped with a warning.
|
|
77
|
-
#
|
|
78
|
-
# [code_scanning.oss-defaults]
|
|
79
|
-
# state = "configured"
|
|
80
|
-
# languages = ["javascript-typescript", "python"]
|
|
81
|
-
# query_suite = "extended"
|
|
82
|
-
# threat_model = "remote"
|
|
83
|
-
|
|
84
|
-
# --- Cleanup defaults ---
|
|
85
|
-
# [cleanup]
|
|
86
|
-
# secrets = false
|
|
87
|
-
# variables = false
|
|
88
|
-
# rulesets = false
|
|
47
|
+
# --- Deployment environments ---
|
|
48
|
+
# [environments.production]
|
|
49
|
+
# wait_timer = 5
|
|
50
|
+
# prevent_self_review = true
|
|
89
51
|
|
|
90
52
|
# --- Groups ---
|
|
91
53
|
# [groups.my-projects]
|
|
92
54
|
# repos = ["repo-one", "repo-two"]
|
|
55
|
+
# credentials = "personal" # REQUIRED: the profile this group authenticates as
|
|
93
56
|
# settings = ["defaults"]
|
|
94
|
-
# secrets = { actions = ["from-
|
|
95
|
-
# variables = { actions = ["turbo", "bot"] }
|
|
96
|
-
# rulesets = ["default-branch"]
|
|
97
|
-
# security = ["oss-defaults"]
|
|
98
|
-
# code_scanning = ["oss-defaults"]
|
|
57
|
+
# secrets = { actions = ["from-creds"] }
|
|
99
58
|
`;
|
|
100
|
-
const CREDENTIALS_TEMPLATE = `# reposets credentials
|
|
101
|
-
#
|
|
59
|
+
const CREDENTIALS_TEMPLATE = `# reposets credentials
|
|
60
|
+
#
|
|
61
|
+
# This file holds REFERENCES, never secret values. Each entry names where a
|
|
62
|
+
# credential lives — a 1Password item or an environment variable — so the file
|
|
63
|
+
# itself discloses nothing if it leaks.
|
|
64
|
+
#
|
|
65
|
+
# 1Password references are resolved with OP_SERVICE_ACCOUNT_TOKEN from the
|
|
66
|
+
# environment. That token is deliberately not stored here: it unlocks
|
|
67
|
+
# everything else, so it does not belong in the same file as the things it
|
|
68
|
+
# unlocks.
|
|
69
|
+
#
|
|
70
|
+
# Every group in reposets.config.toml names one of these profiles in its
|
|
71
|
+
# 'credentials' field — that is the identity the group is synced as, and the
|
|
72
|
+
# owner its repositories belong to. One owner per profile: a token that reaches
|
|
73
|
+
# both your account and an org is declared as two profiles sharing a reference.
|
|
102
74
|
|
|
75
|
+
# Every profile declares who it acts as: exactly one of username or org.
|
|
76
|
+
# That is not bookkeeping — it decides which settings are even valid, and it
|
|
77
|
+
# lets 'reposets validate' reject an organization-only setting with no network.
|
|
78
|
+
#
|
|
103
79
|
# [profiles.personal]
|
|
104
|
-
#
|
|
105
|
-
#
|
|
80
|
+
# username = "your-github-username"
|
|
81
|
+
# github_token = { op = "op://Private/github/token" }
|
|
82
|
+
#
|
|
83
|
+
# [profiles.work]
|
|
84
|
+
# org = "your-org"
|
|
85
|
+
# github_token = { op = "op://Private/github/work-token" }
|
|
86
|
+
|
|
87
|
+
# For CI, where a platform secret store injects the value:
|
|
88
|
+
# [profiles.ci]
|
|
89
|
+
# org = "your-org"
|
|
90
|
+
# github_token = { env = "REPOSETS_GITHUB_TOKEN" }
|
|
91
|
+
|
|
92
|
+
# Named values for 'resolved' secret and variable groups:
|
|
93
|
+
# [profiles.personal.resolve.op]
|
|
94
|
+
# MY_NPM_TOKEN = "op://Private/npm/token"
|
|
95
|
+
#
|
|
96
|
+
# [profiles.personal.resolve.env]
|
|
97
|
+
# MY_BOT_NAME = "BOT_NAME"
|
|
98
|
+
#
|
|
99
|
+
# [profiles.personal.resolve.file]
|
|
100
|
+
# MY_CERT = "./certs/bot.pem"
|
|
106
101
|
`;
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
writeFileSync(gitignorePath, `${CREDENTIALS_FILE}\n`);
|
|
134
|
-
yield* Effect.log(`Created .gitignore with ${CREDENTIALS_FILE}`);
|
|
135
|
-
}
|
|
136
|
-
} else {
|
|
137
|
-
const gitignorePath = join(targetDir, ".gitignore");
|
|
138
|
-
if (!existsSync(gitignorePath)) {
|
|
139
|
-
writeFileSync(gitignorePath, `${CREDENTIALS_FILE}\n`);
|
|
140
|
-
yield* Effect.log(`Created .gitignore in ${targetDir}`);
|
|
102
|
+
/**
|
|
103
|
+
* `reposets init` — scaffold the config and credentials files.
|
|
104
|
+
*
|
|
105
|
+
* @remarks
|
|
106
|
+
* Writes into the XDG config directory by default, or the current directory
|
|
107
|
+
* with `--project`. Existing files are reported and never overwritten — this
|
|
108
|
+
* command must be safe to re-run against a configured machine.
|
|
109
|
+
*
|
|
110
|
+
* The credentials file is added to `.gitignore` in both modes. It contains only
|
|
111
|
+
* references and so is not catastrophic to commit, but it still names a
|
|
112
|
+
* person's vault layout, and the habit is worth keeping.
|
|
113
|
+
*
|
|
114
|
+
* @public
|
|
115
|
+
*/
|
|
116
|
+
const initHandler = (project) => Effect.gen(function* () {
|
|
117
|
+
const fs = yield* FileSystem.FileSystem;
|
|
118
|
+
const path = yield* Path.Path;
|
|
119
|
+
const appDirs = yield* AppDirs;
|
|
120
|
+
const targetDir = project ? cwd() : yield* appDirs.ensureConfig;
|
|
121
|
+
yield* fs.makeDirectory(targetDir, { recursive: true });
|
|
122
|
+
/** Write a file unless it is already there; report either way. */
|
|
123
|
+
const scaffold = (name, contents) => Effect.gen(function* () {
|
|
124
|
+
const target = path.join(targetDir, name);
|
|
125
|
+
if (yield* fs.exists(target).pipe(Effect.orElseSucceed(() => false))) {
|
|
126
|
+
yield* Effect.log(`Already exists: ${target}`);
|
|
127
|
+
return;
|
|
141
128
|
}
|
|
129
|
+
yield* (yield* fs.writeFileString(target, contents).pipe(Effect.option))._tag === "Some" ? Effect.log(`Created: ${target}`) : Effect.logError(`Could not write: ${target}`);
|
|
130
|
+
});
|
|
131
|
+
yield* scaffold(CONFIG_FILENAME, CONFIG_TEMPLATE);
|
|
132
|
+
yield* scaffold(CREDENTIALS_FILENAME, CREDENTIALS_TEMPLATE);
|
|
133
|
+
const gitignorePath = path.join(targetDir, ".gitignore");
|
|
134
|
+
const existing = yield* fs.readFileString(gitignorePath).pipe(Effect.orElseSucceed(() => void 0));
|
|
135
|
+
if (existing === void 0) {
|
|
136
|
+
if ((yield* fs.writeFileString(gitignorePath, `${"reposets.credentials.toml"}\n`).pipe(Effect.option))._tag === "Some") yield* Effect.log(`Created .gitignore with ${CREDENTIALS_FILENAME}`);
|
|
137
|
+
} else if (!existing.includes("reposets.credentials.toml")) {
|
|
138
|
+
const separator = existing.endsWith("\n") ? "" : "\n";
|
|
139
|
+
if ((yield* fs.writeFileString(gitignorePath, `${existing}${separator}${"reposets.credentials.toml"}\n`).pipe(Effect.option))._tag === "Some") yield* Effect.log(`Added ${CREDENTIALS_FILENAME} to .gitignore`);
|
|
142
140
|
}
|
|
143
|
-
yield* Effect.log("
|
|
144
|
-
|
|
141
|
+
yield* Effect.log("");
|
|
142
|
+
yield* Effect.log("Done. Edit the config, then add a credential reference with:");
|
|
143
|
+
yield* Effect.log(" reposets credentials create --profile personal --username YOU --op \"op://Vault/item/field\"");
|
|
144
|
+
});
|
|
145
|
+
/**
|
|
146
|
+
* `reposets init`.
|
|
147
|
+
*
|
|
148
|
+
* @public
|
|
149
|
+
*/
|
|
150
|
+
const initCommand = Command.make("init", { project: projectFlag }, ({ project }) => initHandler(project)).pipe(Command.withDescription("Scaffold reposets.config.toml and reposets.credentials.toml"));
|
|
145
151
|
|
|
146
152
|
//#endregion
|
|
147
|
-
export { initCommand };
|
|
153
|
+
export { initCommand, initHandler };
|
package/cli/commands/list.js
CHANGED
|
@@ -1,49 +1,70 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { profileOwner } from "../../schemas/credentials.js";
|
|
2
|
+
import { ReposetsConfigFile, ReposetsCredentialsFile } from "../../services/ConfigFiles.js";
|
|
2
3
|
import { Effect } from "effect";
|
|
3
|
-
import { Command
|
|
4
|
+
import { Command } from "effect/unstable/cli";
|
|
4
5
|
|
|
5
6
|
//#region src/cli/commands/list.ts
|
|
6
|
-
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
/** `secrets` and `variables` render the same way; only the scope names differ. */
|
|
8
|
+
const scopeParts = (scopes) => {
|
|
9
|
+
if (scopes === void 0) return [];
|
|
10
|
+
const parts = [];
|
|
11
|
+
for (const scope of [
|
|
12
|
+
"actions",
|
|
13
|
+
"dependabot",
|
|
14
|
+
"codespaces"
|
|
15
|
+
]) {
|
|
16
|
+
const groups = scopes[scope];
|
|
17
|
+
if (groups !== void 0 && groups.length > 0) parts.push(`${scope}:[${groups.join(",")}]`);
|
|
18
|
+
}
|
|
19
|
+
for (const [envName, envGroups] of Object.entries(scopes.environments ?? {})) if (envGroups.length > 0) parts.push(`environments.${envName}:[${envGroups.join(",")}]`);
|
|
20
|
+
return parts;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* `reposets list` — what the config declares, per group.
|
|
24
|
+
*
|
|
25
|
+
* @remarks
|
|
26
|
+
* A reading command: it decodes the config and prints its structure, touching
|
|
27
|
+
* neither GitHub nor the store. Empty collections are omitted rather than
|
|
28
|
+
* printed as `(none)` — a group that assigns nothing should read as a short
|
|
29
|
+
* entry, not a wall of blanks.
|
|
30
|
+
*
|
|
31
|
+
* @public
|
|
32
|
+
*/
|
|
33
|
+
const listHandler = Effect.gen(function* () {
|
|
34
|
+
const configFile = yield* ReposetsConfigFile;
|
|
35
|
+
const credentialsFile = yield* ReposetsCredentialsFile;
|
|
36
|
+
if ((yield* configFile.discover).length === 0) {
|
|
37
|
+
yield* Effect.logError("No config file found. Run 'reposets init' to create one.");
|
|
11
38
|
return;
|
|
12
39
|
}
|
|
13
|
-
const
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const
|
|
18
|
-
yield* Effect.log(`[${groupName}] (
|
|
40
|
+
const config = yield* configFile.load;
|
|
41
|
+
const credentials = yield* credentialsFile.loadOrDefault({ profiles: {} });
|
|
42
|
+
for (const [groupName, group] of Object.entries(config.groups)) {
|
|
43
|
+
const profile = credentials.profiles[group.credentials];
|
|
44
|
+
const acts = profile === void 0 ? `credentials: ${group.credentials} — NOT FOUND` : `owner: ${profileOwner(profile).owner}, credentials: ${group.credentials}`;
|
|
45
|
+
yield* Effect.log(`[${groupName}] (${acts})`);
|
|
46
|
+
const owner = profile === void 0 ? "(unknown)" : profileOwner(profile).owner;
|
|
19
47
|
for (const repo of group.repos) yield* Effect.log(` - ${owner}/${repo}`);
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
}
|
|
32
|
-
if (group.variables) {
|
|
33
|
-
const parts = [];
|
|
34
|
-
if (group.variables.actions?.length) parts.push(`actions:[${group.variables.actions.join(",")}]`);
|
|
35
|
-
if (group.variables.environments) {
|
|
36
|
-
for (const [envName, envGroups] of Object.entries(group.variables.environments)) if (envGroups.length) parts.push(`environments.${envName}:[${envGroups.join(",")}]`);
|
|
37
|
-
}
|
|
38
|
-
if (parts.length) yield* Effect.log(` variables: ${parts.join(", ")}`);
|
|
39
|
-
}
|
|
40
|
-
if (group.rulesets?.length) yield* Effect.log(` rulesets: ${group.rulesets.join(", ")}`);
|
|
41
|
-
if (group.security?.length) yield* Effect.log(` security: ${group.security.join(", ")}`);
|
|
42
|
-
if (group.code_scanning?.length) yield* Effect.log(` code_scanning: ${group.code_scanning.join(", ")}`);
|
|
43
|
-
if (group.credentials) yield* Effect.log(` credentials: ${group.credentials}`);
|
|
48
|
+
for (const [label, names] of [
|
|
49
|
+
["settings", group.settings],
|
|
50
|
+
["environments", group.environments],
|
|
51
|
+
["rulesets", group.rulesets],
|
|
52
|
+
["security", group.security],
|
|
53
|
+
["code_scanning", group.code_scanning]
|
|
54
|
+
]) if (names !== void 0 && names.length > 0) yield* Effect.log(` ${label}: ${names.join(", ")}`);
|
|
55
|
+
const secrets = scopeParts(group.secrets);
|
|
56
|
+
if (secrets.length > 0) yield* Effect.log(` secrets: ${secrets.join(", ")}`);
|
|
57
|
+
const variables = scopeParts(group.variables);
|
|
58
|
+
if (variables.length > 0) yield* Effect.log(` variables: ${variables.join(", ")}`);
|
|
44
59
|
yield* Effect.log("");
|
|
45
60
|
}
|
|
46
|
-
})
|
|
61
|
+
});
|
|
62
|
+
/**
|
|
63
|
+
* `reposets list`.
|
|
64
|
+
*
|
|
65
|
+
* @public
|
|
66
|
+
*/
|
|
67
|
+
const listCommand = Command.make("list", {}, () => listHandler).pipe(Command.withDescription("Show a summary of the config: groups, repos and their assigned resources"));
|
|
47
68
|
|
|
48
69
|
//#endregion
|
|
49
|
-
export { listCommand };
|
|
70
|
+
export { listCommand, listHandler };
|