@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/src/update.js ADDED
@@ -0,0 +1,119 @@
1
+ // Update checking and self-upgrade.
2
+ //
3
+ // Deliberately NOT a silent auto-updater. This tool moves checkouts around;
4
+ // changing its own behaviour mid-session without the developer knowing is how
5
+ // you get an unreproducible bug report. So: a passive, cached, once-a-day
6
+ // notice plus an explicit `talea upgrade`.
7
+
8
+ import { spawn } from 'node:child_process';
9
+ import { existsSync, readFileSync } from 'node:fs';
10
+ import path from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+
13
+ import { readUserState, writeUserState } from './config.js';
14
+
15
+ const here = path.dirname(fileURLToPath(import.meta.url));
16
+ export const PACKAGE_ROOT = path.join(here, '..');
17
+
18
+ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
19
+ const CHECK_TIMEOUT_MS = 3000;
20
+
21
+ export const pkgJson = () =>
22
+ JSON.parse(readFileSync(path.join(PACKAGE_ROOT, 'package.json'), 'utf8'));
23
+
24
+ /**
25
+ * How this copy was installed, which decides whether upgrading is ours to do.
26
+ * 'local' — a git checkout (npm link, or run straight from source)
27
+ * 'npm' — installed from the registry
28
+ */
29
+ export function installKind() {
30
+ return existsSync(path.join(PACKAGE_ROOT, '.git')) ? 'local' : 'npm';
31
+ }
32
+
33
+ /** Compare semver-ish strings. Returns true when `b` is newer than `a`. */
34
+ export function isNewer(a, b) {
35
+ const parse = (v) =>
36
+ String(v)
37
+ .replace(/^v/, '')
38
+ .split('-')[0]
39
+ .split('.')
40
+ .map((n) => parseInt(n, 10) || 0);
41
+ const [x, y] = [parse(a), parse(b)];
42
+ for (let i = 0; i < 3; i++) {
43
+ if ((y[i] ?? 0) > (x[i] ?? 0)) return true;
44
+ if ((y[i] ?? 0) < (x[i] ?? 0)) return false;
45
+ }
46
+ return false;
47
+ }
48
+
49
+ /**
50
+ * The version the registry currently calls `latest`.
51
+ *
52
+ * The registry endpoint rather than `npm view`, because spawning npm to read
53
+ * one string costs half a second and pulls a whole config resolution in with
54
+ * it. Resolves `{ version }` or `{ reason }` — never throws, and never blocks
55
+ * for longer than the timeout.
56
+ */
57
+ export async function lookupLatestRelease(name = pkgJson().name) {
58
+ const ctrl = new AbortController();
59
+ const timer = setTimeout(() => ctrl.abort(), CHECK_TIMEOUT_MS);
60
+ try {
61
+ const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(name)}/latest`, {
62
+ signal: ctrl.signal,
63
+ headers: { accept: 'application/json', 'user-agent': 'talea' },
64
+ });
65
+ if (!res.ok) return { reason: res.status === 404 ? 'unpublished' : 'unreachable' };
66
+ const { version } = await res.json();
67
+ return version ? { version } : { reason: 'untagged' };
68
+ } catch (err) {
69
+ return { reason: err.name === 'AbortError' ? 'timeout' : 'unreachable', detail: err.message };
70
+ } finally {
71
+ clearTimeout(timer);
72
+ }
73
+ }
74
+
75
+ export const latestRelease = async () => (await lookupLatestRelease()).version ?? null;
76
+
77
+ /** Reinstall globally from the registry. Resolves the exit code. */
78
+ export function installLatest(name = pkgJson().name) {
79
+ return new Promise((resolve) => {
80
+ const child = spawn('npm', ['install', '-g', `${name}@latest`], {
81
+ stdio: 'inherit',
82
+ // npm is a .cmd on Windows, which spawn cannot exec without a shell.
83
+ shell: process.platform === 'win32',
84
+ });
85
+ child.on('error', () => resolve(1));
86
+ child.on('close', (code) => resolve(code ?? 1));
87
+ });
88
+ }
89
+
90
+ const checksDisabled = () =>
91
+ process.env.TALEA_NO_UPDATE_CHECK === '1' || readUserState().updateCheck === false;
92
+
93
+ /**
94
+ * Print a one-line notice if a newer version was seen. Uses the *cached* result
95
+ * so it costs nothing, then refreshes the cache at most once a day.
96
+ *
97
+ * Runs after the real work and never throws — an update check must not be able
98
+ * to fail a clone.
99
+ */
100
+ export async function notifyIfOutdatedAsync() {
101
+ if (checksDisabled()) return;
102
+
103
+ const { version } = pkgJson();
104
+ const state = readUserState();
105
+
106
+ if (state.latestSeen && isNewer(version, state.latestSeen)) {
107
+ const { c, glyph } = await import('./log.js');
108
+ console.log(
109
+ c.dim(`\n${glyph.rule.repeat(6)}\n`) +
110
+ `${c.yellow('Update available')} ${c.dim(version)} ${glyph.arrow} ${c.green(state.latestSeen)} ` +
111
+ `run ${c.bold('talea upgrade')}\n`,
112
+ );
113
+ }
114
+
115
+ if (Date.now() - (state.lastCheck ?? 0) <= CHECK_INTERVAL_MS) return;
116
+
117
+ const latest = await latestRelease();
118
+ writeUserState({ ...state, lastCheck: Date.now(), ...(latest ? { latestSeen: latest } : {}) });
119
+ }
@@ -0,0 +1,116 @@
1
+ // Target selection: turning `--group nonstopio --repo eklavya` into a repo list,
2
+ // and turning "what does this machine want" into one.
3
+ //
4
+ // Every command that acts on repos goes through here, so `clone`, `sync`,
5
+ // `status` and `exec` filter identically.
6
+
7
+ import { findWorkspace, loadManifest, loadState, repoDir, repoGroup } from './config.js';
8
+ import { isRepo } from './git.js';
9
+ import { fail } from './log.js';
10
+
11
+ const csv = (v) =>
12
+ (Array.isArray(v) ? v : [v])
13
+ .filter(Boolean)
14
+ .flatMap((s) => String(s).split(','))
15
+ .map((s) => s.trim())
16
+ .filter(Boolean);
17
+
18
+ /**
19
+ * Resolve the workspace, manifest and state, or exit with a useful message.
20
+ * Commands that need an initialised workspace call this first.
21
+ */
22
+ export function requireWorkspace() {
23
+ const root = findWorkspace();
24
+ if (!root) {
25
+ fail('Not inside a talea workspace (no .talea.json found).');
26
+ console.error('\n Run `talea init` to create one, or cd into an existing workspace.');
27
+ process.exit(1);
28
+ }
29
+ const manifest = loadManifest(root);
30
+ const state = loadState(root);
31
+ return { root, manifest, state };
32
+ }
33
+
34
+ /**
35
+ * The repos this machine has signed up for.
36
+ *
37
+ * `state.selected` is an explicit list, written by the picker on first sync or
38
+ * by `talea add`. Until it exists, the catalogue's own `default: true` repos
39
+ * stand in — that is what "a default set, already ticked" means on a machine
40
+ * that has never been asked.
41
+ *
42
+ * A name in `selected` that the catalogue no longer has is ignored rather than
43
+ * fatal: repos get renamed and deleted on GitHub, and a machine that has not
44
+ * run `discover` since should still sync the other twenty.
45
+ */
46
+ export function machineRepos(manifest, state) {
47
+ const chosen = state.selected;
48
+ if (!Array.isArray(chosen)) {
49
+ return manifest.repos.filter((r) => r.default && !r.archived);
50
+ }
51
+ const wanted = new Set(chosen.map((n) => n.toLowerCase()));
52
+ return manifest.repos.filter((r) => wanted.has(r.name.toLowerCase()));
53
+ }
54
+
55
+ /** Has this machine ever been asked what it wants? */
56
+ export const hasChosen = (state) => Array.isArray(state.selected);
57
+
58
+ /**
59
+ * Filter a repo list by group / repo name.
60
+ *
61
+ * Unknown names are a hard error: silently doing nothing because of a typo is
62
+ * the worst possible outcome for a bulk command.
63
+ */
64
+ export function selectRepos(manifest, opts = {}, pool = manifest.repos) {
65
+ const groups = csv(opts.group).map((g) => g.toLowerCase());
66
+ const names = csv(opts.repo);
67
+
68
+ const knownGroups = new Set(manifest.repos.map((r) => repoGroup(r).toLowerCase()));
69
+ const unknownGroup = groups.find((g) => !knownGroups.has(g));
70
+ if (unknownGroup) {
71
+ fail(`Unknown group "${unknownGroup}".`);
72
+ console.error(`\n Known groups: ${[...knownGroups].join(', ')}`);
73
+ process.exit(1);
74
+ }
75
+
76
+ const byName = new Set(manifest.repos.map((r) => r.name.toLowerCase()));
77
+ const unknownRepo = names.find((n) => !byName.has(n.toLowerCase()));
78
+ if (unknownRepo) {
79
+ fail(`Unknown repo "${unknownRepo}".`);
80
+ console.error('\n Run `talea list` to see every repo in the catalogue.');
81
+ process.exit(1);
82
+ }
83
+
84
+ // An explicit -r reaches past the machine's selection on purpose: naming a
85
+ // repo is a request for that repo, not a request filtered by what was ticked
86
+ // six months ago.
87
+ let repos = names.length ? manifest.repos : pool;
88
+ if (groups.length) repos = repos.filter((r) => groups.includes(repoGroup(r).toLowerCase()));
89
+ if (names.length) {
90
+ const wanted = new Set(names.map((n) => n.toLowerCase()));
91
+ repos = repos.filter((r) => wanted.has(r.name.toLowerCase()));
92
+ }
93
+ return repos;
94
+ }
95
+
96
+ /** Attach the on-disk path and cloned-ness to each repo. */
97
+ export function withPaths(manifest, root, repos) {
98
+ return repos.map((repo) => {
99
+ const dir = repoDir(manifest, root, repo);
100
+ return { repo, dir, cloned: isRepo(dir) };
101
+ });
102
+ }
103
+
104
+ /** Only the repos that actually exist on disk — sync/status/exec operate on these. */
105
+ export const clonedOnly = (entries) => entries.filter((e) => e.cloned);
106
+
107
+ /**
108
+ * The catalogue is empty, so there is nothing any command can do. Say what to
109
+ * run rather than printing a successful-looking run over zero repos.
110
+ */
111
+ export function requireCatalogue(manifest) {
112
+ if (manifest.repos.length) return;
113
+ fail('The catalogue is empty.');
114
+ console.error('\n Run `talea discover` to build it from your GitHub account.');
115
+ process.exit(1);
116
+ }
File without changes