@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
package/src/cli.js
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { parseArgs } from 'node:util';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
import { c, fail, plain } from './log.js';
|
|
7
|
+
|
|
8
|
+
import * as init from './commands/init.js';
|
|
9
|
+
import * as discover from './commands/discover.js';
|
|
10
|
+
import * as clone from './commands/clone.js';
|
|
11
|
+
import * as adopt from './commands/adopt.js';
|
|
12
|
+
import * as sync from './commands/sync.js';
|
|
13
|
+
import * as status from './commands/status.js';
|
|
14
|
+
import * as list from './commands/list.js';
|
|
15
|
+
import * as tree from './commands/tree.js';
|
|
16
|
+
import * as where from './commands/where.js';
|
|
17
|
+
import * as add from './commands/add.js';
|
|
18
|
+
import * as manifest from './commands/manifest.js';
|
|
19
|
+
import * as doctor from './commands/doctor.js';
|
|
20
|
+
import * as exec from './commands/exec.js';
|
|
21
|
+
import * as upgrade from './commands/upgrade.js';
|
|
22
|
+
import { notifyIfOutdatedAsync } from './update.js';
|
|
23
|
+
|
|
24
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
25
|
+
const pkg = JSON.parse(readFileSync(path.join(here, '..', 'package.json'), 'utf8'));
|
|
26
|
+
|
|
27
|
+
const COMMANDS = {
|
|
28
|
+
init,
|
|
29
|
+
discover,
|
|
30
|
+
clone,
|
|
31
|
+
adopt,
|
|
32
|
+
sync,
|
|
33
|
+
status,
|
|
34
|
+
list,
|
|
35
|
+
tree,
|
|
36
|
+
where,
|
|
37
|
+
add,
|
|
38
|
+
rm: add,
|
|
39
|
+
manifest,
|
|
40
|
+
doctor,
|
|
41
|
+
exec,
|
|
42
|
+
upgrade,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// Aliases keep the muscle memory people already have from git and from every
|
|
46
|
+
// other tool that does one of these jobs.
|
|
47
|
+
const ALIASES = {
|
|
48
|
+
setup: 'init',
|
|
49
|
+
bootstrap: 'init',
|
|
50
|
+
pull: 'sync',
|
|
51
|
+
update: 'sync',
|
|
52
|
+
refresh: 'discover',
|
|
53
|
+
ls: 'list',
|
|
54
|
+
st: 'status',
|
|
55
|
+
cd: 'where',
|
|
56
|
+
path: 'where',
|
|
57
|
+
run: 'exec',
|
|
58
|
+
remove: 'rm',
|
|
59
|
+
'self-update': 'upgrade',
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const OPTIONS = {
|
|
63
|
+
group: { type: 'string', short: 'g', multiple: true },
|
|
64
|
+
repo: { type: 'string', short: 'r', multiple: true },
|
|
65
|
+
jobs: { type: 'string', short: 'j' },
|
|
66
|
+
protocol: { type: 'string' },
|
|
67
|
+
from: { type: 'string', multiple: true },
|
|
68
|
+
user: { type: 'string' },
|
|
69
|
+
since: { type: 'string' },
|
|
70
|
+
gist: { type: 'string' },
|
|
71
|
+
apply: { type: 'boolean', default: false },
|
|
72
|
+
'fix-paths': { type: 'boolean', default: false },
|
|
73
|
+
// parseArgs has no --no-x negation, so the negative flags are declared
|
|
74
|
+
// explicitly and inverted below.
|
|
75
|
+
'no-adopt': { type: 'boolean', default: false },
|
|
76
|
+
'no-clone': { type: 'boolean', default: false },
|
|
77
|
+
'no-archived': { type: 'boolean', default: false },
|
|
78
|
+
check: { type: 'boolean', default: false },
|
|
79
|
+
on: { type: 'boolean', default: false },
|
|
80
|
+
off: { type: 'boolean', default: false },
|
|
81
|
+
pick: { type: 'boolean', default: false },
|
|
82
|
+
drift: { type: 'boolean', default: false },
|
|
83
|
+
missing: { type: 'boolean', default: false },
|
|
84
|
+
all: { type: 'boolean', default: false },
|
|
85
|
+
groups: { type: 'boolean', default: false },
|
|
86
|
+
json: { type: 'boolean', default: false },
|
|
87
|
+
yes: { type: 'boolean', short: 'y', default: false },
|
|
88
|
+
help: { type: 'boolean', short: 'h', default: false },
|
|
89
|
+
version: { type: 'boolean', short: 'v', default: false },
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const USAGE = `
|
|
93
|
+
${c.bold('talea')} ${c.dim(`v${pkg.version}`)} — one folder structure for every machine
|
|
94
|
+
|
|
95
|
+
${c.bold('Usage')}
|
|
96
|
+
talea <command> [options]
|
|
97
|
+
|
|
98
|
+
${c.bold('Commands')}
|
|
99
|
+
${c.cyan('init')} create the workspace on this machine and fill it
|
|
100
|
+
${c.cyan('discover')} build the catalogue from your GitHub account
|
|
101
|
+
${c.cyan('sync')} clone what is missing, fast-forward what is there
|
|
102
|
+
${c.cyan('clone')} clone only — never fetches or merges
|
|
103
|
+
${c.cyan('adopt')} move repos you already have into the right place
|
|
104
|
+
${c.cyan('status')} one table: branch, clean/dirty, ahead/behind
|
|
105
|
+
${c.cyan('add')} keep another repo on this machine (${c.dim('rm')} to drop one)
|
|
106
|
+
${c.cyan('where')} print a repo's path — ${c.dim('cd $(talea where eklavya)')}
|
|
107
|
+
${c.cyan('list')} show the catalogue
|
|
108
|
+
${c.cyan('tree')} the folder tree on disk
|
|
109
|
+
${c.cyan('exec')} run one command in every repo
|
|
110
|
+
${c.cyan('manifest')} push or pull the catalogue through a private gist
|
|
111
|
+
${c.cyan('doctor')} check git, SSH, GitHub auth and workspace health
|
|
112
|
+
${c.cyan('upgrade')} update the CLI itself
|
|
113
|
+
|
|
114
|
+
${c.bold('A new machine')}
|
|
115
|
+
talea doctor ${c.dim('# confirm git and GitHub work first')}
|
|
116
|
+
talea manifest pull <id> ${c.dim('# your catalogue, from the gist')}
|
|
117
|
+
talea init ~/Workspace ${c.dim('# pick what this machine keeps, then fill it')}
|
|
118
|
+
|
|
119
|
+
${c.bold('The first machine')}
|
|
120
|
+
talea init ~/Workspace ${c.dim('# discovers your repos if the catalogue is empty')}
|
|
121
|
+
talea manifest push ${c.dim('# publish it so the next machine can read it')}
|
|
122
|
+
|
|
123
|
+
${c.bold('Every day')}
|
|
124
|
+
talea sync ${c.dim('# clone the new, fast-forward the rest')}
|
|
125
|
+
talea status --drift ${c.dim('# what is not where I left it')}
|
|
126
|
+
|
|
127
|
+
${c.bold('Common options')}
|
|
128
|
+
-g, --group <names> restrict to groups, e.g. -g nonstopio
|
|
129
|
+
-r, --repo <names> restrict to repos, e.g. -r eklavya
|
|
130
|
+
-j, --jobs <n> how many git operations run at once (default: 6-12)
|
|
131
|
+
-h, --help help for any command: talea <command> --help
|
|
132
|
+
`;
|
|
133
|
+
|
|
134
|
+
export async function main(argv) {
|
|
135
|
+
// Split on `--` so `talea exec -- git log` passes the tail through verbatim
|
|
136
|
+
// instead of parseArgs trying to interpret git's own flags.
|
|
137
|
+
const sepIndex = argv.indexOf('--');
|
|
138
|
+
const head = sepIndex === -1 ? argv : argv.slice(0, sepIndex);
|
|
139
|
+
const tail = sepIndex === -1 ? [] : argv.slice(sepIndex + 1);
|
|
140
|
+
|
|
141
|
+
let parsed;
|
|
142
|
+
try {
|
|
143
|
+
parsed = parseArgs({ args: head, options: OPTIONS, allowPositionals: true, strict: true });
|
|
144
|
+
} catch (err) {
|
|
145
|
+
fail(err.message);
|
|
146
|
+
plain(c.dim('\nRun `talea --help` for usage.'));
|
|
147
|
+
process.exit(1);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const { values, positionals } = parsed;
|
|
151
|
+
|
|
152
|
+
if (values.version) {
|
|
153
|
+
console.log(pkg.version);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const name = positionals[0];
|
|
158
|
+
if (!name) {
|
|
159
|
+
console.log(USAGE);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const key = ALIASES[name] ?? name;
|
|
164
|
+
const command = COMMANDS[key];
|
|
165
|
+
|
|
166
|
+
if (!command) {
|
|
167
|
+
fail(`Unknown command "${name}".`);
|
|
168
|
+
plain(`\n Available: ${Object.keys(COMMANDS).join(', ')}`);
|
|
169
|
+
process.exit(1);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (values.help) {
|
|
173
|
+
console.log(command.help);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// `talea sync all` — "all" is how people say it and it is already the
|
|
178
|
+
// default, so accept it as a no-op rather than an error.
|
|
179
|
+
const rest = positionals.slice(1).filter((p) => !(p === 'all' && key !== 'exec'));
|
|
180
|
+
|
|
181
|
+
// -j reaches `pooled()` as a worker count. `Math.min(NaN, n)` is NaN, so a
|
|
182
|
+
// non-numeric value silently started zero workers and the command reported
|
|
183
|
+
// success having done nothing at all.
|
|
184
|
+
let jobs;
|
|
185
|
+
if (values.jobs !== undefined) {
|
|
186
|
+
jobs = Number(values.jobs);
|
|
187
|
+
if (!Number.isInteger(jobs) || jobs < 1) {
|
|
188
|
+
fail(`--jobs must be a positive whole number, got "${values.jobs}".`);
|
|
189
|
+
process.exit(1);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const opts = {
|
|
194
|
+
...values,
|
|
195
|
+
jobs,
|
|
196
|
+
// `rm` is `add` with the sign flipped; the command reads this rather than
|
|
197
|
+
// being a near-copy of the same forty lines.
|
|
198
|
+
removing: key === 'rm' || name === 'rm' || name === 'remove',
|
|
199
|
+
clone: !values['no-clone'],
|
|
200
|
+
adopt: !values['no-adopt'],
|
|
201
|
+
archived: !values['no-archived'],
|
|
202
|
+
// -g/-r are `multiple`, so they arrive as arrays; leave them for
|
|
203
|
+
// selectRepos to flatten, but normalise "not passed" to undefined.
|
|
204
|
+
group: values.group?.length ? values.group : undefined,
|
|
205
|
+
from: values.from?.length ? values.from : undefined,
|
|
206
|
+
repo: values.repo?.length ? values.repo : undefined,
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
await command.run(opts, key === 'exec' ? tail : rest);
|
|
210
|
+
|
|
211
|
+
// After the real work, never before it, and never able to fail it.
|
|
212
|
+
if (key !== 'upgrade') {
|
|
213
|
+
try {
|
|
214
|
+
await notifyIfOutdatedAsync();
|
|
215
|
+
} catch {
|
|
216
|
+
// An update check is not worth a non-zero exit.
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// `talea add` / `talea rm` — change what this machine keeps, one repo at a time.
|
|
2
|
+
//
|
|
3
|
+
// One file for both because they are one operation with a sign. `rm` never
|
|
4
|
+
// deletes a checkout: it takes the repo off this machine's list and says where
|
|
5
|
+
// the folder still is, because removing a directory full of somebody's work is
|
|
6
|
+
// not a thing a CLI should do on the back of a three-letter command.
|
|
7
|
+
|
|
8
|
+
import { existsSync } from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
|
|
11
|
+
import { repoDir, saveState } from '../config.js';
|
|
12
|
+
import { defaultJobs } from '../git.js';
|
|
13
|
+
import { c, fail, heading, info, ok, plain, skip } from '../log.js';
|
|
14
|
+
import { machineRepos, requireCatalogue, requireWorkspace, withPaths } from '../workspace.js';
|
|
15
|
+
import { adoptInPlace, cloneMissing, writeDocs } from './clone.js';
|
|
16
|
+
|
|
17
|
+
export const help = `
|
|
18
|
+
${c.bold('talea add')} — keep another repo on this machine
|
|
19
|
+
|
|
20
|
+
${c.dim('talea add eklavya')} add it to the list and clone it now
|
|
21
|
+
${c.dim('talea add nonstopio/json-viewer')} when two owners have the same repo name
|
|
22
|
+
${c.dim('talea rm eklavya')} take it off the list — the folder stays
|
|
23
|
+
|
|
24
|
+
${c.dim('add')} is the escape hatch from the checklist: one repo you want on this machine
|
|
25
|
+
without re-opening the picker or editing JSON. It writes to ${c.dim('.talea.json')}, which
|
|
26
|
+
is this machine's file, so it never changes what any other machine keeps.
|
|
27
|
+
|
|
28
|
+
${c.dim('rm')} removes the repo from the list and stops there. The checkout is left
|
|
29
|
+
exactly where it is and the path is printed — deleting it is your call, and
|
|
30
|
+
${c.dim('rm -rf')} already exists for when you mean it.
|
|
31
|
+
|
|
32
|
+
Options
|
|
33
|
+
--protocol <p> ssh (default) or https
|
|
34
|
+
-j, --jobs <n> parallel clones
|
|
35
|
+
`;
|
|
36
|
+
|
|
37
|
+
/** Resolve `name` or `owner/name` against the catalogue, or exit saying why. */
|
|
38
|
+
export function resolve(manifest, name) {
|
|
39
|
+
const wanted = String(name).toLowerCase();
|
|
40
|
+
const matches = manifest.repos.filter(
|
|
41
|
+
(r) => r.name.toLowerCase() === wanted || `${r.owner}/${r.name}`.toLowerCase() === wanted,
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
if (!matches.length) {
|
|
45
|
+
fail(`No repo called "${name}" in the catalogue.`);
|
|
46
|
+
console.error('\n Run `talea discover --apply` if it is new, or `talea list` to see what is there.');
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
if (matches.length > 1) {
|
|
50
|
+
fail(`"${name}" is ambiguous — name the owner too, e.g. ${matches[0].owner}/${matches[0].name}`);
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
return matches[0];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function run(opts, positionals = []) {
|
|
57
|
+
const { root, manifest, state } = requireWorkspace();
|
|
58
|
+
requireCatalogue(manifest);
|
|
59
|
+
|
|
60
|
+
if (!positionals.length) {
|
|
61
|
+
fail(`Nothing named. ${opts.removing ? 'talea rm <repo>' : 'talea add <repo>'}`);
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const targets = positionals.map((n) => resolve(manifest, n));
|
|
66
|
+
|
|
67
|
+
// The current list, made explicit. Until now this machine may have been
|
|
68
|
+
// running on the catalogue's defaults; the moment it adds or removes one it
|
|
69
|
+
// has an opinion, and that opinion has to be written down or the next sync
|
|
70
|
+
// would silently undo it.
|
|
71
|
+
const current = new Map(machineRepos(manifest, state).map((r) => [r.name, r]));
|
|
72
|
+
|
|
73
|
+
if (opts.removing) {
|
|
74
|
+
heading('Removing from this machine');
|
|
75
|
+
for (const repo of targets) {
|
|
76
|
+
if (!current.delete(repo.name)) {
|
|
77
|
+
skip(`${c.bold(repo.name)} was not on this machine's list`);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const dir = repoDir(manifest, root, repo);
|
|
81
|
+
ok(`${c.bold(repo.name)} ${c.dim('off the list')}`);
|
|
82
|
+
if (existsSync(dir)) {
|
|
83
|
+
plain(` ${c.dim(`the checkout is still at ${path.relative(root, dir)} — delete it yourself if you want it gone`)}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
saveState(root, { ...state, selected: [...current.keys()] });
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
heading('Adding to this machine');
|
|
91
|
+
const added = [];
|
|
92
|
+
for (const repo of targets) {
|
|
93
|
+
if (current.has(repo.name)) {
|
|
94
|
+
skip(`${c.bold(repo.name)} is already on this machine's list`);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
current.set(repo.name, repo);
|
|
98
|
+
added.push(repo);
|
|
99
|
+
ok(`${c.bold(repo.name)} ${c.dim(`→ ${path.relative(root, repoDir(manifest, root, repo))}`)}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
saveState(root, { ...state, selected: [...current.keys()] });
|
|
103
|
+
if (!added.length) return;
|
|
104
|
+
|
|
105
|
+
// Adopt first, in case the repo being added is one already sitting somewhere
|
|
106
|
+
// else on this disk — that is the common case for `add`, not the rare one.
|
|
107
|
+
const adoption = await adoptInPlace({
|
|
108
|
+
manifest,
|
|
109
|
+
root,
|
|
110
|
+
state: { ...state, selected: [...current.keys()] },
|
|
111
|
+
repos: added,
|
|
112
|
+
opts,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
writeDocs(manifest, root, added);
|
|
116
|
+
|
|
117
|
+
const todo = withPaths(manifest, root, added).filter(
|
|
118
|
+
(e) => !e.cloned && !adoption.skip.has(e.repo.name),
|
|
119
|
+
);
|
|
120
|
+
if (!todo.length) {
|
|
121
|
+
info('Nothing to clone — already on disk.');
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
plain('');
|
|
126
|
+
const counts = { ok: 0, skipped: 0, failed: 0, okLabel: 'cloned' };
|
|
127
|
+
await cloneMissing({
|
|
128
|
+
manifest,
|
|
129
|
+
root,
|
|
130
|
+
entries: todo,
|
|
131
|
+
protocol: opts.protocol ?? state.protocol ?? 'ssh',
|
|
132
|
+
jobs: opts.jobs ?? defaultJobs(),
|
|
133
|
+
counts,
|
|
134
|
+
});
|
|
135
|
+
if (counts.failed) process.exitCode = 1;
|
|
136
|
+
}
|