@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,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
+ }