@ajaykumarnpm/talea 0.1.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.
@@ -0,0 +1,194 @@
1
+ import { findWorkspace, loadManifest, saveManifest } from '../config.js';
2
+ import { listRepos, toEntry, token, whoami } from '../github.js';
3
+ import { c, context, fail, heading, info, ok, plain, skip, table, warn } from '../log.js';
4
+
5
+ export const help = `
6
+ ${c.bold('talea discover')} — build the catalogue from your GitHub account
7
+
8
+ ${c.dim('talea discover')} every repo you can see
9
+ ${c.dim('talea discover --since 6mo')} anything older is kept, but marked archived
10
+ ${c.dim('talea discover --user someone')} public repos of an account, no token needed
11
+ ${c.dim('talea discover --apply')} write the catalogue (default is a dry run)
12
+
13
+ Lists your own repos and every org you belong to, then writes each one's owner,
14
+ default branch and last push into the catalogue. This is what makes the
15
+ catalogue current instead of hand-maintained.
16
+
17
+ Options
18
+ --since <window> 6mo | 90d | 2y | an ISO date. Older repos are kept in
19
+ the catalogue and marked archived, never dropped.
20
+ --user <login> read a specific account's public repos instead
21
+ --apply write the file (default prints what would change)
22
+
23
+ ${c.bold('What is preserved')} — a repo already in the catalogue keeps its ${c.dim('default')},
24
+ ${c.dim('group')} and ${c.dim('dir')}. Discovery refreshes the facts GitHub owns; the choices are
25
+ yours and are never overwritten.
26
+
27
+ Auth comes from ${c.dim('gh auth token')}, then ${c.dim('GITHUB_TOKEN')}, then nothing — and nothing is
28
+ a working state, it just means public repos only.
29
+
30
+ The first run writes ${c.dim('~/.talea/talea.repos.json')}. Share it with
31
+ ${c.dim('talea manifest push')}.
32
+ `;
33
+
34
+ /**
35
+ * Turn `6mo` / `90d` / `2y` / `2026-01-01` into an ISO timestamp.
36
+ *
37
+ * Returns null for "no window", which means nothing gets marked archived by
38
+ * age. A window that cannot be parsed is an error rather than a silent null:
39
+ * `--since 6m` quietly meaning "everything is active" is the kind of typo that
40
+ * makes a command look like it worked.
41
+ */
42
+ export function parseSince(spec, now = new Date()) {
43
+ if (!spec) return null;
44
+ const m = /^(\d+)\s*(d|w|mo|m|y)$/i.exec(String(spec).trim());
45
+ if (m) {
46
+ const n = Number(m[1]);
47
+ const d = new Date(now);
48
+ const unit = m[2].toLowerCase();
49
+ if (unit === 'd') d.setDate(d.getDate() - n);
50
+ else if (unit === 'w') d.setDate(d.getDate() - n * 7);
51
+ else if (unit === 'y') d.setFullYear(d.getFullYear() - n);
52
+ else d.setMonth(d.getMonth() - n); // 'mo' and bare 'm' both mean months
53
+ return d.toISOString();
54
+ }
55
+ const date = new Date(spec);
56
+ if (Number.isNaN(date.getTime())) {
57
+ throw new Error(`Cannot read --since "${spec}". Try 6mo, 90d, 2y, or 2026-01-01.`);
58
+ }
59
+ return date.toISOString();
60
+ }
61
+
62
+ /**
63
+ * Merge what GitHub says into what the catalogue already holds.
64
+ *
65
+ * The split that matters: GitHub owns the *facts* (owner, default branch, fork,
66
+ * last push) and refreshing them is the whole point. You own the *choices*
67
+ * (`default`, `group`, `dir`, `url`), and a discovery run that reset those
68
+ * would undo your curation every time the tool got run.
69
+ */
70
+ export function merge(existing, found) {
71
+ const byKey = new Map(existing.map((r) => [`${r.owner}/${r.name}`.toLowerCase(), r]));
72
+ const seen = new Set();
73
+ const repos = [];
74
+ const added = [];
75
+
76
+ for (const entry of found) {
77
+ const key = `${entry.owner}/${entry.name}`.toLowerCase();
78
+ seen.add(key);
79
+ const prior = byKey.get(key);
80
+ if (!prior) added.push(entry);
81
+ repos.push({
82
+ ...entry,
83
+ // Choices win over discovery, every time.
84
+ ...(prior?.default !== undefined ? { default: prior.default } : {}),
85
+ ...(prior?.group !== undefined ? { group: prior.group } : {}),
86
+ ...(prior?.dir !== undefined ? { dir: prior.dir } : {}),
87
+ ...(prior?.url !== undefined ? { url: prior.url } : {}),
88
+ });
89
+ }
90
+
91
+ // A repo the API did not return is not a repo that stopped existing — a token
92
+ // with narrower scopes, a revoked org grant or a rate limit all look the same
93
+ // from here. Keep it, mark it, and let the human decide.
94
+ const vanished = existing.filter((r) => !seen.has(`${r.owner}/${r.name}`.toLowerCase()));
95
+ for (const r of vanished) repos.push({ ...r, missing: true });
96
+
97
+ return { repos, added, vanished };
98
+ }
99
+
100
+ export async function run(opts) {
101
+ const { token: tok, from } = token();
102
+ const since = parseSince(opts.since);
103
+
104
+ heading('Discovering repositories');
105
+ context([
106
+ ['auth', tok ? c.green(from) : c.yellow('none — public repos only')],
107
+ opts.user ? ['user', c.bold(opts.user)] : null,
108
+ since ? ['active since', c.cyan(since.slice(0, 10))] : null,
109
+ ['mode', opts.apply ? c.bold('apply') : c.dim('dry run')],
110
+ ]);
111
+
112
+ let login = opts.user ?? null;
113
+ if (tok && !login) {
114
+ try {
115
+ login = await whoami(tok);
116
+ info(`signed in as ${c.bold(login)}`);
117
+ } catch (err) {
118
+ warn(`Could not read the account behind that token — ${err.message}`);
119
+ }
120
+ }
121
+
122
+ let api;
123
+ try {
124
+ api = await listRepos({ token: opts.user ? null : tok, user: login });
125
+ } catch (err) {
126
+ fail(err.message);
127
+ process.exit(1);
128
+ }
129
+
130
+ const found = api.map((r) => toEntry(r, { activeSince: since }));
131
+ const root = findWorkspace();
132
+ const manifest = loadManifest(root);
133
+ const { repos, added, vanished } = merge(manifest.repos, found);
134
+
135
+ const active = repos.filter((r) => !r.archived && !r.missing);
136
+ const owners = new Set(repos.map((r) => r.owner));
137
+
138
+ plain('');
139
+ table(
140
+ [
141
+ ['found', `${found.length}`],
142
+ ['active', `${active.length}`],
143
+ ['archived / quiet', `${repos.length - active.length - vanished.length}`],
144
+ ['new to the catalogue', `${added.length}`],
145
+ ['owners', [...owners].join(', ')],
146
+ ],
147
+ ['', ''],
148
+ );
149
+
150
+ if (added.length) {
151
+ heading('New');
152
+ for (const r of added.slice(0, 40)) {
153
+ plain(` ${c.green('+')} ${c.bold(`${r.owner}/${r.name}`)} ${c.dim(r.pushedAt?.slice(0, 10) ?? '')}`);
154
+ }
155
+ if (added.length > 40) plain(c.dim(` … and ${added.length - 40} more`));
156
+ }
157
+
158
+ if (vanished.length) {
159
+ heading('In the catalogue, not returned by GitHub');
160
+ for (const r of vanished) {
161
+ plain(` ${c.yellow('?')} ${c.bold(`${r.owner}/${r.name}`)}`);
162
+ }
163
+ plain(
164
+ c.dim(
165
+ ' Kept and marked. Renamed, made private, or your token cannot see it —\n' +
166
+ ' none of which is a reason for this tool to forget it.',
167
+ ),
168
+ );
169
+ }
170
+
171
+ if (!opts.apply) {
172
+ plain(`\n${c.dim('Re-run with --apply to write the catalogue.')}`);
173
+ return;
174
+ }
175
+
176
+ // First discovery decides what "default" means: everything still active. It
177
+ // is only a starting point — the catalogue is yours to edit, and `talea add`
178
+ // and the picker both write to it.
179
+ const seeding = !manifest.repos.length;
180
+ if (seeding) {
181
+ for (const r of repos) r.default = !r.archived && !r.fork;
182
+ }
183
+
184
+ const groups = { ...manifest.groups };
185
+ for (const owner of owners) groups[owner] ??= { dir: owner, title: `${owner} on GitHub` };
186
+
187
+ const file = saveManifest({ ...manifest, groups, repos });
188
+ plain('');
189
+ ok(`catalogue written ${c.dim(`→ ${file}`)}`);
190
+ if (seeding) {
191
+ const on = repos.filter((r) => r.default).length;
192
+ skip(`${on} repos marked default — edit the file, or run \`talea sync --pick\` to choose`);
193
+ }
194
+ }
@@ -0,0 +1,142 @@
1
+ import { spawn } from 'node:child_process';
2
+ import path from 'node:path';
3
+
4
+ import { findWorkspace, loadManifest, loadState } from '../config.js';
5
+ import { git, gitVersion } from '../git.js';
6
+ import { token, whoami } from '../github.js';
7
+ import { c, heading, icon, plain, verdict } from '../log.js';
8
+ import { padEndVisible } from '../theme.js';
9
+ import { hasChosen, machineRepos } from '../workspace.js';
10
+
11
+ export const help = `
12
+ ${c.bold('talea doctor')} — check that this machine can actually do the work
13
+
14
+ ${c.dim('talea doctor')}
15
+
16
+ Verifies Node, git, SSH access to GitHub, the API token, and the workspace it
17
+ found. Run this first when a clone fails.
18
+ `;
19
+
20
+ const PASS = icon.ok;
21
+ const FAIL = icon.fail;
22
+ const WARN = icon.warn;
23
+
24
+ /**
25
+ * `ssh -T git@github.com` exits non-zero even when it works — GitHub
26
+ * authenticates you and then refuses the shell. So the signal is the text, not
27
+ * the exit code.
28
+ */
29
+ function sshProbe(host) {
30
+ return new Promise((resolve) => {
31
+ const child = spawn(
32
+ 'ssh',
33
+ ['-T', '-o', 'StrictHostKeyChecking=accept-new', '-o', 'ConnectTimeout=10', host],
34
+ { stdio: ['ignore', 'pipe', 'pipe'], shell: false },
35
+ );
36
+ let out = '';
37
+ child.stdout.on('data', (d) => (out += d));
38
+ child.stderr.on('data', (d) => (out += d));
39
+ child.on('error', (e) => resolve({ ok: false, message: e.message }));
40
+ child.on('close', () => {
41
+ const text = out.trim();
42
+ const authed = /successfully authenticated|shell access|You've successfully|Welcome/i.test(text);
43
+ const denied = /permission denied|publickey/i.test(text);
44
+ resolve({ ok: authed && !denied, message: text.split('\n')[0] ?? 'no response' });
45
+ });
46
+ setTimeout(() => child.kill(), 15000);
47
+ });
48
+ }
49
+
50
+ export async function run() {
51
+ heading('talea doctor');
52
+
53
+ const checks = [];
54
+
55
+ const nodeMajor = Number(process.versions.node.split('.')[0]);
56
+ checks.push([
57
+ nodeMajor >= 20 ? PASS : FAIL,
58
+ 'Node.js',
59
+ `v${process.versions.node}${nodeMajor >= 20 ? '' : c.red(' (need >= 20)')}`,
60
+ ]);
61
+
62
+ const gv = await gitVersion();
63
+ checks.push([gv ? PASS : FAIL, 'git', gv ?? c.red('not found on PATH')]);
64
+
65
+ const user = await git(['config', '--get', 'user.email']);
66
+ checks.push([
67
+ user.stdout ? PASS : WARN,
68
+ 'git user.email',
69
+ user.stdout || c.yellow('not set — commits will be attributed oddly'),
70
+ ]);
71
+
72
+ const gh = await sshProbe('git@github.com');
73
+ checks.push([
74
+ gh.ok ? PASS : FAIL,
75
+ `SSH ${icon.arrow} GitHub`,
76
+ gh.ok ? c.dim(gh.message) : c.red(gh.message),
77
+ ]);
78
+
79
+ // The token is for reading the catalogue, not for cloning — so no token is a
80
+ // warning, never a failure. SSH is what clones private repos.
81
+ const { token: tok, from } = token();
82
+ let login = null;
83
+ if (tok) {
84
+ try {
85
+ login = await whoami(tok);
86
+ } catch (err) {
87
+ login = c.red(err.message);
88
+ }
89
+ }
90
+ checks.push([
91
+ tok ? PASS : WARN,
92
+ 'GitHub API token',
93
+ tok
94
+ ? `${c.dim(from)}${login ? ` ${icon.arrow} ${c.bold(login)}` : ''}`
95
+ : c.yellow('none — `talea discover` will see public repos only'),
96
+ ]);
97
+
98
+ const root = findWorkspace();
99
+ const manifest = loadManifest(root);
100
+ checks.push([root ? PASS : WARN, 'workspace', root ?? c.yellow('none found — run `talea init`')]);
101
+ checks.push([
102
+ manifest.repos.length ? PASS : WARN,
103
+ 'catalogue',
104
+ manifest.repos.length
105
+ ? `${manifest.repos.length} repos ${c.dim(manifest.__source)}`
106
+ : c.yellow('empty — run `talea discover`'),
107
+ ]);
108
+
109
+ if (root) {
110
+ const state = loadState(root);
111
+ checks.push([
112
+ PASS,
113
+ 'this machine keeps',
114
+ hasChosen(state)
115
+ ? `${machineRepos(manifest, state).length} repos`
116
+ : c.dim(`${machineRepos(manifest, state).length} by default — not chosen yet`),
117
+ ]);
118
+ }
119
+
120
+ plain('');
121
+ for (const [mark, label, detail] of checks) {
122
+ plain(` ${mark} ${padEndVisible(label, 22)} ${detail}`);
123
+ }
124
+
125
+ const broken = checks.filter(([m]) => m === FAIL).length;
126
+ verdict(
127
+ { failed: broken },
128
+ {
129
+ clear: 'ALL CLEAR · this machine can do the work',
130
+ trouble: `${broken} problem(s) to fix before cloning`,
131
+ },
132
+ );
133
+
134
+ if (!gh.ok) {
135
+ plain(
136
+ c.dim(
137
+ '\nGitHub SSH: add your public key at https://github.com/settings/keys,\n' +
138
+ ' or run `talea sync --protocol https` to clone over HTTPS instead.',
139
+ ),
140
+ );
141
+ }
142
+ }
@@ -0,0 +1,78 @@
1
+ import { spawn } from 'node:child_process';
2
+ import path from 'node:path';
3
+
4
+ import { c, fail, heading, info, ok, plain, summary } from '../log.js';
5
+ import { clonedOnly, machineRepos, requireWorkspace, selectRepos, withPaths } from '../workspace.js';
6
+
7
+ export const help = `
8
+ ${c.bold('talea exec')} — run one command in every repo
9
+
10
+ ${c.dim('talea exec -- git log --oneline -1')}
11
+ ${c.dim('talea exec -g nonstopio -- npm install')}
12
+ ${c.dim('talea exec -- git branch --show-current')}
13
+
14
+ Everything after ${c.bold('--')} is the command. It runs once per repo with the
15
+ repo as the working directory.
16
+
17
+ Options
18
+ -g, --group <names> comma-separated groups
19
+ -r, --repo <names> comma-separated repo names
20
+ --all every repo in the catalogue, not just what this machine keeps
21
+
22
+ Repos are processed one at a time so the output stays readable and attributable.
23
+ `;
24
+
25
+ function runOne(command, args, cwd) {
26
+ return new Promise((resolve) => {
27
+ // shell:true so developers can write `talea exec -- yarn build && yarn test`
28
+ // and get the shell semantics they expect on their own platform.
29
+ const child = spawn([command, ...args].join(' '), {
30
+ cwd,
31
+ shell: true,
32
+ stdio: ['ignore', 'pipe', 'pipe'],
33
+ });
34
+ let out = '';
35
+ child.stdout.on('data', (d) => (out += d));
36
+ child.stderr.on('data', (d) => (out += d));
37
+ child.on('error', (e) => resolve({ code: -1, out: e.message }));
38
+ child.on('close', (code) => resolve({ code, out: out.trimEnd() }));
39
+ });
40
+ }
41
+
42
+ export async function run(opts, positionals) {
43
+ const [command, ...args] = positionals;
44
+ if (!command) {
45
+ fail('Nothing to run. Put the command after `--`, e.g. `talea exec -- git status`.');
46
+ process.exit(1);
47
+ }
48
+
49
+ const { root, manifest, state } = requireWorkspace();
50
+ const pool = opts.all ? manifest.repos : machineRepos(manifest, state);
51
+ const entries = clonedOnly(withPaths(manifest, root, selectRepos(manifest, opts, pool)));
52
+
53
+ heading(`${c.bold([command, ...args].join(' '))} ${c.dim(`in ${entries.length} repos`)}`);
54
+
55
+ const counts = { ok: 0, skipped: 0, failed: 0, okLabel: 'ok' };
56
+
57
+ for (const { repo, dir } of entries) {
58
+ const res = await runOne(command, args, dir);
59
+ const label = `${c.bold(repo.name)} ${c.dim(path.relative(root, dir))}`;
60
+ if (res.code === 0) {
61
+ counts.ok++;
62
+ ok(label);
63
+ } else {
64
+ counts.failed++;
65
+ fail(`${label} ${c.dim(`(exit ${res.code})`)}`);
66
+ }
67
+ if (res.out) {
68
+ plain(
69
+ res.out
70
+ .split('\n')
71
+ .map((l) => ' ' + c.dim(l))
72
+ .join('\n'),
73
+ );
74
+ }
75
+ }
76
+
77
+ summary(counts);
78
+ }
@@ -0,0 +1,106 @@
1
+ import { existsSync, mkdirSync } from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ import {
5
+ STATE_FILE,
6
+ expandHome,
7
+ groupDir,
8
+ loadManifest,
9
+ loadState,
10
+ repoGroup,
11
+ saveState,
12
+ } from '../config.js';
13
+ import { machineRepos } from '../workspace.js';
14
+ import { c, context, heading, info, ok, plain, skip, warn } from '../log.js';
15
+ import { run as discover } from './discover.js';
16
+ import { run as sync } from './sync.js';
17
+
18
+ export const help = `
19
+ ${c.bold('talea init')} — set this machine up
20
+
21
+ ${c.dim('talea init')} use the current folder as the workspace
22
+ ${c.dim('talea init ~/Workspace')} use that folder instead
23
+ ${c.dim('talea init --no-clone')} write the config, clone later
24
+
25
+ Creates the workspace, records this machine's preferences in ${STATE_FILE}, asks
26
+ what this machine should keep, and fills it.
27
+
28
+ If the catalogue is empty, ${c.dim('talea discover')} runs first — so on your very first
29
+ machine this one command goes from nothing to a folder tree.
30
+
31
+ Options
32
+ --protocol <p> ssh (default) or https
33
+ --no-clone create the workspace without cloning
34
+ --pick go straight to the checklist, skipping the defaults offer
35
+ -g, --group <names> restrict to these groups
36
+ -j, --jobs <n> parallel clones
37
+
38
+ ${c.bold('On your second machine')}, pull the catalogue first:
39
+
40
+ ${c.dim('talea manifest pull <gist-id>')}
41
+ ${c.dim('talea init ~/Workspace')}
42
+ `;
43
+
44
+ export async function run(opts, positionals = []) {
45
+ const target = path.resolve(expandHome(positionals[0] ?? process.cwd()));
46
+ const stateFile = path.join(target, STATE_FILE);
47
+ const fresh = !existsSync(stateFile);
48
+
49
+ heading(fresh ? `Creating a workspace at ${target}` : `Workspace already at ${target}`);
50
+
51
+ mkdirSync(target, { recursive: true });
52
+
53
+ if (fresh) {
54
+ // Written before anything else: every other command finds the workspace by
55
+ // walking up to this file, so until it exists `talea sync` run from inside
56
+ // the folder we just made would not know it is in one.
57
+ saveState(target, { protocol: opts.protocol ?? 'ssh', createdAt: new Date().toISOString() });
58
+ ok(`${STATE_FILE} ${c.dim('— this machine’s own state, never shared')}`);
59
+ } else {
60
+ skip(`${STATE_FILE} already here, leaving it alone`);
61
+ }
62
+
63
+ let manifest = loadManifest(target);
64
+
65
+ if (!manifest.repos.length) {
66
+ info('The catalogue is empty — discovering your repos from GitHub.');
67
+ plain('');
68
+ await discover({ ...opts, apply: true });
69
+ manifest = loadManifest(target);
70
+ if (!manifest.repos.length) {
71
+ warn('Still nothing in the catalogue. Nothing to clone.');
72
+ return;
73
+ }
74
+ }
75
+
76
+ const groups = [...new Set(manifest.repos.map((r) => repoGroup(r)))];
77
+ context([
78
+ ['catalogue', c.dim(manifest.__source)],
79
+ ['repos', `${manifest.repos.length}`],
80
+ ['groups', `${groups.length}`],
81
+ ]);
82
+
83
+ // The group folders for the repos this machine will actually hold — not one
84
+ // per owner in the catalogue. A collaborator repo you never clone should not
85
+ // leave an empty folder in the tree forever.
86
+ const mine = machineRepos(manifest, loadState(target));
87
+ for (const g of new Set(mine.map((r) => repoGroup(r)))) {
88
+ mkdirSync(path.join(target, groupDir(manifest, g)), { recursive: true });
89
+ }
90
+
91
+ if (opts.clone === false) {
92
+ plain(`\n${c.dim('Workspace ready. Run `talea sync` when you want the repos.')}`);
93
+ return;
94
+ }
95
+
96
+ // `sync` finds the workspace by walking up from cwd, and cwd is wherever the
97
+ // developer typed the command — which is not necessarily inside the folder
98
+ // they just named.
99
+ const back = process.cwd();
100
+ try {
101
+ process.chdir(target);
102
+ await sync(opts);
103
+ } finally {
104
+ process.chdir(back);
105
+ }
106
+ }
@@ -0,0 +1,100 @@
1
+ import { findWorkspace, groupDir, loadManifest, loadState, repoGroup } from '../config.js';
2
+ import { c, glyph, group, heading, plain, table } from '../log.js';
3
+ import { machineRepos, selectRepos } from '../workspace.js';
4
+
5
+ export const help = `
6
+ ${c.bold('talea list')} — show the catalogue
7
+
8
+ ${c.dim('talea list')} every repo, grouped by folder
9
+ ${c.dim('talea list -g nonstopio')} one owner
10
+ ${c.dim('talea list --groups')} just the group summary
11
+ ${c.dim('talea list --json')} machine-readable output
12
+
13
+ The ${c.bold('KEEP')} column is this machine: ${glyph.ok} kept, blank not. The ${c.bold('DEFAULT')} column is
14
+ the catalogue's opinion — what a brand new machine would start with.
15
+
16
+ Options
17
+ -g, --group <names> comma-separated groups
18
+ -r, --repo <names> comma-separated repo names
19
+ --all include archived and quiet repos (default: hidden)
20
+ --groups show groups only
21
+ --json emit JSON
22
+ `;
23
+
24
+ export function run(opts) {
25
+ // Readable from anywhere. Listing the catalogue is not a workspace operation
26
+ // — on a fresh machine it is the thing you run *before* `talea init`, to see
27
+ // what you are about to be offered.
28
+ const root = findWorkspace();
29
+ const manifest = loadManifest(root);
30
+ const state = root ? loadState(root) : {};
31
+ const kept = new Set(machineRepos(manifest, state).map((r) => r.name));
32
+
33
+ let repos = selectRepos(manifest, opts, manifest.repos);
34
+ if (!opts.all) repos = repos.filter((r) => !r.archived || kept.has(r.name));
35
+
36
+ if (opts.json) {
37
+ // Data, so stdout and nothing else — this is what a script reads.
38
+ process.stdout.write(
39
+ JSON.stringify(
40
+ repos.map((r) => ({ ...r, group: repoGroup(r), kept: kept.has(r.name) })),
41
+ null,
42
+ 2,
43
+ ) + '\n',
44
+ );
45
+ return;
46
+ }
47
+
48
+ const byGroup = new Map();
49
+ for (const r of repos) {
50
+ const g = repoGroup(r);
51
+ if (!byGroup.has(g)) byGroup.set(g, []);
52
+ byGroup.get(g).push(r);
53
+ }
54
+
55
+ if (opts.groups) {
56
+ heading('Groups');
57
+ table(
58
+ [...byGroup.entries()].map(([g, members]) => [
59
+ c.bold(`${groupDir(manifest, g)}/`),
60
+ `${members.length}`,
61
+ c.dim(`${members.filter((r) => kept.has(r.name)).length} kept here`),
62
+ ]),
63
+ ['GROUP', 'REPOS', ''],
64
+ );
65
+ return;
66
+ }
67
+
68
+ heading(`Catalogue ${c.dim(manifest.__source)}`);
69
+
70
+ for (const [g, members] of byGroup) {
71
+ plain('');
72
+ group(`${groupDir(manifest, g)}/`, members.length);
73
+ table(
74
+ members.map((r) => [
75
+ ' ' + (kept.has(r.name) ? c.bold(r.name) : c.dim(r.name)),
76
+ kept.has(r.name) ? c.green(glyph.ok) : c.dim(''),
77
+ r.default ? c.dim('default') : c.dim(''),
78
+ c.dim(r.defaultBranch ?? '?'),
79
+ [
80
+ r.private ? c.dim('private') : '',
81
+ r.fork ? c.dim('fork') : '',
82
+ r.archived ? c.yellow('quiet') : '',
83
+ r.missing ? c.yellow('not on github') : '',
84
+ ]
85
+ .filter(Boolean)
86
+ .join(' '),
87
+ ]),
88
+ [' REPO', 'KEEP', 'DEFAULT', 'BRANCH', ''],
89
+ );
90
+ }
91
+
92
+ const hidden = manifest.repos.length - repos.length;
93
+ plain('');
94
+ plain(
95
+ c.dim(
96
+ `${repos.length} shown, ${kept.size} kept on this machine` +
97
+ (hidden > 0 ? `, ${hidden} quiet (--all to show)` : ''),
98
+ ),
99
+ );
100
+ }