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.
Files changed (54) hide show
  1. package/README.md +87 -75
  2. package/bin/reposets.js +68 -17
  3. package/cli/commands/credentials.js +170 -49
  4. package/cli/commands/doctor.js +364 -91
  5. package/cli/commands/drift.js +48 -0
  6. package/cli/commands/history.js +203 -0
  7. package/cli/commands/init.js +110 -104
  8. package/cli/commands/list.js +60 -39
  9. package/cli/commands/nuke.js +127 -0
  10. package/cli/commands/sync.js +219 -59
  11. package/cli/commands/validate.js +57 -41
  12. package/cli/flags.js +36 -0
  13. package/cli/logger.js +48 -0
  14. package/index.d.ts +471 -1499
  15. package/index.js +3 -18
  16. package/lib/config-refs.js +76 -0
  17. package/lib/credential-labels.js +0 -0
  18. package/lib/fingerprint.js +52 -0
  19. package/lib/org-only.js +61 -0
  20. package/lib/schema-issues.js +50 -0
  21. package/package.json +11 -10
  22. package/schemas/annotations.js +81 -0
  23. package/schemas/common.js +83 -48
  24. package/schemas/config.js +214 -212
  25. package/schemas/credentials.js +190 -54
  26. package/schemas/environment.js +27 -21
  27. package/schemas/ruleset.js +235 -139
  28. package/services/ConfigFiles.js +126 -104
  29. package/services/CredentialResolver.js +97 -33
  30. package/services/OnePasswordClient.js +88 -16
  31. package/services/SyncLogger.js +107 -76
  32. package/store/AppliedState.js +0 -0
  33. package/store/RepoCache.js +86 -0
  34. package/store/SyncJournal.js +92 -0
  35. package/store/migrations.js +86 -0
  36. package/sync/SyncEngine.js +156 -0
  37. package/sync/decide.js +56 -0
  38. package/sync/phase.js +55 -0
  39. package/sync/phases/cleanup.js +220 -0
  40. package/sync/phases/code-scanning.js +187 -0
  41. package/sync/phases/environments.js +106 -0
  42. package/sync/phases/index.js +39 -0
  43. package/sync/phases/resource.js +149 -0
  44. package/sync/phases/rulesets.js +186 -0
  45. package/sync/phases/secrets.js +138 -0
  46. package/sync/phases/security.js +129 -0
  47. package/sync/phases/settings.js +274 -0
  48. package/sync/phases/variables.js +132 -0
  49. package/tsdoc-metadata.json +1 -1
  50. package/bin/reposets.d.ts +0 -1
  51. package/errors.js +0 -12
  52. package/lib/crypto.js +0 -27
  53. package/services/GitHubClient.js +0 -875
  54. 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 };
@@ -1,17 +1,17 @@
1
- import { makeConfigFilesLive } from "../../services/ConfigFiles.js";
2
- import { AppDirs } from "xdg-effect";
3
- import { Effect, Option } from "effect";
4
- import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
- import { join } from "node:path";
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 projectOption = Options.boolean("project").pipe(Options.withDescription("Create config in current directory instead of XDG/home location"), Options.withDefault(false));
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
- # Default owner for all groups (can be overridden per group)
14
- # owner = "your-github-username"
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
- # Secrets can be file, value, or resolved kind:
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
- # STATIC_SECRET = "my-secret"
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
- # target = "branch"
47
- #
48
- # [rulesets.default-branch.conditions.ref_name]
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
- # --- Advanced security ---
56
- # Nested inside a settings group (folded into the same PATCH /repos call).
57
- # Some fields are GHAS-licensed and only work on public repos or
58
- # private repos with a GHAS subscription. Org-only fields are silently
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-files", "from-creds"] }
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 (keep this file private)
101
- # See: https://github.com/spencerbeggs/reposets
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
- # github_token = "ghp_your_token_here"
105
- # op_service_account_token = "ops_your_token_here"
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
- const CREDENTIALS_FILE = "reposets.credentials.toml";
108
- const CONFIG_FILE = "reposets.config.toml";
109
- const initCommand = Command.make("init", { project: projectOption }, ({ project }) => Effect.gen(function* () {
110
- const xdgConfigDir = yield* (yield* AppDirs).config;
111
- const targetDir = project ? process.cwd() : xdgConfigDir;
112
- if (!existsSync(targetDir)) mkdirSync(targetDir, { recursive: true });
113
- const configPath = join(targetDir, CONFIG_FILE);
114
- const credsPath = join(targetDir, CREDENTIALS_FILE);
115
- if (existsSync(configPath)) yield* Effect.log(`Config already exists: ${configPath}`);
116
- else {
117
- writeFileSync(configPath, CONFIG_TEMPLATE);
118
- yield* Effect.log(`Created: ${configPath}`);
119
- }
120
- if (existsSync(credsPath)) yield* Effect.log(`Credentials already exists: ${credsPath}`);
121
- else {
122
- writeFileSync(credsPath, CREDENTIALS_TEMPLATE);
123
- yield* Effect.log(`Created: ${credsPath}`);
124
- }
125
- if (project) {
126
- const gitignorePath = join(targetDir, ".gitignore");
127
- if (existsSync(gitignorePath)) {
128
- if (!readFileSync(gitignorePath, "utf-8").includes(CREDENTIALS_FILE)) {
129
- appendFileSync(gitignorePath, `\n${CREDENTIALS_FILE}\n`);
130
- yield* Effect.log(`Added ${CREDENTIALS_FILE} to .gitignore`);
131
- }
132
- } else {
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("\nDone! Edit your config and credentials files to get started.");
144
- }).pipe(Effect.provide(makeConfigFilesLive(Option.none())))).pipe(Command.withDescription("Scaffold config files"));
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 };
@@ -1,49 +1,70 @@
1
- import { ReposetsConfigFile, makeConfigFilesLive } from "../../services/ConfigFiles.js";
1
+ import { profileOwner } from "../../schemas/credentials.js";
2
+ import { ReposetsConfigFile, ReposetsCredentialsFile } from "../../services/ConfigFiles.js";
2
3
  import { Effect } from "effect";
3
- import { Command, Options } from "@effect/cli";
4
+ import { Command } from "effect/unstable/cli";
4
5
 
5
6
  //#region src/cli/commands/list.ts
6
- const configOption = Options.file("config").pipe(Options.withDescription("Path to config directory or reposets.config.toml file"), Options.optional);
7
- const listCommand = Command.make("list", { config: configOption }, ({ config }) => Effect.gen(function* () {
8
- const sources = yield* (yield* ReposetsConfigFile).discover;
9
- if (sources.length === 0) {
10
- yield* Effect.logError("No config file found.");
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 parsedConfig = sources[0].value;
14
- const defaultOwner = parsedConfig.owner ?? "(not set)";
15
- yield* Effect.log(`Default owner: ${defaultOwner}\n`);
16
- for (const [groupName, group] of Object.entries(parsedConfig.groups)) {
17
- const owner = group.owner ?? parsedConfig.owner ?? "(not set)";
18
- yield* Effect.log(`[${groupName}] (owner: ${owner})`);
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
- if (group.settings?.length) yield* Effect.log(` settings: ${group.settings.join(", ")}`);
21
- if (group.environments?.length) yield* Effect.log(` environments: ${group.environments.join(", ")}`);
22
- if (group.secrets) {
23
- const parts = [];
24
- if (group.secrets.actions?.length) parts.push(`actions:[${group.secrets.actions.join(",")}]`);
25
- if (group.secrets.dependabot?.length) parts.push(`dependabot:[${group.secrets.dependabot.join(",")}]`);
26
- if (group.secrets.codespaces?.length) parts.push(`codespaces:[${group.secrets.codespaces.join(",")}]`);
27
- if (group.secrets.environments) {
28
- for (const [envName, envGroups] of Object.entries(group.secrets.environments)) if (envGroups.length) parts.push(`environments.${envName}:[${envGroups.join(",")}]`);
29
- }
30
- if (parts.length) yield* Effect.log(` secrets: ${parts.join(", ")}`);
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
- }).pipe(Effect.provide(makeConfigFilesLive(config)))).pipe(Command.withDescription("Show config summary"));
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 };