@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.
- package/README.md +174 -0
- package/bin/talea.js +11 -0
- package/manifest/talea.repos.json +11 -0
- package/package.json +39 -0
- package/src/adopt.js +983 -0
- package/src/cli.js +219 -0
- package/src/commands/add.js +136 -0
- package/src/commands/adopt.js +441 -0
- package/src/commands/clone.js +233 -0
- package/src/commands/discover.js +194 -0
- package/src/commands/doctor.js +142 -0
- package/src/commands/exec.js +78 -0
- package/src/commands/init.js +106 -0
- package/src/commands/list.js +100 -0
- package/src/commands/manifest.js +190 -0
- package/src/commands/status.js +114 -0
- package/src/commands/sync.js +203 -0
- package/src/commands/tree.js +103 -0
- package/src/commands/upgrade.js +84 -0
- package/src/commands/where.js +67 -0
- package/src/config.js +155 -0
- package/src/docs.js +122 -0
- package/src/git.js +291 -0
- package/src/github.js +208 -0
- package/src/live.js +197 -0
- package/src/log.js +190 -0
- package/src/prompt.js +375 -0
- package/src/select.js +97 -0
- package/src/theme.js +138 -0
- package/src/update.js +119 -0
- package/src/workspace.js +116 -0
- package/templates/.gitkeep +0 -0
|
@@ -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
|
+
}
|