balladeer 0.0.5 → 1.0.1

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.
Files changed (72) hide show
  1. package/LICENSE +200 -5
  2. package/README.md +167 -68
  3. package/dist/agent.d.ts +126 -0
  4. package/dist/agent.js +209 -0
  5. package/dist/cli.d.ts +48 -0
  6. package/dist/cli.js +531 -0
  7. package/dist/client.d.ts +66 -0
  8. package/dist/client.js +142 -0
  9. package/dist/commands/affected.d.ts +22 -0
  10. package/dist/commands/affected.js +123 -0
  11. package/dist/commands/check-seals.d.ts +37 -0
  12. package/dist/commands/check-seals.js +289 -0
  13. package/dist/commands/discover.d.ts +68 -0
  14. package/dist/commands/discover.js +403 -0
  15. package/dist/commands/explain.d.ts +35 -0
  16. package/dist/commands/explain.js +90 -0
  17. package/dist/commands/invite.d.ts +24 -0
  18. package/dist/commands/invite.js +198 -0
  19. package/dist/commands/mcp.d.ts +65 -0
  20. package/dist/commands/mcp.js +202 -0
  21. package/dist/commands/prepare.d.ts +74 -0
  22. package/dist/commands/prepare.js +217 -0
  23. package/dist/commands/propose.d.ts +69 -0
  24. package/dist/commands/propose.js +284 -0
  25. package/dist/commands/repositories.d.ts +18 -0
  26. package/dist/commands/repositories.js +185 -0
  27. package/dist/commands/session.d.ts +35 -0
  28. package/dist/commands/session.js +118 -0
  29. package/dist/commands/setup.d.ts +98 -0
  30. package/dist/commands/setup.js +1600 -0
  31. package/dist/commands/status.d.ts +51 -0
  32. package/dist/commands/status.js +542 -0
  33. package/dist/commands/touch-map.d.ts +42 -0
  34. package/dist/commands/touch-map.js +251 -0
  35. package/dist/commands/whoami.d.ts +8 -0
  36. package/dist/commands/whoami.js +80 -0
  37. package/dist/conventions.d.ts +77 -0
  38. package/dist/conventions.js +183 -0
  39. package/dist/copy.d.ts +224 -0
  40. package/dist/copy.js +641 -0
  41. package/dist/currency.d.ts +31 -0
  42. package/dist/currency.js +72 -0
  43. package/dist/desktop-config.d.ts +85 -0
  44. package/dist/desktop-config.js +217 -0
  45. package/dist/gh.d.ts +80 -0
  46. package/dist/gh.js +188 -0
  47. package/dist/git.d.ts +91 -0
  48. package/dist/git.js +226 -0
  49. package/dist/legacy.d.ts +41 -0
  50. package/dist/legacy.js +143 -0
  51. package/dist/local-time.d.ts +66 -0
  52. package/dist/local-time.js +84 -0
  53. package/dist/markers.d.ts +76 -0
  54. package/dist/markers.js +125 -0
  55. package/dist/mcp-config.d.ts +109 -0
  56. package/dist/mcp-config.js +234 -0
  57. package/dist/release.d.ts +55 -0
  58. package/dist/release.js +67 -0
  59. package/dist/repository.d.ts +8 -0
  60. package/dist/repository.js +32 -0
  61. package/dist/seals.d.ts +48 -0
  62. package/dist/seals.js +112 -0
  63. package/dist/session.d.ts +84 -0
  64. package/dist/session.js +135 -0
  65. package/dist/store.d.ts +108 -0
  66. package/dist/store.js +237 -0
  67. package/dist/touch-map.d.ts +241 -0
  68. package/dist/touch-map.js +487 -0
  69. package/dist/wire.d.ts +674 -0
  70. package/dist/wire.js +20 -0
  71. package/package.json +19 -10
  72. package/bin/balladeer.js +0 -161
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Whether this copy is the newest one, answered by the server on every call.
3
+ *
4
+ * Legacy customers ran a months-old command because nothing ever told them
5
+ * otherwise: the invocation in their config named a version, the version kept
6
+ * working, and a working thing announces nothing. So the server stamps what the
7
+ * newest published version is on every answer it gives a program, and this
8
+ * module is where the command notices.
9
+ *
10
+ * It is a nag, never a refusal. Refusing is the floor's job, and the floor is
11
+ * the server's to enforce; a copy that is merely behind still works, and a
12
+ * command that stopped working the day after a publish would be worse than the
13
+ * problem it fixes.
14
+ */
15
+ export declare const CLI_NEWEST_HEADER = "x-balladeer-cli-newest";
16
+ export declare const CLI_FLOOR_HEADER = "x-balladeer-cli-minimum";
17
+ export declare const CLI_ADVICE_HEADER = "x-balladeer-cli-advice";
18
+ /**
19
+ * Reads the version signals off one response.
20
+ *
21
+ * The comparison is this command's own, against the number the server named.
22
+ * The server's sentence is used for the wording when it sent one, because only
23
+ * the server knows whether the registry form or the checkout form is the line a
24
+ * person can actually run, but the decision to say anything at all is made here
25
+ * against a version this program knows for certain: its own.
26
+ */
27
+ export declare function noteServerVersion(headers: Headers): void;
28
+ /** The one line this run prints about itself, or nothing when it is current. */
29
+ export declare function updateNotice(): string | undefined;
30
+ /** For tests, and for a process that runs more than one command. */
31
+ export declare function resetUpdateNotice(): void;
@@ -0,0 +1,72 @@
1
+ import { CLI_VERSION } from "./wire.js";
2
+ /**
3
+ * Whether this copy is the newest one, answered by the server on every call.
4
+ *
5
+ * Legacy customers ran a months-old command because nothing ever told them
6
+ * otherwise: the invocation in their config named a version, the version kept
7
+ * working, and a working thing announces nothing. So the server stamps what the
8
+ * newest published version is on every answer it gives a program, and this
9
+ * module is where the command notices.
10
+ *
11
+ * It is a nag, never a refusal. Refusing is the floor's job, and the floor is
12
+ * the server's to enforce; a copy that is merely behind still works, and a
13
+ * command that stopped working the day after a publish would be worse than the
14
+ * problem it fixes.
15
+ */
16
+ export const CLI_NEWEST_HEADER = "x-balladeer-cli-newest";
17
+ export const CLI_FLOOR_HEADER = "x-balladeer-cli-minimum";
18
+ export const CLI_ADVICE_HEADER = "x-balladeer-cli-advice";
19
+ /**
20
+ * How much of the server's sentence may be printed. It is one line from the
21
+ * control plane this machine paired with, and a ceiling means a server that
22
+ * answers with pages of text cannot turn a terminal into its own output.
23
+ */
24
+ const ADVICE_LIMIT = 300;
25
+ const VERSION = /^\d{1,4}\.\d{1,4}\.\d{1,4}$/;
26
+ let notice;
27
+ function older(left, right) {
28
+ const [a, b] = [left.split(".").map(Number), right.split(".").map(Number)];
29
+ for (let index = 0; index < 3; index += 1) {
30
+ const difference = a[index] - b[index];
31
+ if (difference !== 0)
32
+ return difference < 0;
33
+ }
34
+ return false;
35
+ }
36
+ /** Control characters stripped, length bounded: a server writes words, not terminal control. */
37
+ function bounded(value) {
38
+ return value
39
+ .replace(/[\u0000-\u001f\u007f]/g, " ")
40
+ .trim()
41
+ .slice(0, ADVICE_LIMIT);
42
+ }
43
+ /**
44
+ * Reads the version signals off one response.
45
+ *
46
+ * The comparison is this command's own, against the number the server named.
47
+ * The server's sentence is used for the wording when it sent one, because only
48
+ * the server knows whether the registry form or the checkout form is the line a
49
+ * person can actually run, but the decision to say anything at all is made here
50
+ * against a version this program knows for certain: its own.
51
+ */
52
+ export function noteServerVersion(headers) {
53
+ const newest = headers.get(CLI_NEWEST_HEADER)?.trim();
54
+ if (newest === undefined || !VERSION.test(newest))
55
+ return;
56
+ if (!older(CLI_VERSION, newest))
57
+ return;
58
+ const advice = headers.get(CLI_ADVICE_HEADER);
59
+ const line = advice === null ? "" : bounded(advice);
60
+ notice =
61
+ line === ""
62
+ ? `A newer Balladeer is available (${newest}; this is ${CLI_VERSION}). Run setup --refresh, or restart this session, before continuing.`
63
+ : line;
64
+ }
65
+ /** The one line this run prints about itself, or nothing when it is current. */
66
+ export function updateNotice() {
67
+ return notice;
68
+ }
69
+ /** For tests, and for a process that runs more than one command. */
70
+ export function resetUpdateNotice() {
71
+ notice = undefined;
72
+ }
@@ -0,0 +1,85 @@
1
+ import { type StdioMcpEntry } from "./mcp-config.js";
2
+ /** The file the Claude desktop app reads its stdio servers out of, on every platform that has one. */
3
+ export declare const DESKTOP_CONFIG_FILE = "claude_desktop_config.json";
4
+ /**
5
+ * Where that file lives, or why this machine has no such place.
6
+ *
7
+ * Two platforms are documented and both are handled: macOS keeps it under
8
+ * `Library/Application Support/Claude`, Windows under `%APPDATA%\Claude`.
9
+ * Claude desktop is published for those two and no others, so every other
10
+ * platform is told so by name rather than written to on a guess. A person
11
+ * running an unofficial build, or a Windows install whose packaging redirects
12
+ * that directory, points `BALLADEER_CLAUDE_DESKTOP_CONFIG` at the file itself
13
+ * and this writes exactly there.
14
+ */
15
+ export type DesktopLocation = Readonly<{
16
+ kind: "path";
17
+ path: string;
18
+ directory: string;
19
+ }> | Readonly<{
20
+ kind: "unsupported";
21
+ platform: string;
22
+ reason: string;
23
+ }>;
24
+ export declare function desktopConfigLocation(environment?: NodeJS.ProcessEnv, platform?: string): DesktopLocation;
25
+ /**
26
+ * One key per repository, named after the repository.
27
+ *
28
+ * The desktop app has no notion of a project: every server in that file is
29
+ * loaded into every chat. A person who plans work in two repositories therefore
30
+ * has two of our servers running at once, and the only thing that tells them
31
+ * apart in the app's own connector list is the key. `balladeer-owner-name` is
32
+ * that name. A directory whose GitHub origin this command could not read has no
33
+ * name to use, so the key falls back to the repository's own id, which is unique
34
+ * by construction and still tells two entries apart.
35
+ */
36
+ export declare function desktopServerKey(repositoryName: string, repositoryId: string): string;
37
+ /**
38
+ * The entry the desktop app can actually start, which is not the one a coding
39
+ * agent gets.
40
+ *
41
+ * Claude desktop launches a stdio server from the application rather than from a
42
+ * login shell, with a minimal PATH carrying none of the places a developer's
43
+ * node lives: nvm, volta, asdf, Homebrew, fnm. So `node` and `npx` both resolve
44
+ * to nothing there, and what a person gets instead of a server is a line in a
45
+ * log file nobody opens. This names the interpreter running setup by its
46
+ * absolute path and this command's own entry point by its absolute path, so the
47
+ * entry needs no PATH at all.
48
+ *
49
+ * The environment is written for the same reason. The forwarder reads the bearer
50
+ * out of this machine's credential store, and where that store is depends on a
51
+ * variable the desktop app never inherits, so the resolved directory goes into
52
+ * the entry rather than being left to a shell that is not there. It is a path
53
+ * and never a secret: the credential itself stays in the store, mode 600.
54
+ */
55
+ export declare function desktopStdioEntry(repositoryId: string, environment?: NodeJS.ProcessEnv, controlPlane?: string, nodePath?: string, entryPath?: string): StdioMcpEntry;
56
+ export type DesktopMergeResult = Readonly<{
57
+ kind: "written";
58
+ changed: boolean;
59
+ path: string;
60
+ key: string;
61
+ }> | Readonly<{
62
+ kind: "absent";
63
+ directory: string;
64
+ reason: string;
65
+ }> | Readonly<{
66
+ kind: "refused";
67
+ reason: string;
68
+ block: string;
69
+ }>;
70
+ export declare function desktopBlock(key: string, entry: StdioMcpEntry): string;
71
+ /**
72
+ * Merges our entry into the desktop app's configuration, or refuses.
73
+ *
74
+ * Everything already in that file stays in it. Other people's servers are other
75
+ * people's servers, and so is every top-level key beside `mcpServers`, which is
76
+ * where the app keeps settings this command knows nothing about. An unparseable
77
+ * file is never overwritten, and a key of ours holding somebody else's server is
78
+ * never replaced: the block is printed and a person decides.
79
+ *
80
+ * The key is found by what the entry says rather than by what it is called. An
81
+ * entry of ours already bound to this repository is rewritten where it sits,
82
+ * whatever it was named, so a repository renamed on GitHub does not quietly
83
+ * acquire a second server that starts alongside the first.
84
+ */
85
+ export declare function mergeDesktopConfig(location: DesktopLocation, key: string, entry: StdioMcpEntry, repositoryId: string, controlPlane: string): DesktopMergeResult;
@@ -0,0 +1,217 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { isAbsolute, join } from "node:path";
3
+ import { entryRepositoryId, isOurEntry, writeJsonAtomically, } from "./mcp-config.js";
4
+ import { checkoutEntryPath } from "./release.js";
5
+ import { configHome } from "./store.js";
6
+ import { DEFAULT_CONTROL_PLANE } from "./wire.js";
7
+ /** The file the Claude desktop app reads its stdio servers out of, on every platform that has one. */
8
+ export const DESKTOP_CONFIG_FILE = "claude_desktop_config.json";
9
+ export function desktopConfigLocation(environment = process.env, platform = process.platform) {
10
+ const override = environment.BALLADEER_CLAUDE_DESKTOP_CONFIG?.trim();
11
+ if (override !== undefined && override.length > 0) {
12
+ // An override that is not absolute would be resolved against whatever
13
+ // directory this command happens to have been started in, which is never
14
+ // the directory the person meant.
15
+ return isAbsolute(override)
16
+ ? { kind: "path", path: override, directory: parentOf(override) }
17
+ : {
18
+ kind: "unsupported",
19
+ platform,
20
+ reason: `BALLADEER_CLAUDE_DESKTOP_CONFIG is set to ${override}, which is not an absolute path, so I did not guess what it meant.`,
21
+ };
22
+ }
23
+ const home = homeOf(environment);
24
+ if (platform === "darwin") {
25
+ if (home === undefined)
26
+ return noHome(platform);
27
+ const directory = join(home, "Library", "Application Support", "Claude");
28
+ return { kind: "path", path: join(directory, DESKTOP_CONFIG_FILE), directory };
29
+ }
30
+ if (platform === "win32") {
31
+ const appData = environment.APPDATA?.trim();
32
+ const roaming = appData !== undefined && appData.length > 0
33
+ ? appData
34
+ : home === undefined
35
+ ? undefined
36
+ : join(home, "AppData", "Roaming");
37
+ if (roaming === undefined)
38
+ return noHome(platform);
39
+ const directory = join(roaming, "Claude");
40
+ return { kind: "path", path: join(directory, DESKTOP_CONFIG_FILE), directory };
41
+ }
42
+ return {
43
+ kind: "unsupported",
44
+ platform,
45
+ reason: `Claude desktop is published for macOS and Windows, and this machine reports ${platform}, so there is no ${DESKTOP_CONFIG_FILE} here to write. If you run an unofficial build, set BALLADEER_CLAUDE_DESKTOP_CONFIG to the absolute path of its ${DESKTOP_CONFIG_FILE} and run this again.`,
46
+ };
47
+ }
48
+ /**
49
+ * The home directory this environment names, and never the one the operating
50
+ * system would name instead.
51
+ *
52
+ * Asking the OS whose home this is would be a second source, and it answers for
53
+ * the account the process runs under rather than for the environment it was
54
+ * handed. A command given a bounded environment on purpose, which is what the
55
+ * tests hand it and what a service account hands it, would reach past that
56
+ * environment and write into a real person's chat client. So an environment that
57
+ * names no home is an answer: this run cannot tell where Claude desktop's
58
+ * configuration is, and it says so rather than guessing.
59
+ */
60
+ function homeOf(environment) {
61
+ for (const value of [environment.HOME, environment.USERPROFILE]) {
62
+ const trimmed = value?.trim();
63
+ if (trimmed !== undefined && trimmed.length > 0)
64
+ return trimmed;
65
+ }
66
+ return undefined;
67
+ }
68
+ function noHome(platform) {
69
+ return {
70
+ kind: "unsupported",
71
+ platform,
72
+ reason: `This process was given no HOME, so I could not tell where Claude desktop keeps ${DESKTOP_CONFIG_FILE} and did not guess. Set BALLADEER_CLAUDE_DESKTOP_CONFIG to its absolute path, or run this from a shell that sets HOME.`,
73
+ };
74
+ }
75
+ function parentOf(path) {
76
+ const at = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
77
+ return at <= 0 ? path : path.slice(0, at);
78
+ }
79
+ /**
80
+ * One key per repository, named after the repository.
81
+ *
82
+ * The desktop app has no notion of a project: every server in that file is
83
+ * loaded into every chat. A person who plans work in two repositories therefore
84
+ * has two of our servers running at once, and the only thing that tells them
85
+ * apart in the app's own connector list is the key. `balladeer-owner-name` is
86
+ * that name. A directory whose GitHub origin this command could not read has no
87
+ * name to use, so the key falls back to the repository's own id, which is unique
88
+ * by construction and still tells two entries apart.
89
+ */
90
+ export function desktopServerKey(repositoryName, repositoryId) {
91
+ const cleaned = repositoryName
92
+ .toLowerCase()
93
+ .replace(/[^a-z0-9]+/g, "-")
94
+ .replace(/^-+|-+$/g, "");
95
+ const usable = cleaned.length > 0 && cleaned !== "unknown-unknown" ? cleaned : repositoryId.toLowerCase();
96
+ return `balladeer-${usable}`.slice(0, 100).replace(/-+$/, "");
97
+ }
98
+ /**
99
+ * The entry the desktop app can actually start, which is not the one a coding
100
+ * agent gets.
101
+ *
102
+ * Claude desktop launches a stdio server from the application rather than from a
103
+ * login shell, with a minimal PATH carrying none of the places a developer's
104
+ * node lives: nvm, volta, asdf, Homebrew, fnm. So `node` and `npx` both resolve
105
+ * to nothing there, and what a person gets instead of a server is a line in a
106
+ * log file nobody opens. This names the interpreter running setup by its
107
+ * absolute path and this command's own entry point by its absolute path, so the
108
+ * entry needs no PATH at all.
109
+ *
110
+ * The environment is written for the same reason. The forwarder reads the bearer
111
+ * out of this machine's credential store, and where that store is depends on a
112
+ * variable the desktop app never inherits, so the resolved directory goes into
113
+ * the entry rather than being left to a shell that is not there. It is a path
114
+ * and never a secret: the credential itself stays in the store, mode 600.
115
+ */
116
+ export function desktopStdioEntry(repositoryId, environment = process.env, controlPlane, nodePath = process.execPath, entryPath = checkoutEntryPath()) {
117
+ const env = { BALLADEER_CONFIG_HOME: configHome(environment) };
118
+ // Only when it is not the default. A deployment nobody named is the one the
119
+ // forwarder already reaches on its own, and writing it down would pin an
120
+ // address that a later release moves.
121
+ if (controlPlane !== undefined && controlPlane !== DEFAULT_CONTROL_PLANE) {
122
+ env.BALLADEER_CONTROL_PLANE = controlPlane;
123
+ }
124
+ return { command: nodePath, args: [entryPath, "mcp", "--repository", repositoryId], env };
125
+ }
126
+ export function desktopBlock(key, entry) {
127
+ return JSON.stringify({ mcpServers: { [key]: entry } }, null, 2);
128
+ }
129
+ /**
130
+ * Merges our entry into the desktop app's configuration, or refuses.
131
+ *
132
+ * Everything already in that file stays in it. Other people's servers are other
133
+ * people's servers, and so is every top-level key beside `mcpServers`, which is
134
+ * where the app keeps settings this command knows nothing about. An unparseable
135
+ * file is never overwritten, and a key of ours holding somebody else's server is
136
+ * never replaced: the block is printed and a person decides.
137
+ *
138
+ * The key is found by what the entry says rather than by what it is called. An
139
+ * entry of ours already bound to this repository is rewritten where it sits,
140
+ * whatever it was named, so a repository renamed on GitHub does not quietly
141
+ * acquire a second server that starts alongside the first.
142
+ */
143
+ export function mergeDesktopConfig(location, key, entry, repositoryId, controlPlane) {
144
+ const block = desktopBlock(key, entry);
145
+ if (location.kind === "unsupported") {
146
+ return { kind: "refused", reason: location.reason, block };
147
+ }
148
+ // The app creates its own directory the first time it runs. Creating one it
149
+ // never made would leave a configuration file behind for an application that
150
+ // is not installed, and the person would read "connected" about a chat client
151
+ // they do not have.
152
+ if (!existsSync(location.directory)) {
153
+ return {
154
+ kind: "absent",
155
+ directory: location.directory,
156
+ reason: `Claude desktop was not found on this machine: ${location.directory} does not exist, so I wrote no ${DESKTOP_CONFIG_FILE}. Install Claude desktop and open it once, then run this again.`,
157
+ };
158
+ }
159
+ let existing = "";
160
+ try {
161
+ existing = readFileSync(location.path, "utf8");
162
+ }
163
+ catch {
164
+ writeJsonAtomically(location.path, `${block}\n`);
165
+ return { kind: "written", changed: true, path: location.path, key };
166
+ }
167
+ let parsed;
168
+ try {
169
+ const value = JSON.parse(existing);
170
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
171
+ throw new Error("not an object");
172
+ }
173
+ parsed = value;
174
+ }
175
+ catch {
176
+ return {
177
+ kind: "refused",
178
+ reason: `Claude desktop's ${DESKTOP_CONFIG_FILE} could not be parsed; I did not change it.`,
179
+ block,
180
+ };
181
+ }
182
+ const raw = parsed.mcpServers;
183
+ if (raw !== undefined && (raw === null || typeof raw !== "object" || Array.isArray(raw))) {
184
+ return {
185
+ kind: "refused",
186
+ reason: `Claude desktop's ${DESKTOP_CONFIG_FILE} has an mcpServers value that is not a set of servers; I did not change it.`,
187
+ block,
188
+ };
189
+ }
190
+ const servers = raw === undefined ? {} : { ...raw };
191
+ const target = ourKeyFor(servers, repositoryId, controlPlane) ?? key;
192
+ const current = servers[target];
193
+ if (current !== undefined && !isOurEntry(current, controlPlane)) {
194
+ return {
195
+ kind: "refused",
196
+ reason: `Claude desktop's ${DESKTOP_CONFIG_FILE} already has an entry named ${target} that is not this workspace's server; I did not change it.`,
197
+ block: desktopBlock(target, entry),
198
+ };
199
+ }
200
+ servers[target] = entry;
201
+ const next = `${JSON.stringify({ ...parsed, mcpServers: servers }, null, 2)}\n`;
202
+ if (next === existing) {
203
+ return { kind: "written", changed: false, path: location.path, key: target };
204
+ }
205
+ writeJsonAtomically(location.path, next);
206
+ return { kind: "written", changed: true, path: location.path, key: target };
207
+ }
208
+ /** The key already carrying our server for this repository, whatever it is called. */
209
+ function ourKeyFor(servers, repositoryId, controlPlane) {
210
+ for (const [name, value] of Object.entries(servers)) {
211
+ if (!isOurEntry(value, controlPlane))
212
+ continue;
213
+ if (entryRepositoryId(value) === repositoryId)
214
+ return name;
215
+ }
216
+ return undefined;
217
+ }
package/dist/gh.d.ts ADDED
@@ -0,0 +1,80 @@
1
+ export type CommandResult = Readonly<{
2
+ ok: boolean;
3
+ code: number | null;
4
+ stdout: string;
5
+ stderr: string;
6
+ }>;
7
+ export declare function runCommand(file: string, args: readonly string[], options?: Readonly<{
8
+ cwd?: string;
9
+ timeoutMs?: number;
10
+ /**
11
+ * The whole environment the child gets, when the caller needs to name one.
12
+ * Left out, the child inherits this process's, which is what every other
13
+ * call here wants.
14
+ */
15
+ env?: NodeJS.ProcessEnv;
16
+ }>): Promise<CommandResult>;
17
+ export type RepositoryFacts = Readonly<{
18
+ githubRepositoryId: string;
19
+ githubOwnerId: string;
20
+ defaultBranch: string;
21
+ /** Whether the person can push to, or administer, this repository. */
22
+ canWrite: boolean;
23
+ /** True when the facts came from an authenticated read. */
24
+ authenticated: boolean;
25
+ }>;
26
+ export type RepositoryLookup = Readonly<{
27
+ kind: "found";
28
+ facts: RepositoryFacts;
29
+ }>
30
+ /**
31
+ * Why the read did not happen, and what the person does about it. The two
32
+ * travel together because they came apart once: a developer with no `gh`
33
+ * installed at all was told to run `gh auth login`, which is a command they do
34
+ * not have.
35
+ */
36
+ | Readonly<{
37
+ kind: "unavailable";
38
+ reason: string;
39
+ next: string;
40
+ }>;
41
+ export declare function ghAvailable(): Promise<boolean>;
42
+ export declare function ghSignedIn(): Promise<boolean>;
43
+ /**
44
+ * Reads a repository's immutable ids, its default branch, and what the person
45
+ * may do with it.
46
+ *
47
+ * The unauthenticated read is deliberately NOT a fallback for enrolling. It
48
+ * proves nothing about who is asking, so a workspace could otherwise end up
49
+ * holding an enrollment, a CI identity, and eventually a protection claim about
50
+ * a repository nobody in it controls. It exists only so the command can render
51
+ * the do-it-by-hand block with real values.
52
+ */
53
+ export declare function readRepositoryFacts(repository: string): Promise<RepositoryLookup>;
54
+ /** The public read, for rendering the fallback block only. */
55
+ export declare function readPublicRepositoryFacts(repository: string): Promise<RepositoryLookup>;
56
+ /**
57
+ * What this machine's own GitHub access can see, so an agent has a list to
58
+ * offer instead of asking a person to type repository names from memory.
59
+ *
60
+ * Read through the person's own `gh`, exactly as every other GitHub read here
61
+ * is: Balladeer holds no GitHub token and this list never leaves the machine
62
+ * unless a person picks something off it. Archived repositories are dropped,
63
+ * because enrolling one records a claim about a repository nobody is going to
64
+ * push to again.
65
+ */
66
+ export type VisibleRepository = Readonly<{
67
+ repository: string;
68
+ canWrite: boolean;
69
+ }>;
70
+ /**
71
+ * The repositories one GitHub account or organization holds, newest first.
72
+ *
73
+ * `owner` empty means the signed-in account's own. A refusal is an empty list
74
+ * rather than an error: this is a convenience for offering a choice, and a
75
+ * person who cannot list an organization can still name a repository by hand.
76
+ */
77
+ export declare function listVisibleRepositories(owner: string | undefined, limit?: number): Promise<readonly VisibleRepository[]>;
78
+ /** The signed-in GitHub account's login, or nothing. */
79
+ export declare function ghLogin(): Promise<string | undefined>;
80
+ export declare function setRepositoryVariable(repository: string, name: string, value: string): Promise<CommandResult>;
package/dist/gh.js ADDED
@@ -0,0 +1,188 @@
1
+ import { execFile } from "node:child_process";
2
+ /**
3
+ * The person's own GitHub access, used through the tool they already signed in
4
+ * to. Balladeer holds no GitHub token and this command never sends one anywhere.
5
+ *
6
+ * Every call goes through `execFile` with an argument array, never a shell, so
7
+ * nothing here can be turned into a command by a repository name. Standard error
8
+ * is captured rather than inherited, because a `gh` failure has to become one
9
+ * plain sentence with a next action rather than provider noise in the middle of
10
+ * a step.
11
+ */
12
+ const TIMEOUT_MS = 20_000;
13
+ export function runCommand(file, args, options = {}) {
14
+ return new Promise((done) => {
15
+ execFile(file, [...args], {
16
+ ...(options.cwd === undefined ? {} : { cwd: options.cwd }),
17
+ ...(options.env === undefined ? {} : { env: { ...options.env } }),
18
+ timeout: options.timeoutMs ?? TIMEOUT_MS,
19
+ maxBuffer: 4 * 1024 * 1024,
20
+ windowsHide: true,
21
+ }, (error, stdout, stderr) => {
22
+ const code = error === null ? 0 : typeof error.code === "number" ? error.code : 1;
23
+ done({
24
+ ok: error === null,
25
+ code,
26
+ stdout: String(stdout),
27
+ // Bounded: a refusal is a sentence, never a page of output.
28
+ stderr: String(stderr).trim().slice(0, 500),
29
+ });
30
+ });
31
+ });
32
+ }
33
+ function readFacts(raw, authenticated) {
34
+ let parsed;
35
+ try {
36
+ parsed = JSON.parse(raw);
37
+ }
38
+ catch {
39
+ return undefined;
40
+ }
41
+ const id = typeof parsed.id === "number" ? String(parsed.id) : undefined;
42
+ const ownerId = typeof parsed.owner?.id === "number" ? String(parsed.owner.id) : undefined;
43
+ const branch = typeof parsed.default_branch === "string" ? parsed.default_branch : undefined;
44
+ if (!id || !ownerId || !branch)
45
+ return undefined;
46
+ // GitHub returns `permissions` only for an authenticated request, so its
47
+ // absence is exactly the case where nothing was proved about the caller.
48
+ const canWrite = parsed.permissions?.admin === true || parsed.permissions?.push === true;
49
+ return {
50
+ githubRepositoryId: id,
51
+ githubOwnerId: ownerId,
52
+ defaultBranch: branch,
53
+ canWrite,
54
+ authenticated,
55
+ };
56
+ }
57
+ export async function ghAvailable() {
58
+ return (await runCommand("gh", ["--version"])).ok;
59
+ }
60
+ export async function ghSignedIn() {
61
+ return (await runCommand("gh", ["auth", "status"])).ok;
62
+ }
63
+ /**
64
+ * Reads a repository's immutable ids, its default branch, and what the person
65
+ * may do with it.
66
+ *
67
+ * The unauthenticated read is deliberately NOT a fallback for enrolling. It
68
+ * proves nothing about who is asking, so a workspace could otherwise end up
69
+ * holding an enrollment, a CI identity, and eventually a protection claim about
70
+ * a repository nobody in it controls. It exists only so the command can render
71
+ * the do-it-by-hand block with real values.
72
+ */
73
+ export async function readRepositoryFacts(repository) {
74
+ if (await ghAvailable()) {
75
+ if (!(await ghSignedIn())) {
76
+ return {
77
+ kind: "unavailable",
78
+ reason: "gh is installed but not signed in",
79
+ next: "Run `gh auth login`, then run this command again.",
80
+ };
81
+ }
82
+ const answer = await runCommand("gh", ["api", `repos/${repository}`]);
83
+ if (answer.ok) {
84
+ const facts = readFacts(answer.stdout, true);
85
+ if (facts)
86
+ return { kind: "found", facts };
87
+ return {
88
+ kind: "unavailable",
89
+ reason: "GitHub's answer did not carry the repository's ids",
90
+ next: "Check that the name is right and that GitHub is reachable, then run this command again.",
91
+ };
92
+ }
93
+ return {
94
+ kind: "unavailable",
95
+ reason: answer.stderr || `gh could not read repos/${repository}`,
96
+ next: `Check that ${repository} exists and that your GitHub account can see it, then run this command again.`,
97
+ };
98
+ }
99
+ return {
100
+ kind: "unavailable",
101
+ reason: "the GitHub CLI is not installed",
102
+ next: "Install the GitHub CLI from https://cli.github.com, sign in with `gh auth login`, then run this command again.",
103
+ };
104
+ }
105
+ /**
106
+ * The public read is a display convenience with no next action of its own: the
107
+ * caller is already printing the by-hand block, and this only fills its values.
108
+ */
109
+ const PUBLIC_NEXT = "Add the repository by hand instead.";
110
+ /** The public read, for rendering the fallback block only. */
111
+ export async function readPublicRepositoryFacts(repository) {
112
+ let response;
113
+ try {
114
+ response = await fetch(`https://api.github.com/repos/${repository}`, {
115
+ headers: { accept: "application/vnd.github+json" },
116
+ redirect: "manual",
117
+ signal: AbortSignal.timeout(TIMEOUT_MS),
118
+ });
119
+ }
120
+ catch {
121
+ return { kind: "unavailable", reason: "GitHub could not be reached", next: PUBLIC_NEXT };
122
+ }
123
+ if (!response.ok) {
124
+ return {
125
+ kind: "unavailable",
126
+ reason: `GitHub answered ${response.status}`,
127
+ next: PUBLIC_NEXT,
128
+ };
129
+ }
130
+ const facts = readFacts(await response.text(), false);
131
+ return facts === undefined
132
+ ? {
133
+ kind: "unavailable",
134
+ reason: "GitHub's answer did not carry the repository's ids",
135
+ next: PUBLIC_NEXT,
136
+ }
137
+ : { kind: "found", facts };
138
+ }
139
+ const WRITE_PERMISSIONS = new Set(["ADMIN", "MAINTAIN", "WRITE"]);
140
+ function readRepositoryList(raw) {
141
+ let parsed;
142
+ try {
143
+ parsed = JSON.parse(raw);
144
+ }
145
+ catch {
146
+ return [];
147
+ }
148
+ if (!Array.isArray(parsed))
149
+ return [];
150
+ const rows = [];
151
+ for (const entry of parsed) {
152
+ const name = typeof entry?.nameWithOwner === "string" ? entry.nameWithOwner : undefined;
153
+ if (name === undefined || entry.isArchived === true)
154
+ continue;
155
+ const permission = typeof entry.viewerPermission === "string" ? entry.viewerPermission.toUpperCase() : "";
156
+ rows.push({ repository: name, canWrite: WRITE_PERMISSIONS.has(permission) });
157
+ }
158
+ return rows;
159
+ }
160
+ /**
161
+ * The repositories one GitHub account or organization holds, newest first.
162
+ *
163
+ * `owner` empty means the signed-in account's own. A refusal is an empty list
164
+ * rather than an error: this is a convenience for offering a choice, and a
165
+ * person who cannot list an organization can still name a repository by hand.
166
+ */
167
+ export async function listVisibleRepositories(owner, limit = 100) {
168
+ const answer = await runCommand("gh", [
169
+ "repo",
170
+ "list",
171
+ ...(owner === undefined ? [] : [owner]),
172
+ "--limit",
173
+ String(limit),
174
+ "--no-archived",
175
+ "--json",
176
+ "nameWithOwner,isArchived,viewerPermission",
177
+ ]);
178
+ return answer.ok ? readRepositoryList(answer.stdout) : [];
179
+ }
180
+ /** The signed-in GitHub account's login, or nothing. */
181
+ export async function ghLogin() {
182
+ const answer = await runCommand("gh", ["api", "user", "--jq", ".login"]);
183
+ const login = answer.stdout.trim();
184
+ return answer.ok && /^[A-Za-z0-9-]{1,39}$/.test(login) ? login : undefined;
185
+ }
186
+ export async function setRepositoryVariable(repository, name, value) {
187
+ return runCommand("gh", ["variable", "set", name, "--repo", repository, "--body", value]);
188
+ }