@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,190 @@
|
|
|
1
|
+
// The catalogue's trip between machines.
|
|
2
|
+
//
|
|
3
|
+
// A private gist, and nothing else: no server to run, no account to create, no
|
|
4
|
+
// extra repo to remember to commit. It is reachable with the token the machine
|
|
5
|
+
// already has for `discover`, it has a URL you can paste into the next laptop,
|
|
6
|
+
// and it keeps a revision history for free.
|
|
7
|
+
|
|
8
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
MANIFEST_NAME,
|
|
13
|
+
USER_MANIFEST,
|
|
14
|
+
findWorkspace,
|
|
15
|
+
loadManifest,
|
|
16
|
+
readUserState,
|
|
17
|
+
writeUserState,
|
|
18
|
+
} from '../config.js';
|
|
19
|
+
import { createGist, readGist, token, updateGist } from '../github.js';
|
|
20
|
+
import { c, context, fail, heading, info, ok, plain, warn } from '../log.js';
|
|
21
|
+
|
|
22
|
+
export const help = `
|
|
23
|
+
${c.bold('talea manifest')} — move the catalogue between machines
|
|
24
|
+
|
|
25
|
+
${c.dim('talea manifest push')} publish it to a private gist
|
|
26
|
+
${c.dim('talea manifest pull')} fetch the one this machine is linked to
|
|
27
|
+
${c.dim('talea manifest pull <gist-id>')} link this machine to a gist and fetch it
|
|
28
|
+
${c.dim('talea manifest where')} which file is in use, and which gist
|
|
29
|
+
|
|
30
|
+
The catalogue is the only thing that travels. What each machine *keeps* stays
|
|
31
|
+
in that machine's ${c.dim('.talea.json')} and is never published — so pulling on a new
|
|
32
|
+
laptop gives you the full list to choose from, not the last machine's choices.
|
|
33
|
+
|
|
34
|
+
The gist is ${c.bold('private')}. It still holds the names of your private repositories,
|
|
35
|
+
so treat the id like a bookmark you would not paste into a public channel.
|
|
36
|
+
|
|
37
|
+
The id is remembered in ${c.dim('~/.talea/state.json')}, so after the first ${c.dim('pull <id>')}
|
|
38
|
+
every later ${c.dim('push')} and ${c.dim('pull')} needs no argument.
|
|
39
|
+
|
|
40
|
+
Options
|
|
41
|
+
--gist <id> use this gist for one command without remembering it
|
|
42
|
+
`;
|
|
43
|
+
|
|
44
|
+
const GIST_FILE = MANIFEST_NAME;
|
|
45
|
+
|
|
46
|
+
function requireToken() {
|
|
47
|
+
const { token: tok, from } = token();
|
|
48
|
+
if (!tok) {
|
|
49
|
+
fail('A GitHub token is needed to read or write a gist.');
|
|
50
|
+
console.error('\n Run `gh auth login`, or set GITHUB_TOKEN.');
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
return { tok, from };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The catalogue file this machine would load, and where it came from. */
|
|
57
|
+
function currentFile() {
|
|
58
|
+
const root = findWorkspace();
|
|
59
|
+
const manifest = loadManifest(root);
|
|
60
|
+
return { root, manifest, file: manifest.__source };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function push(opts) {
|
|
64
|
+
const { tok, from } = requireToken();
|
|
65
|
+
const { manifest, file } = currentFile();
|
|
66
|
+
|
|
67
|
+
if (!manifest.repos.length) {
|
|
68
|
+
fail('The catalogue is empty — there is nothing to publish.');
|
|
69
|
+
console.error('\n Run `talea discover --apply` first.');
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const state = readUserState();
|
|
74
|
+
const id = opts.gist ?? state.gist ?? null;
|
|
75
|
+
|
|
76
|
+
// Published from the file on disk, byte for byte, rather than from the parsed
|
|
77
|
+
// object — a round trip through JSON.parse would drop comments-by-convention,
|
|
78
|
+
// key order and anything a future version of the format adds that this
|
|
79
|
+
// version does not know to keep.
|
|
80
|
+
const content = readFileSync(file, 'utf8');
|
|
81
|
+
|
|
82
|
+
heading(id ? 'Updating the catalogue gist' : 'Publishing the catalogue');
|
|
83
|
+
context([
|
|
84
|
+
['auth', c.dim(from)],
|
|
85
|
+
['from', c.dim(file)],
|
|
86
|
+
['repos', `${manifest.repos.length}`],
|
|
87
|
+
]);
|
|
88
|
+
|
|
89
|
+
const gistId = id
|
|
90
|
+
? await updateGist({ token: tok, id, filename: GIST_FILE, content })
|
|
91
|
+
: await createGist({
|
|
92
|
+
token: tok,
|
|
93
|
+
filename: GIST_FILE,
|
|
94
|
+
content,
|
|
95
|
+
description: 'talea catalogue — the repos I keep, and where they go',
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
writeUserState({ ...state, gist: gistId });
|
|
99
|
+
|
|
100
|
+
plain('');
|
|
101
|
+
ok(`gist ${c.bold(gistId)}`);
|
|
102
|
+
plain(c.dim(` https://gist.github.com/${gistId}`));
|
|
103
|
+
plain('');
|
|
104
|
+
info(`On another machine: ${c.bold(`talea manifest pull ${gistId}`)}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function pull(opts, positionals) {
|
|
108
|
+
const { tok, from } = requireToken();
|
|
109
|
+
const state = readUserState();
|
|
110
|
+
const id = positionals[0] ?? opts.gist ?? state.gist ?? null;
|
|
111
|
+
|
|
112
|
+
if (!id) {
|
|
113
|
+
fail('No gist to pull from.');
|
|
114
|
+
console.error('\n Pass the id once — `talea manifest pull <gist-id>` — and it is remembered.');
|
|
115
|
+
process.exit(1);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
heading('Pulling the catalogue');
|
|
119
|
+
context([
|
|
120
|
+
['auth', c.dim(from)],
|
|
121
|
+
['gist', c.bold(id)],
|
|
122
|
+
]);
|
|
123
|
+
|
|
124
|
+
const content = await readGist({ token: tok, id, filename: GIST_FILE });
|
|
125
|
+
|
|
126
|
+
// Parsed before it is written, never after: a truncated download or somebody
|
|
127
|
+
// else's gist would otherwise land on top of a working catalogue and only
|
|
128
|
+
// fail on the next command, with the good copy already gone.
|
|
129
|
+
let parsed;
|
|
130
|
+
try {
|
|
131
|
+
parsed = JSON.parse(content);
|
|
132
|
+
} catch (err) {
|
|
133
|
+
fail(`That gist is not a catalogue — ${err.message}`);
|
|
134
|
+
process.exit(1);
|
|
135
|
+
}
|
|
136
|
+
if (!Array.isArray(parsed.repos)) {
|
|
137
|
+
fail('That gist has no `repos` array, so it is not a talea catalogue.');
|
|
138
|
+
process.exit(1);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
mkdirSync(path.dirname(USER_MANIFEST), { recursive: true });
|
|
142
|
+
writeFileSync(USER_MANIFEST, content.endsWith('\n') ? content : content + '\n');
|
|
143
|
+
writeUserState({ ...state, gist: id });
|
|
144
|
+
|
|
145
|
+
plain('');
|
|
146
|
+
ok(`${parsed.repos.length} repos ${c.dim(`→ ${USER_MANIFEST}`)}`);
|
|
147
|
+
|
|
148
|
+
const { file } = currentFile();
|
|
149
|
+
if (file !== USER_MANIFEST) {
|
|
150
|
+
warn(`A nearer catalogue is still winning: ${c.bold(file)}`);
|
|
151
|
+
plain(c.dim(' Delete or rename it if you meant to use the one you just pulled.'));
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
plain('');
|
|
156
|
+
info(`Next: ${c.bold('talea init ~/Workspace')} — or ${c.bold('talea sync --pick')} if you already have one.`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function where() {
|
|
160
|
+
const { root, manifest, file } = currentFile();
|
|
161
|
+
const { gist } = readUserState();
|
|
162
|
+
|
|
163
|
+
heading('Catalogue');
|
|
164
|
+
context([
|
|
165
|
+
['in use', c.bold(file)],
|
|
166
|
+
['repos', `${manifest.repos.length}`],
|
|
167
|
+
]);
|
|
168
|
+
plain('');
|
|
169
|
+
plain(` ${c.dim('workspace')} ${root ?? c.dim('none found')}`);
|
|
170
|
+
plain(` ${c.dim('gist')} ${gist ? `${gist} ${c.dim(`https://gist.github.com/${gist}`)}` : c.dim('not linked')}`);
|
|
171
|
+
plain(` ${c.dim('user copy')} ${USER_MANIFEST}`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export async function run(opts, positionals = []) {
|
|
175
|
+
const [verb, ...rest] = positionals;
|
|
176
|
+
|
|
177
|
+
switch (verb) {
|
|
178
|
+
case 'push':
|
|
179
|
+
return push(opts);
|
|
180
|
+
case 'pull':
|
|
181
|
+
return pull(opts, rest);
|
|
182
|
+
case 'where':
|
|
183
|
+
case undefined:
|
|
184
|
+
return where();
|
|
185
|
+
default:
|
|
186
|
+
fail(`Unknown: talea manifest ${verb}`);
|
|
187
|
+
console.error('\n Try: push, pull, where');
|
|
188
|
+
process.exit(1);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
|
|
3
|
+
import { defaultBranch, groupDir, repoGroup } from '../config.js';
|
|
4
|
+
import { aheadBehind, currentBranch, defaultJobs, isDirty, pooled } from '../git.js';
|
|
5
|
+
import { c, context, glyph, group, heading, plain, table } from '../log.js';
|
|
6
|
+
import { machineRepos, requireWorkspace, selectRepos, withPaths } from '../workspace.js';
|
|
7
|
+
|
|
8
|
+
export const help = `
|
|
9
|
+
${c.bold('talea status')} — one table showing where every repo stands
|
|
10
|
+
|
|
11
|
+
${c.dim('talea status')} every repo this machine keeps
|
|
12
|
+
${c.dim('talea status -g nonstopio')} only that owner
|
|
13
|
+
${c.dim('talea status --drift')} only repos not on their default branch
|
|
14
|
+
${c.dim('talea status --missing')} only repos not cloned yet
|
|
15
|
+
${c.dim('talea status --all')} the whole catalogue, not just this machine
|
|
16
|
+
|
|
17
|
+
Columns
|
|
18
|
+
REPO repository name
|
|
19
|
+
BRANCH the branch checked out right now
|
|
20
|
+
DEFAULT the repo's default branch (blank when you are on it)
|
|
21
|
+
STATE clean / dirty, and commits ahead or behind origin
|
|
22
|
+
|
|
23
|
+
Options
|
|
24
|
+
-g, --group <names> comma-separated groups
|
|
25
|
+
-r, --repo <names> comma-separated repo names
|
|
26
|
+
--drift only repos on some other branch
|
|
27
|
+
--missing only repos not cloned yet
|
|
28
|
+
--all ignore this machine's selection
|
|
29
|
+
`;
|
|
30
|
+
|
|
31
|
+
export async function run(opts) {
|
|
32
|
+
const { root, manifest, state } = requireWorkspace();
|
|
33
|
+
const pool = opts.all ? manifest.repos : machineRepos(manifest, state);
|
|
34
|
+
const entries = withPaths(manifest, root, selectRepos(manifest, opts, pool));
|
|
35
|
+
|
|
36
|
+
const rows = await pooled(entries, defaultJobs(), async ({ repo, dir, cloned }) => {
|
|
37
|
+
const home = defaultBranch(repo);
|
|
38
|
+
if (!cloned) {
|
|
39
|
+
return {
|
|
40
|
+
repo,
|
|
41
|
+
missing: true,
|
|
42
|
+
cells: [c.dim(repo.name), c.dim(glyph.rule), c.dim(home ?? glyph.rule), c.yellow('not cloned')],
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const [branch, dirty, delta] = await Promise.all([
|
|
47
|
+
currentBranch(dir),
|
|
48
|
+
isDirty(dir),
|
|
49
|
+
aheadBehind(dir),
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
// Drift here means "somewhere other than the default branch", which is a
|
|
53
|
+
// fact worth showing and not a problem to fix — most of the time it is
|
|
54
|
+
// exactly where the work is. `--drift` is a filter, never a warning.
|
|
55
|
+
const drift = home != null && branch !== home;
|
|
56
|
+
const bits = [dirty ? c.yellow('dirty') : c.dim('clean')];
|
|
57
|
+
if (delta?.ahead) bits.push(c.cyan(`${glyph.up}${delta.ahead}`));
|
|
58
|
+
if (delta?.behind) bits.push(c.yellow(`${glyph.down}${delta.behind}`));
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
repo,
|
|
62
|
+
drift,
|
|
63
|
+
missing: false,
|
|
64
|
+
cells: [
|
|
65
|
+
c.bold(repo.name),
|
|
66
|
+
drift ? c.cyan(branch ?? '?') : (branch ?? '?'),
|
|
67
|
+
drift ? c.dim(home) : c.dim(''),
|
|
68
|
+
bits.join(' '),
|
|
69
|
+
],
|
|
70
|
+
};
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
let shown = rows;
|
|
74
|
+
if (opts.drift) shown = rows.filter((r) => r.drift && !r.missing);
|
|
75
|
+
if (opts.missing) shown = rows.filter((r) => r.missing);
|
|
76
|
+
|
|
77
|
+
heading('Workspace');
|
|
78
|
+
context([
|
|
79
|
+
['root', c.bold(root)],
|
|
80
|
+
['repos', `${rows.length}`],
|
|
81
|
+
['catalogue', c.dim(path.basename(manifest.__source))],
|
|
82
|
+
]);
|
|
83
|
+
plain('');
|
|
84
|
+
|
|
85
|
+
if (shown.length === 0) {
|
|
86
|
+
plain(c.dim('Nothing to show.'));
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Grouped by folder, so the table reads like the tree on disk.
|
|
91
|
+
const byGroup = new Map();
|
|
92
|
+
for (const row of shown) {
|
|
93
|
+
const g = repoGroup(row.repo);
|
|
94
|
+
if (!byGroup.has(g)) byGroup.set(g, []);
|
|
95
|
+
byGroup.get(g).push(row);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
for (const [name, groupRows] of byGroup) {
|
|
99
|
+
group(`${groupDir(manifest, name)}/`, groupRows.length);
|
|
100
|
+
table(
|
|
101
|
+
groupRows.map((r) => [' ' + r.cells[0], ...r.cells.slice(1)]),
|
|
102
|
+
[' REPO', 'BRANCH', 'DEFAULT', 'STATE'],
|
|
103
|
+
);
|
|
104
|
+
plain();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const missing = rows.filter((r) => r.missing).length;
|
|
108
|
+
const working = rows.filter((r) => r.drift && !r.missing).length;
|
|
109
|
+
const parts = [`${rows.length - missing} cloned`];
|
|
110
|
+
if (missing) parts.push(c.yellow(`${missing} missing`));
|
|
111
|
+
if (working) parts.push(c.cyan(`${working} on another branch`));
|
|
112
|
+
plain(parts.join(c.dim(', ')));
|
|
113
|
+
if (missing) plain(c.dim('Run `talea sync` to get the missing repos.'));
|
|
114
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
|
|
3
|
+
import { defaultBranch, groupDir, repoGroup } from '../config.js';
|
|
4
|
+
import {
|
|
5
|
+
aheadBehind,
|
|
6
|
+
currentBranch,
|
|
7
|
+
defaultJobs,
|
|
8
|
+
fetch,
|
|
9
|
+
ffMerge,
|
|
10
|
+
hasUpstream,
|
|
11
|
+
isDirty,
|
|
12
|
+
isMissingRemote,
|
|
13
|
+
pooled,
|
|
14
|
+
} from '../git.js';
|
|
15
|
+
import { board } from '../live.js';
|
|
16
|
+
import { c, context, glyph, heading, plain, summary, verdict, warn } from '../log.js';
|
|
17
|
+
import { chooseRepos } from '../select.js';
|
|
18
|
+
import { requireCatalogue, requireWorkspace, selectRepos, withPaths } from '../workspace.js';
|
|
19
|
+
import { adoptInPlace, cloneMissing, writeDocs } from './clone.js';
|
|
20
|
+
|
|
21
|
+
export const help = `
|
|
22
|
+
${c.bold('talea sync')} — make this machine match the list
|
|
23
|
+
|
|
24
|
+
${c.dim('talea sync')} clone what is missing, fast-forward the rest
|
|
25
|
+
${c.dim('talea sync --pick')} change what this machine keeps, then sync
|
|
26
|
+
${c.dim('talea sync -g nonstopio')} only that owner
|
|
27
|
+
${c.dim('talea sync --no-clone')} fast-forward only, clone nothing
|
|
28
|
+
|
|
29
|
+
The first time you run this on a machine you are asked what it should keep:
|
|
30
|
+
your default set, or a checklist of everything in the catalogue with those
|
|
31
|
+
defaults already ticked. The answer is remembered in ${c.dim('.talea.json')}, so every run
|
|
32
|
+
after that is a bare ${c.dim('talea sync')}.
|
|
33
|
+
|
|
34
|
+
Options
|
|
35
|
+
-g, --group <names> comma-separated groups
|
|
36
|
+
-r, --repo <names> comma-separated repo names
|
|
37
|
+
--pick re-open the checklist before syncing
|
|
38
|
+
--no-clone do not clone anything new
|
|
39
|
+
--no-adopt do not look for checkouts to move into place
|
|
40
|
+
--protocol <p> ssh (default) or https
|
|
41
|
+
-j, --jobs <n> parallel operations (default: one per core, 6-12)
|
|
42
|
+
|
|
43
|
+
${c.bold('What it will not do')}
|
|
44
|
+
|
|
45
|
+
Fast-forwards only. A repo that has diverged is reported, never merged
|
|
46
|
+
automatically. A repo with uncommitted changes is fetched and left exactly as
|
|
47
|
+
you left it. A branch with no upstream is fetched and left alone.
|
|
48
|
+
|
|
49
|
+
It never switches branches. If you are on a feature branch, that is where you
|
|
50
|
+
are working, and a tool that moves you off it mid-task is no better than one
|
|
51
|
+
that clobbers your changes — so it fast-forwards the branch you are on, or
|
|
52
|
+
leaves it alone, and says which.
|
|
53
|
+
`;
|
|
54
|
+
|
|
55
|
+
export async function run(opts) {
|
|
56
|
+
const { root, manifest, state } = requireWorkspace();
|
|
57
|
+
requireCatalogue(manifest);
|
|
58
|
+
|
|
59
|
+
const protocol = opts.protocol ?? state.protocol ?? 'ssh';
|
|
60
|
+
const jobs = Number(opts.jobs ?? defaultJobs());
|
|
61
|
+
|
|
62
|
+
const { repos: chosen } = await chooseRepos({ manifest, root, state, opts });
|
|
63
|
+
const repos = selectRepos(manifest, opts, chosen);
|
|
64
|
+
|
|
65
|
+
if (!repos.length) {
|
|
66
|
+
warn('Nothing selected for this machine. Run `talea sync --pick` to choose.');
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Adopt before cloning, never after: a repo already on disk must be moved
|
|
71
|
+
// into place, not cloned a second time beside the work in it.
|
|
72
|
+
const adoption =
|
|
73
|
+
opts.adopt === false || opts.clone === false
|
|
74
|
+
? { skip: new Map() }
|
|
75
|
+
: await adoptInPlace({ manifest, root, state, repos, opts });
|
|
76
|
+
|
|
77
|
+
// Before the network work, not after: the group doc explains what is about to
|
|
78
|
+
// land in the folder, and it must also appear on a re-run where nothing is
|
|
79
|
+
// cloned at all — that is how an existing workspace picks up a new template.
|
|
80
|
+
writeDocs(manifest, root, repos);
|
|
81
|
+
|
|
82
|
+
const entries = withPaths(manifest, root, repos);
|
|
83
|
+
const missing = entries.filter((e) => !e.cloned && !adoption.skip.has(e.repo.name));
|
|
84
|
+
const present = entries.filter((e) => e.cloned);
|
|
85
|
+
|
|
86
|
+
heading(`Syncing ${root}`);
|
|
87
|
+
context([
|
|
88
|
+
['repos', `${entries.length}`],
|
|
89
|
+
['to clone', `${opts.clone === false ? 0 : missing.length}`],
|
|
90
|
+
['on disk', `${present.length}`],
|
|
91
|
+
['jobs', `${jobs}`],
|
|
92
|
+
]);
|
|
93
|
+
|
|
94
|
+
const counts = { ok: 0, skipped: adoption.skip.size, failed: 0, okLabel: 'synced' };
|
|
95
|
+
|
|
96
|
+
if (missing.length && opts.clone !== false) {
|
|
97
|
+
plain('');
|
|
98
|
+
const cloneCounts = { ok: 0, skipped: 0, failed: 0, okLabel: 'cloned' };
|
|
99
|
+
await cloneMissing({ manifest, root, entries: missing, protocol, jobs, counts: cloneCounts });
|
|
100
|
+
counts.ok += cloneCounts.ok;
|
|
101
|
+
counts.skipped += cloneCounts.skipped;
|
|
102
|
+
counts.failed += cloneCounts.failed;
|
|
103
|
+
} else if (missing.length) {
|
|
104
|
+
counts.skipped += missing.length;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (present.length) {
|
|
108
|
+
plain('');
|
|
109
|
+
await fastForward({ manifest, root, entries: present, jobs, counts });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
summary(counts);
|
|
113
|
+
verdict(counts, {
|
|
114
|
+
clear: 'ALL CLEAR · every repo is on disk and level with origin',
|
|
115
|
+
partial: `${counts.skipped} repo(s) were fetched only or left alone — see above`,
|
|
116
|
+
trouble: `${counts.failed} repo(s) need attention — review above`,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Fetch each repo once, then fast-forward the branch it is on.
|
|
122
|
+
*
|
|
123
|
+
* One fetch, not two: `git pull` fetches again on top of the fetch we already
|
|
124
|
+
* did, and `ffMerge` merges the ref that fetch just updated. Over twenty repos
|
|
125
|
+
* on a slow link that is half the wall clock.
|
|
126
|
+
*/
|
|
127
|
+
async function fastForward({ manifest, root, entries, jobs, counts }) {
|
|
128
|
+
const view = board(
|
|
129
|
+
entries.map(({ repo }) => ({
|
|
130
|
+
id: repo.name,
|
|
131
|
+
group: groupDir(manifest, repoGroup(repo)),
|
|
132
|
+
label: repo.name,
|
|
133
|
+
})),
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
await pooled(entries, jobs, async ({ repo, dir }) => {
|
|
137
|
+
view.set(repo.name, 'busy', 'fetching …');
|
|
138
|
+
const fetched = await fetch(dir);
|
|
139
|
+
|
|
140
|
+
if (fetched.code !== 0) {
|
|
141
|
+
// A repo the catalogue lists but this account cannot reach is not a
|
|
142
|
+
// broken checkout — nothing here or in a retry fixes it, so say so and
|
|
143
|
+
// let the rest of the run stand.
|
|
144
|
+
if (isMissingRemote(fetched.stderr)) {
|
|
145
|
+
counts.skipped++;
|
|
146
|
+
view.set(repo.name, 'skip', 'origin is gone or not granted to you, left as it is');
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
counts.failed++;
|
|
150
|
+
view.set(repo.name, 'fail', 'fetch failed');
|
|
151
|
+
view.note(repo.name, fetched.stderr.split('\n')[0] ?? 'fetch failed');
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Independent reads of the same checkout — no reason to wait for one before
|
|
156
|
+
// starting the other, and every repo pays for all three.
|
|
157
|
+
const [branch, dirty, tracked] = await Promise.all([
|
|
158
|
+
currentBranch(dir),
|
|
159
|
+
isDirty(dir),
|
|
160
|
+
hasUpstream(dir),
|
|
161
|
+
]);
|
|
162
|
+
|
|
163
|
+
// `currentBranch` is `rev-parse --abbrev-ref HEAD`, which reports the
|
|
164
|
+
// literal string "HEAD" when nothing is checked out. Printing that as if it
|
|
165
|
+
// were a branch name is how "HEAD tracks no remote branch" happens.
|
|
166
|
+
const where = branch === 'HEAD' ? 'a detached HEAD' : branch;
|
|
167
|
+
const home = defaultBranch(repo);
|
|
168
|
+
const away = home && branch !== home && branch !== 'HEAD' ? c.dim(` (not ${home})`) : '';
|
|
169
|
+
|
|
170
|
+
if (dirty) {
|
|
171
|
+
counts.skipped++;
|
|
172
|
+
view.set(repo.name, 'warn', `uncommitted changes on ${where}, fetched only`);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (!tracked) {
|
|
177
|
+
// A local-only branch has nothing to fast-forward onto. Saying so beats
|
|
178
|
+
// the "no upstream configured" git spits out of a bare merge.
|
|
179
|
+
counts.skipped++;
|
|
180
|
+
view.set(repo.name, 'skip', `${where} tracks no remote branch, fetched only`);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const res = await ffMerge(dir);
|
|
185
|
+
if (res.code !== 0) {
|
|
186
|
+
counts.failed++;
|
|
187
|
+
const reason = /diverge|non-fast-forward|not possible to fast-forward/i.test(res.stderr)
|
|
188
|
+
? 'diverged from origin — needs a manual merge or rebase'
|
|
189
|
+
: (res.stderr.split('\n')[0] ?? 'fast-forward failed');
|
|
190
|
+
view.set(repo.name, 'fail', `${branch} — ${reason}`);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
counts.ok++;
|
|
195
|
+
const delta = await aheadBehind(dir);
|
|
196
|
+
const note = res.stdout.includes('Already up to date') ? c.dim('up to date') : c.green('updated');
|
|
197
|
+
const ahead = delta?.ahead ? c.yellow(` ${glyph.up}${delta.ahead}`) : '';
|
|
198
|
+
view.set(repo.name, 'ok', `${c.cyan(branch)}${away} ${note}${ahead}`);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
view.stop();
|
|
202
|
+
return counts;
|
|
203
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// The folder tree as it is on disk, with a CLAUDE.md marker on every level.
|
|
2
|
+
//
|
|
3
|
+
// `status` answers "what branch is this on"; `tree` answers "where does this
|
|
4
|
+
// live, and is there a doc there". The group folders are not repos, so nothing
|
|
5
|
+
// else in the tool ever shows them as folders — and they are exactly the levels
|
|
6
|
+
// whose CLAUDE.md is easy to lose, being outside every git repo. See src/docs.js.
|
|
7
|
+
|
|
8
|
+
import { existsSync } from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
|
|
11
|
+
import { groupDir, repoGroup } from '../config.js';
|
|
12
|
+
import { c, context, glyph, heading, icon, plain, table } from '../log.js';
|
|
13
|
+
import { machineRepos, requireWorkspace, selectRepos, withPaths } from '../workspace.js';
|
|
14
|
+
|
|
15
|
+
export const help = `
|
|
16
|
+
${c.bold('talea tree')} — the workspace folder tree, and who has a CLAUDE.md
|
|
17
|
+
|
|
18
|
+
${c.dim('talea tree')} the whole workspace
|
|
19
|
+
${c.dim('talea tree -g nonstopio')} one owner
|
|
20
|
+
|
|
21
|
+
Every folder from the workspace root down to each repo is listed, with a marker
|
|
22
|
+
saying whether a ${c.bold('CLAUDE.md')} sits in it. The group folders are not git repos, so
|
|
23
|
+
their docs come from the CLI — run \`talea sync\` to drop in any that are new.
|
|
24
|
+
|
|
25
|
+
Options
|
|
26
|
+
-g, --group <names> comma-separated groups
|
|
27
|
+
-r, --repo <names> comma-separated repo names
|
|
28
|
+
--all the whole catalogue, not just what this machine keeps
|
|
29
|
+
`;
|
|
30
|
+
|
|
31
|
+
const hasDoc = (dir) => existsSync(path.join(dir, 'CLAUDE.md'));
|
|
32
|
+
|
|
33
|
+
export function run(opts) {
|
|
34
|
+
const { root, manifest, state } = requireWorkspace();
|
|
35
|
+
const pool = opts.all ? manifest.repos : machineRepos(manifest, state);
|
|
36
|
+
const entries = withPaths(manifest, root, selectRepos(manifest, opts, pool));
|
|
37
|
+
|
|
38
|
+
// A node per folder, keyed by path segment, so work/api and work/web share
|
|
39
|
+
// the one work node instead of printing it twice.
|
|
40
|
+
const make = (dir) => ({ dir, children: new Map() });
|
|
41
|
+
const tree = make(root);
|
|
42
|
+
|
|
43
|
+
for (const { repo, dir, cloned } of entries) {
|
|
44
|
+
let node = tree;
|
|
45
|
+
for (const seg of groupDir(manifest, repoGroup(repo)).split('/')) {
|
|
46
|
+
if (!node.children.has(seg)) node.children.set(seg, make(path.join(node.dir, seg)));
|
|
47
|
+
node = node.children.get(seg);
|
|
48
|
+
}
|
|
49
|
+
node.children.set(path.basename(dir), { ...make(dir), repo, cloned });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const rows = [];
|
|
53
|
+
const walk = (node, prefix) => {
|
|
54
|
+
// Sorted, because this claims to show the folders on disk and `ls` does
|
|
55
|
+
// not print them in catalogue order.
|
|
56
|
+
const kids = [...node.children.entries()].sort(([a], [b]) =>
|
|
57
|
+
a.localeCompare(b, 'en', { sensitivity: 'base' }),
|
|
58
|
+
);
|
|
59
|
+
kids.forEach(([name, kid], i) => {
|
|
60
|
+
const last = i === kids.length - 1;
|
|
61
|
+
const branch = c.dim(last ? '└─ ' : '├─ ');
|
|
62
|
+
const label = kid.repo
|
|
63
|
+
? kid.cloned
|
|
64
|
+
? c.bold(name)
|
|
65
|
+
: c.dim(name)
|
|
66
|
+
: c.bold(`${name}/`);
|
|
67
|
+
rows.push({
|
|
68
|
+
folder: !kid.repo,
|
|
69
|
+
missing: !hasDoc(kid.dir),
|
|
70
|
+
cells: [
|
|
71
|
+
prefix + branch + label,
|
|
72
|
+
hasDoc(kid.dir) ? `${icon.ok} CLAUDE.md` : c.dim(`${glyph.pending} no CLAUDE.md`),
|
|
73
|
+
kid.repo && !kid.cloned ? c.yellow('not cloned') : '',
|
|
74
|
+
],
|
|
75
|
+
});
|
|
76
|
+
walk(kid, prefix + c.dim(last ? ' ' : '│ '));
|
|
77
|
+
});
|
|
78
|
+
};
|
|
79
|
+
walk(tree, '');
|
|
80
|
+
|
|
81
|
+
heading('Workspace');
|
|
82
|
+
context([
|
|
83
|
+
['root', c.bold(root)],
|
|
84
|
+
['repos', `${entries.length}`],
|
|
85
|
+
]);
|
|
86
|
+
plain('');
|
|
87
|
+
table(
|
|
88
|
+
[
|
|
89
|
+
[c.bold(`${path.basename(root)}/`), hasDoc(root) ? `${icon.ok} CLAUDE.md` : c.dim(`${glyph.pending} no CLAUDE.md`), ''],
|
|
90
|
+
...rows.map((r) => r.cells),
|
|
91
|
+
],
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
// Only the group folders are counted: a repo's own CLAUDE.md is whoever
|
|
95
|
+
// wrote it's business, but a missing group one is a file `sync` can drop in.
|
|
96
|
+
const gaps = rows.filter((r) => r.folder && r.missing).length + (hasDoc(root) ? 0 : 1);
|
|
97
|
+
plain();
|
|
98
|
+
plain(
|
|
99
|
+
gaps
|
|
100
|
+
? c.dim(`${gaps} folder${gaps === 1 ? '' : 's'} without a CLAUDE.md — \`talea sync\` writes the ones that ship with the CLI`)
|
|
101
|
+
: c.dim('every folder has a CLAUDE.md'),
|
|
102
|
+
);
|
|
103
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { readUserState, writeUserState } from '../config.js';
|
|
2
|
+
import { c, fail, heading, icon, info, ok, plain, skip, warn } from '../log.js';
|
|
3
|
+
import { installKind, installLatest, isNewer, lookupLatestRelease, pkgJson } from '../update.js';
|
|
4
|
+
|
|
5
|
+
export const help = `
|
|
6
|
+
${c.bold('talea upgrade')} — update the CLI itself
|
|
7
|
+
|
|
8
|
+
${c.dim('talea upgrade')} reinstall from npm at the latest version
|
|
9
|
+
${c.dim('talea upgrade --check')} only report whether one is available
|
|
10
|
+
${c.dim('talea upgrade --on')} turn the daily update notice on
|
|
11
|
+
${c.dim('talea upgrade --off')} turn the daily update notice off
|
|
12
|
+
|
|
13
|
+
By default the CLI checks for a new version at most once a day and prints a
|
|
14
|
+
one-line notice. It never updates itself silently — this tool moves checkouts
|
|
15
|
+
around, so it should not change behaviour underneath you mid-session.
|
|
16
|
+
|
|
17
|
+
Set ${c.dim('TALEA_NO_UPDATE_CHECK=1')} to disable the check for a single shell.
|
|
18
|
+
|
|
19
|
+
Options
|
|
20
|
+
--check report only, change nothing
|
|
21
|
+
--on enable the daily update notice
|
|
22
|
+
--off disable the daily update notice
|
|
23
|
+
`;
|
|
24
|
+
|
|
25
|
+
export async function run(opts) {
|
|
26
|
+
const state = readUserState();
|
|
27
|
+
|
|
28
|
+
if (opts.on || opts.off) {
|
|
29
|
+
writeUserState({ ...state, updateCheck: Boolean(opts.on) });
|
|
30
|
+
ok(`daily update notice ${opts.on ? c.green('on') : c.dim('off')}`);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const { name, version } = pkgJson();
|
|
35
|
+
heading('talea upgrade');
|
|
36
|
+
|
|
37
|
+
const latest = await lookupLatestRelease(name);
|
|
38
|
+
if (!latest.version) {
|
|
39
|
+
const why = {
|
|
40
|
+
timeout: 'the registry did not answer in time',
|
|
41
|
+
unpublished: `${name} is not on the registry yet`,
|
|
42
|
+
unreachable: 'could not reach the registry',
|
|
43
|
+
untagged: 'the registry has no version for it',
|
|
44
|
+
};
|
|
45
|
+
warn(`No version to compare against — ${why[latest.reason] ?? latest.reason}.`);
|
|
46
|
+
plain(c.dim(` Installed: ${version}`));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Cached for the passive notice, whatever happens next. A `--check` that
|
|
51
|
+
// silently refreshed nothing would make the daily notice go stale for a day.
|
|
52
|
+
writeUserState({ ...state, lastCheck: Date.now(), latestSeen: latest.version });
|
|
53
|
+
|
|
54
|
+
if (!isNewer(version, latest.version)) {
|
|
55
|
+
ok(`already on the latest version ${c.dim(version)}`);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
info(`${c.dim(version)} ${icon.arrow} ${c.green(latest.version)}`);
|
|
60
|
+
|
|
61
|
+
if (opts.check) {
|
|
62
|
+
plain(c.dim('\n Run `talea upgrade` to install it.'));
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// A git checkout is somebody's working copy of this project. Reinstalling
|
|
67
|
+
// over it from the registry would replace their branch with a release, which
|
|
68
|
+
// is not an upgrade, it is a data loss with a friendly name.
|
|
69
|
+
if (installKind() === 'local') {
|
|
70
|
+
skip('this copy is a git checkout, not an npm install — `git pull` instead');
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
plain('');
|
|
75
|
+
const code = await installLatest(name);
|
|
76
|
+
plain('');
|
|
77
|
+
if (code !== 0) {
|
|
78
|
+
fail(`npm install exited ${code}.`);
|
|
79
|
+
plain(c.dim(` Try it yourself: npm install -g ${name}@latest`));
|
|
80
|
+
process.exitCode = code;
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
ok(`upgraded to ${c.green(latest.version)}`);
|
|
84
|
+
}
|