@ajaykumarnpm/talea 0.4.0 → 0.5.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/CHANGELOG.md CHANGED
@@ -4,6 +4,11 @@ All notable changes to this project are recorded here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the versions follow
5
5
  [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.5.0] - 2026-09-22
8
+
9
+ ### Added
10
+ - run commands from outside a workspace
11
+
7
12
  ## [0.4.0] - 2026-09-22
8
13
 
9
14
  ### Added
package/README.md CHANGED
@@ -46,6 +46,9 @@ talea manifest push # publish the catalogue so the next machine can rea
46
46
  repos, every org you belong to, and anything shared with you directly. It then
47
47
  asks what this machine should keep and fills the tree.
48
48
 
49
+ Every command works from anywhere after that. Outside the workspace it uses
50
+ the one `init` made; with more than one on the machine, it asks which.
51
+
49
52
  ## Every machine after that
50
53
 
51
54
  ```sh
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ajaykumarnpm/talea",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "One folder structure for every machine. Clone, adopt and sync your GitHub repos from a manifest you own.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -46,6 +46,10 @@ It exits **non-zero** on an unknown name and writes every diagnostic to stderr,
46
46
  so `cd "$(talea where typo)"` fails instead of landing in the home directory.
47
47
  Never `cd` to a path it did not print.
48
48
 
49
+ Outside a workspace it falls back to the ones this machine has. With several
50
+ and no terminal — which is how you run it — it lists them and exits non-zero.
51
+ `cd` into the workspace the user means rather than guessing from the list.
52
+
49
53
  Two owners can own a repo of the same name. When that happens it says so and
50
54
  exits non-zero — pass `owner/name` rather than picking one.
51
55
 
@@ -54,7 +54,7 @@ export function resolve(manifest, name) {
54
54
  }
55
55
 
56
56
  export async function run(opts, positionals = []) {
57
- const { root, manifest, state } = requireWorkspace();
57
+ const { root, manifest, state } = await requireWorkspace();
58
58
  requireCatalogue(manifest);
59
59
 
60
60
  if (!positionals.length) {
@@ -322,7 +322,7 @@ export function parseFromPaths(from) {
322
322
  }
323
323
 
324
324
  export async function run(opts) {
325
- const { root, manifest, state } = requireWorkspace();
325
+ const { root, manifest, state } = await requireWorkspace();
326
326
 
327
327
  if (opts['fix-paths']) {
328
328
  heading('Repairing paths for repos already adopted');
@@ -200,7 +200,7 @@ export function writeDocs(manifest, root, repos) {
200
200
  }
201
201
 
202
202
  export async function run(opts) {
203
- const { root, manifest, state } = requireWorkspace();
203
+ const { root, manifest, state } = await requireWorkspace();
204
204
  requireCatalogue(manifest);
205
205
 
206
206
  const protocol = opts.protocol ?? state.protocol ?? 'ssh';
@@ -46,7 +46,7 @@ export async function run(opts, positionals) {
46
46
  process.exit(1);
47
47
  }
48
48
 
49
- const { root, manifest, state } = requireWorkspace();
49
+ const { root, manifest, state } = await requireWorkspace();
50
50
  const pool = opts.all ? manifest.repos : machineRepos(manifest, state);
51
51
  const entries = clonedOnly(withPaths(manifest, root, selectRepos(manifest, opts, pool)));
52
52
 
@@ -15,6 +15,7 @@ import { samePath } from '../adopt.js';
15
15
  import { c, context, fail, heading, info, ok, plain, skip, warn } from '../log.js';
16
16
  import { run as discover } from './discover.js';
17
17
  import { run as sync } from './sync.js';
18
+ import { rememberWorkspace } from '../workspace.js';
18
19
 
19
20
  export const help = `
20
21
  ${c.bold('talea init')} — set this machine up
@@ -105,6 +106,8 @@ export async function run(opts, positionals = []) {
105
106
  } else {
106
107
  skip(`${STATE_FILE} already here, leaving it alone`);
107
108
  }
109
+ // So `talea sync` run from outside any workspace knows this one exists.
110
+ rememberWorkspace(target);
108
111
 
109
112
  let manifest = loadManifest(target);
110
113
 
@@ -48,7 +48,7 @@ export function changes(before, after) {
48
48
  }
49
49
 
50
50
  export async function run(opts) {
51
- const { root, manifest, state } = requireWorkspace();
51
+ const { root, manifest, state } = await requireWorkspace();
52
52
  requireCatalogue(manifest);
53
53
 
54
54
  // -g/-r narrow a run; they cannot narrow a decision about the whole machine.
@@ -29,7 +29,7 @@ Options
29
29
  `;
30
30
 
31
31
  export async function run(opts) {
32
- const { root, manifest, state } = requireWorkspace();
32
+ const { root, manifest, state } = await requireWorkspace();
33
33
  const pool = opts.all ? manifest.repos : machineRepos(manifest, state);
34
34
  const entries = withPaths(manifest, root, selectRepos(manifest, opts, pool));
35
35
 
@@ -31,6 +31,9 @@ your default set, or a checklist of everything in the catalogue with those
31
31
  defaults already ticked. The answer is remembered in ${c.dim('.talea.json')}, so every run
32
32
  after that is a bare ${c.dim('talea sync')}.
33
33
 
34
+ It runs from anywhere. Inside a workspace it uses that one; outside, it uses
35
+ the workspace ${c.dim('talea init')} made, or asks which when this machine has several.
36
+
34
37
  Options
35
38
  -g, --group <names> comma-separated groups
36
39
  -r, --repo <names> comma-separated repo names
@@ -53,7 +56,7 @@ leaves it alone, and says which.
53
56
  `;
54
57
 
55
58
  export async function run(opts) {
56
- const { root, manifest, state } = requireWorkspace();
59
+ const { root, manifest, state } = await requireWorkspace();
57
60
  requireCatalogue(manifest);
58
61
 
59
62
  const protocol = opts.protocol ?? state.protocol ?? 'ssh';
@@ -30,8 +30,8 @@ Options
30
30
 
31
31
  const hasDoc = (dir) => existsSync(path.join(dir, 'CLAUDE.md'));
32
32
 
33
- export function run(opts) {
34
- const { root, manifest, state } = requireWorkspace();
33
+ export async function run(opts) {
34
+ const { root, manifest, state } = await requireWorkspace();
35
35
  const pool = opts.all ? manifest.repos : machineRepos(manifest, state);
36
36
  const entries = withPaths(manifest, root, selectRepos(manifest, opts, pool));
37
37
 
@@ -17,10 +17,13 @@ composes with ${c.dim('cd')}, ${c.dim('code')}, ${c.dim('open')} and anything el
17
17
 
18
18
  Exits non-zero if the repo is not in the catalogue, so ${c.dim('cd $(talea where typo)')}
19
19
  fails loudly instead of landing you in your home directory.
20
+
21
+ Works from outside a workspace too: with one on this machine it uses that, with
22
+ several it asks — on stderr, so the answer never ends up in the path.
20
23
  `;
21
24
 
22
- export function run(opts, positionals = []) {
23
- const { root, manifest } = requireWorkspace();
25
+ export async function run(opts, positionals = []) {
26
+ const { root, manifest } = await requireWorkspace();
24
27
  const name = positionals[0];
25
28
 
26
29
  if (!name) {
package/src/config.js CHANGED
@@ -33,7 +33,7 @@ export const USER_STATE = path.join(USER_DIR, 'state.json');
33
33
 
34
34
  const readJson = (file) => JSON.parse(readFileSync(file, 'utf8'));
35
35
 
36
- /** Machine-wide state: the update-check stamp, the gist id. Not per workspace. */
36
+ /** Machine-wide state: the update-check stamp, the gist id, the workspace list. Not per workspace. */
37
37
  export function readUserState() {
38
38
  try {
39
39
  return readJson(USER_STATE);
@@ -48,7 +48,8 @@ export function writeUserState(state) {
48
48
  writeFileSync(USER_STATE, JSON.stringify(state, null, 2) + '\n');
49
49
  } catch {
50
50
  // A read-only home directory must not break the actual command. The only
51
- // things kept here are a cache stamp and a gist id.
51
+ // things kept here are a cache stamp, a gist id and the workspace list,
52
+ // and a lost list re-fills itself the next time a command runs inside one.
52
53
  }
53
54
  }
54
55
 
@@ -95,6 +96,19 @@ export function findWorkspace(start = process.cwd()) {
95
96
  }
96
97
  }
97
98
 
99
+ /**
100
+ * Every workspace this machine has set up, as recorded in ~/.talea/state.json.
101
+ *
102
+ * This is what lets `talea sync` run from anywhere: outside a workspace the
103
+ * upward walk finds nothing, and this list is what is left to ask. Kept in the
104
+ * machine-wide state rather than the catalogue because a path on this laptop
105
+ * means nothing on the next one. Entries whose .talea.json has gone are skipped
106
+ * on read, not dropped on write — a deleted workspace stops being offered, but
107
+ * an unplugged drive that comes back has not been forgotten.
108
+ */
109
+ export const knownWorkspaces = () =>
110
+ (readUserState().workspaces ?? []).filter((dir) => existsSync(path.join(dir, STATE_FILE)));
111
+
98
112
  export function loadState(workspaceRoot) {
99
113
  const file = path.join(workspaceRoot, STATE_FILE);
100
114
  return existsSync(file) ? readJson(file) : {};
package/src/workspace.js CHANGED
@@ -4,9 +4,25 @@
4
4
  // Every command that acts on repos goes through here, so `clone`, `sync`,
5
5
  // `status` and `exec` filter identically.
6
6
 
7
- import { findWorkspace, loadManifest, loadState, repoDir, repoGroup } from './config.js';
7
+ import { existsSync } from 'node:fs';
8
+ import os from 'node:os';
9
+ import path from 'node:path';
10
+ import readline from 'node:readline/promises';
11
+
12
+ import { samePath } from './adopt.js';
13
+ import {
14
+ STATE_FILE,
15
+ findWorkspace,
16
+ knownWorkspaces,
17
+ loadManifest,
18
+ loadState,
19
+ readUserState,
20
+ repoDir,
21
+ repoGroup,
22
+ writeUserState,
23
+ } from './config.js';
8
24
  import { isRepo } from './git.js';
9
- import { fail } from './log.js';
25
+ import { c, fail } from './log.js';
10
26
 
11
27
  const csv = (v) =>
12
28
  (Array.isArray(v) ? v : [v])
@@ -16,16 +32,87 @@ const csv = (v) =>
16
32
  .filter(Boolean);
17
33
 
18
34
  /**
19
- * Resolve the workspace, manifest and state, or exit with a useful message.
20
- * Commands that need an initialised workspace call this first.
35
+ * Add a workspace to this machine's list, once.
36
+ *
37
+ * Compared with samePath, not as strings: on macOS and Windows `~/workspace`
38
+ * and `~/Workspace` are one folder, and cd-ing in with the other spelling would
39
+ * otherwise add it again on every run.
40
+ */
41
+ export function rememberWorkspace(root) {
42
+ const state = readUserState();
43
+ const list = state.workspaces ?? [];
44
+ if (list.some((dir) => samePath(dir, root))) return;
45
+ writeUserState({ ...state, workspaces: [...list, root] });
46
+ }
47
+
48
+ /**
49
+ * The workspaces a command run from outside any of them could mean: every one
50
+ * `init` has recorded, plus the default `~/Workspace` — which is how a
51
+ * workspace made before the list existed is still found from anywhere.
21
52
  */
22
- export function requireWorkspace() {
23
- const root = findWorkspace();
24
- if (!root) {
53
+ export function workspaceCandidates(known = knownWorkspaces(), manifest = loadManifest(null)) {
54
+ const fallback = path.join(os.homedir(), manifest.workspace || 'Workspace');
55
+ const all = existsSync(path.join(fallback, STATE_FILE)) ? [...known, fallback] : known;
56
+ return all.filter((dir, i) => all.findIndex((d) => samePath(d, dir)) === i);
57
+ }
58
+
59
+ /**
60
+ * Outside a workspace: one known workspace is used, several are asked about.
61
+ *
62
+ * Everything here goes to stderr — `cd $(talea where)` reads stdout, and a
63
+ * prompt or a note there is a folder the shell tries to enter. With no
64
+ * terminal to ask on, it stops rather than picks: a script that syncs whichever
65
+ * workspace happened to be first is running against a tree nobody named.
66
+ */
67
+ async function pickKnownWorkspace() {
68
+ const found = workspaceCandidates();
69
+
70
+ if (!found.length) {
25
71
  fail('Not inside a talea workspace (no .talea.json found).');
26
72
  console.error('\n Run `talea init` to create one, or cd into an existing workspace.');
27
73
  process.exit(1);
28
74
  }
75
+ if (found.length === 1) {
76
+ console.error(c.dim(`Using the workspace at ${found[0]}\n`));
77
+ return found[0];
78
+ }
79
+ if (!process.stdin.isTTY || !process.stderr.isTTY) {
80
+ fail(`Not inside a talea workspace, and this machine has ${found.length}.`);
81
+ for (const dir of found) console.error(` ${c.bold(dir)}`);
82
+ console.error('\n cd into the one you mean.');
83
+ process.exit(1);
84
+ }
85
+
86
+ console.error(`\n${c.bold('Which workspace?')}`);
87
+ found.forEach((dir, i) => console.error(` ${c.cyan(String(i + 1))} ${dir}`));
88
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
89
+ const answer = (await rl.question(`\n${c.dim(`[1-${found.length}]`)} `)).trim();
90
+ rl.close();
91
+
92
+ // A wrong answer stops, same as a typo'd repo name: guessing here would run
93
+ // a bulk command against a tree the developer did not choose.
94
+ const chosen = found[Number(answer) - 1];
95
+ if (!/^\d+$/.test(answer) || !chosen) {
96
+ fail(`"${answer}" is not one of 1-${found.length}.`);
97
+ process.exit(1);
98
+ }
99
+ console.error('');
100
+ return chosen;
101
+ }
102
+
103
+ /**
104
+ * Resolve the workspace, manifest and state, or exit with a useful message.
105
+ * Commands that need an initialised workspace call this first.
106
+ *
107
+ * Inside a workspace the upward walk wins, exactly as git's does. Outside one,
108
+ * the workspaces this machine knows about are offered instead.
109
+ */
110
+ export async function requireWorkspace() {
111
+ const inside = findWorkspace();
112
+ // Recorded on every run from inside, so a workspace made before the list
113
+ // existed joins it the first time anything is run there.
114
+ if (inside) rememberWorkspace(inside);
115
+ const root = inside ?? (await pickKnownWorkspace());
29
116
  const manifest = loadManifest(root);
30
117
  const state = loadState(root);
31
118
  return { root, manifest, state };