balladeer 1.0.8 → 1.0.12

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.
@@ -1,8 +1,8 @@
1
1
  import { existsSync, lstatSync, mkdirSync, readFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
- import { dirname, join, resolve } from "node:path";
3
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
4
4
  import { parse } from "@iarna/toml";
5
- import { PUBLISHED_SPECIFIER, checkoutEntryPath } from "./release.js";
5
+ import { HOOK_SPECIFIER, checkoutEntryPath } from "./release.js";
6
6
  import { writeJsonAtomically } from "./mcp-config.js";
7
7
  /**
8
8
  * Install Balladeer once per laptop.
@@ -22,21 +22,43 @@ import { writeJsonAtomically } from "./mcp-config.js";
22
22
  */
23
23
  export const USER_SCOPE_OWNER = "Balladeer current guidance (user scope, loader 1)";
24
24
  const CLAUDE_EVENTS = ["SessionStart", "UserPromptSubmit", "SubagentStart"];
25
- /** The command a host runs to reach Balladeer, with no repository named: the
26
- * directory decides. The published form is what a laptop gets; the checkout
27
- * form exists so this repository's own tests can point a host at source. */
28
- export function userScopeCommand(published) {
25
+ /**
26
+ * The command a host runs to reach Balladeer, with no repository named: the
27
+ * directory decides.
28
+ *
29
+ * With a durable command installed, the entries name it by absolute path:
30
+ * no npx, so a session start costs node's startup and nothing else, and no
31
+ * PATH, so a host launched from the dock finds it. A laptop where the durable
32
+ * install was refused gets the `npx` form on the current major, which works
33
+ * everywhere and costs a registry lookup per session. The checkout form
34
+ * exists so this repository's own tests can point a host at source.
35
+ */
36
+ export function userScopeCommand(published, installed) {
37
+ if (installed !== undefined)
38
+ return { command: installed, args: [] };
29
39
  return published
30
- ? { command: "npx", args: ["-y", PUBLISHED_SPECIFIER] }
40
+ ? { command: "npx", args: ["-y", HOOK_SPECIFIER] }
31
41
  : { command: "node", args: [checkoutEntryPath()] };
32
42
  }
33
43
  function ordinaryFile(path) {
34
44
  return !existsSync(path) || (lstatSync(path).isFile() && !lstatSync(path).isSymbolicLink());
35
45
  }
46
+ /** The home the entries go under has to exist; a home that does not is a
47
+ * refusal in a sentence, never a stack trace. */
48
+ function homeExists(home) {
49
+ try {
50
+ return lstatSync(home).isDirectory();
51
+ }
52
+ catch {
53
+ return false;
54
+ }
55
+ }
36
56
  /** `~/.claude.json` carries the user-scope MCP list under `mcpServers`. */
37
- export function mergeClaudeUserMcp(home, published) {
57
+ export function mergeClaudeUserMcp(home, published, installed) {
38
58
  const path = join(home, ".claude.json");
39
59
  const host = "claude";
60
+ if (!homeExists(home))
61
+ return { host, path, status: "refused", reason: "home directory missing" };
40
62
  if (!ordinaryFile(path))
41
63
  return { host, path, status: "refused", reason: "not an ordinary file" };
42
64
  let root = {};
@@ -54,7 +76,7 @@ export function mergeClaudeUserMcp(home, published) {
54
76
  const servers = root.mcpServers && typeof root.mcpServers === "object" && !Array.isArray(root.mcpServers)
55
77
  ? root.mcpServers
56
78
  : {};
57
- const { command, args } = userScopeCommand(published);
79
+ const { command, args } = userScopeCommand(published, installed);
58
80
  const expected = { type: "stdio", command, args: [...args, "mcp"] };
59
81
  const current = servers.balladeer;
60
82
  if (current !== undefined && !isOurUserEntry(current)) {
@@ -84,16 +106,48 @@ export function isOurUserEntry(entry) {
84
106
  if (record.command === "npx")
85
107
  return (args.length === 3 &&
86
108
  args[0] === "-y" &&
87
- /^balladeer@(?:latest|\d+\.\d+\.\d+)$/.test(args[1]) &&
109
+ /^balladeer@(?:latest|1|\d+\.\d+\.\d+)$/.test(args[1]) &&
88
110
  args[2] === "mcp");
111
+ // A checkout of this repository at any path: the entry setup writes when it
112
+ // runs from source, on this laptop or another. Ours to replace, never to
113
+ // refuse; refusing it once left a developer's laptop with no entry at all.
89
114
  if (record.command === "node" || String(record.command).endsWith("/node"))
90
- return args.length === 2 && args[0] === checkoutEntryPath() && args[1] === "mcp";
115
+ return (args.length === 2 &&
116
+ typeof args[0] === "string" &&
117
+ isAbsolute(args[0]) &&
118
+ /[\\/]packages[\\/]cli[\\/]dist[\\/]cli\.js$/.test(args[0]) &&
119
+ args[1] === "mcp");
120
+ // The durable command by absolute path: ours when it sits where this
121
+ // product's installer puts it and is asked for `mcp` alone. The July client
122
+ // also registered an absolute `balladeer mcp`, from its own home, so the
123
+ // name is not enough; the place is what says whose it is.
124
+ if (typeof record.command === "string" && isAbsolute(record.command))
125
+ return isInstalledCommandPath(record.command) && args.length === 1 && args[0] === "mcp";
91
126
  return false;
92
127
  }
128
+ /**
129
+ * Where `ensureInstalled` puts the durable command: Volta's shim directory, an
130
+ * npm global prefix's `bin` with the package beside it under `lib/node_modules`,
131
+ * or this product's own fallback prefix under the person's home.
132
+ */
133
+ export function isInstalledCommandPath(command) {
134
+ if (basename(command) !== "balladeer")
135
+ return false;
136
+ const normalized = command.replaceAll("\\", "/");
137
+ if (/\/\.volta\/bin\/balladeer$/.test(normalized))
138
+ return true;
139
+ if (/\/\.local\/share\/balladeer\/npm\/bin\/balladeer$/.test(normalized))
140
+ return true;
141
+ const bin = dirname(command);
142
+ return (basename(bin) === "bin" &&
143
+ existsSync(join(dirname(bin), "lib", "node_modules", "balladeer", "package.json")));
144
+ }
93
145
  /** `~/.claude/settings.json` carries user-scope hooks. */
94
- export function mergeClaudeUserHooks(home, published) {
146
+ export function mergeClaudeUserHooks(home, published, installed) {
95
147
  const path = join(home, ".claude", "settings.json");
96
148
  const host = "claude";
149
+ if (!homeExists(home))
150
+ return { host, path, status: "refused", reason: "home directory missing" };
97
151
  if (!ordinaryFile(path))
98
152
  return { host, path, status: "refused", reason: "not an ordinary file" };
99
153
  let root = {};
@@ -111,7 +165,7 @@ export function mergeClaudeUserHooks(home, published) {
111
165
  const hooks = root.hooks && typeof root.hooks === "object" && !Array.isArray(root.hooks)
112
166
  ? { ...root.hooks }
113
167
  : {};
114
- const { command, args } = userScopeCommand(published);
168
+ const { command, args } = userScopeCommand(published, installed);
115
169
  const line = [command, ...args, "guidance", "--hook", "claude"].join(" ");
116
170
  let changed = false;
117
171
  for (const event of CLAUDE_EVENTS) {
@@ -149,7 +203,7 @@ const CODEX_START = "# balladeer:user:start";
149
203
  const CODEX_END = "# balladeer:user:end";
150
204
  /** `~/.codex/config.toml` carries both the server and the hooks for Codex, in
151
205
  * one fenced block this command owns end to end. */
152
- export function mergeCodexUserConfig(home, published) {
206
+ export function mergeCodexUserConfig(home, published, installed) {
153
207
  const path = join(home, ".codex", "config.toml");
154
208
  const host = "codex";
155
209
  if (!ordinaryFile(path))
@@ -163,7 +217,7 @@ export function mergeCodexUserConfig(home, published) {
163
217
  catch {
164
218
  return { host, path, status: "refused", reason: "could not read valid TOML" };
165
219
  }
166
- const { command, args } = userScopeCommand(published);
220
+ const { command, args } = userScopeCommand(published, installed);
167
221
  const line = [command, ...args, "guidance", "--hook", "codex"].join(" ");
168
222
  const block = [
169
223
  CODEX_START,
@@ -225,10 +279,10 @@ export function userHome(environment = process.env) {
225
279
  export function installUserScope(options) {
226
280
  const home = options.home ?? userHome(options.environment);
227
281
  const writes = [
228
- mergeClaudeUserMcp(home, options.published),
229
- mergeClaudeUserHooks(home, options.published),
282
+ mergeClaudeUserMcp(home, options.published, options.installed),
283
+ mergeClaudeUserHooks(home, options.published, options.installed),
230
284
  ];
231
285
  if (existsSync(join(home, ".codex")))
232
- writes.push(mergeCodexUserConfig(home, options.published));
286
+ writes.push(mergeCodexUserConfig(home, options.published, options.installed));
233
287
  return writes;
234
288
  }
package/dist/wire.d.ts CHANGED
@@ -5,10 +5,10 @@
5
5
  * runtime dependency at all: Node 22 builtins and global fetch, nothing else.
6
6
  * A contract test compares the scope list below against the server's.
7
7
  */
8
- export declare const CLI_VERSION = "1.0.8";
8
+ export declare const CLI_VERSION = "1.0.12";
9
9
  export declare const CLI_INVOCATION = "npx -y balladeer@latest";
10
10
  export declare const CLIENT_HEADER = "x-balladeer-client";
11
- export declare const CLIENT_HEADER_VALUE = "balladeer/1.0.8";
11
+ export declare const CLIENT_HEADER_VALUE = "balladeer/1.0.12";
12
12
  export declare const DEFAULT_CONTROL_PLANE = "https://envelopes.balladeer.ai";
13
13
  export type DelegatedScope = "repository:enroll" | "agent:issue" | "ci:connect" | "workspace:invite" | "candidate:propose";
14
14
  export declare const DELEGATED_SCOPES: readonly DelegatedScope[];
@@ -222,6 +222,19 @@ export type ClaudeDesktopStep = Readonly<{
222
222
  reason?: string;
223
223
  }>;
224
224
  export type JsonStep = Readonly<{
225
+ /** The earlier Balladeer found on this machine and what setup did about it. */
226
+ step: "earlier";
227
+ removed: readonly {
228
+ path: string;
229
+ found: string;
230
+ backup?: string;
231
+ }[];
232
+ failed: readonly {
233
+ path: string;
234
+ found: string;
235
+ reason: string;
236
+ }[];
237
+ }> | Readonly<{
225
238
  step: "guidance_loader";
226
239
  status: "installed_needs_host_trust" | "installed_trust_unverified" | "unavailable" | "not_applicable";
227
240
  changed: boolean;
@@ -332,7 +345,7 @@ export type JsonStep = Readonly<{
332
345
  reason?: string;
333
346
  }> | Readonly<{
334
347
  step: "warning";
335
- code: "enforcement_unavailable";
348
+ code: "enforcement_unavailable" | "not_in_checkout";
336
349
  message: string;
337
350
  }> | Readonly<{
338
351
  step: "promise";
package/dist/wire.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * runtime dependency at all: Node 22 builtins and global fetch, nothing else.
6
6
  * A contract test compares the scope list below against the server's.
7
7
  */
8
- export const CLI_VERSION = "1.0.8";
8
+ export const CLI_VERSION = "1.0.12";
9
9
  export const CLI_INVOCATION = "npx -y balladeer@latest";
10
10
  export const CLIENT_HEADER = "x-balladeer-client";
11
11
  export const CLIENT_HEADER_VALUE = `balladeer/${CLI_VERSION}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "balladeer",
3
- "version": "1.0.8",
3
+ "version": "1.0.12",
4
4
  "description": "Set up Balladeer from your terminal, or from a coding agent's.",
5
5
  "license": "Apache-2.0",
6
6
  "private": false,
package/dist/legacy.d.ts DELETED
@@ -1,41 +0,0 @@
1
- /**
2
- * The earlier Balladeer, still installed, found before this one writes anything.
3
- *
4
- * A team that used the July client has a program on their machine that this one
5
- * knows nothing about: it updates itself from its own server rather than from
6
- * npm, it registers an MCP server named `balladeer` for the whole machine, and
7
- * it installs a session hook and a conventions block of its own. This product
8
- * registers an MCP server under the same name, per repository. Nothing breaks
9
- * loudly when both are present, which is the problem: the older session hook
10
- * keeps firing inside the repository somebody enrolled today, one file ends up
11
- * carrying two conventions blocks, and the person reads the result as this
12
- * product misbehaving.
13
- *
14
- * The older client can take itself off, reversibly, with `balladeer uninstall`
15
- * followed by the removal command it prints. So this is a warning that stops
16
- * rather than a refusal that stands: the sentence names the one thing to do,
17
- * and `--force` is there for whoever has decided to run both anyway.
18
- */
19
- export type LegacyInstall = Readonly<{
20
- /** Which file said so, in the form a person can go and open. */
21
- where: string;
22
- /** What was found there, bounded, so a config file cannot become an essay. */
23
- detail: string;
24
- }>;
25
- /**
26
- * The instruction, in the same words `docs/didero-day-one.md` and
27
- * `docs/didero-mcp-walkthrough.md` print. A person who hits this in the terminal
28
- * and a person who read the document ahead of time are told to do the same
29
- * thing, by the same sentence.
30
- */
31
- export declare const UNINSTALL_INSTRUCTION = "If you used the earlier Balladeer on this machine: run `balladeer uninstall`, then the removal command it prints, then continue.";
32
- /**
33
- * The older install this machine still carries, or nothing.
34
- *
35
- * Two places, because those are the two the older client owns that reach into a
36
- * session in a repository enrolled today: the machine-wide MCP entry under our
37
- * own name, and the hook that fires at session start.
38
- */
39
- export declare function findLegacyInstall(environment: NodeJS.ProcessEnv, controlPlane: string): LegacyInstall | undefined;
40
- /** What the person reads, whichever of the two files said so. */
41
- export declare function legacyMessage(found: LegacyInstall): string;
package/dist/legacy.js DELETED
@@ -1,143 +0,0 @@
1
- import { readFileSync } from "node:fs";
2
- import { join } from "node:path";
3
- import { isOurEntry } from "./mcp-config.js";
4
- /**
5
- * The instruction, in the same words `docs/didero-day-one.md` and
6
- * `docs/didero-mcp-walkthrough.md` print. A person who hits this in the terminal
7
- * and a person who read the document ahead of time are told to do the same
8
- * thing, by the same sentence.
9
- */
10
- export const UNINSTALL_INSTRUCTION = "If you used the earlier Balladeer on this machine: run `balladeer uninstall`, then the removal command it prints, then continue.";
11
- /** How much of a foreign command line is worth printing back. */
12
- const DETAIL_LIMIT = 120;
13
- /**
14
- * Where the agent host keeps its configuration, read from the environment this
15
- * command was handed and never from the ambient process.
16
- *
17
- * Every command in this package takes its configuration as a parameter, which is
18
- * what lets a test plant a whole home directory. When the environment names no
19
- * home there is nothing to read, and a check that cannot read cannot warn: it
20
- * stays silent rather than guessing at a path.
21
- */
22
- function agentHome(environment) {
23
- const home = environment.HOME?.trim() || environment.USERPROFILE?.trim() || "";
24
- return home.length > 0 ? home : undefined;
25
- }
26
- function readJson(path) {
27
- try {
28
- return JSON.parse(readFileSync(path, "utf8"));
29
- }
30
- catch {
31
- // Absent, unreadable, or somebody's hand-edited file mid-save. None of those
32
- // is evidence of an older install, and none of them is this command's to fix.
33
- return undefined;
34
- }
35
- }
36
- function bounded(text) {
37
- const flattened = text.replace(/\s+/g, " ").trim();
38
- return flattened.length > DETAIL_LIMIT ? `${flattened.slice(0, DETAIL_LIMIT)}...` : flattened;
39
- }
40
- /** The command line an MCP entry runs, in the shape a person would recognise. */
41
- function describeEntry(entry) {
42
- const record = entry;
43
- if (record !== null && typeof record?.url === "string")
44
- return bounded(record.url);
45
- const command = typeof record?.command === "string" ? record.command : "";
46
- const args = Array.isArray(record?.args)
47
- ? record.args.filter((value) => typeof value === "string")
48
- : [];
49
- const line = [command, ...args].join(" ").trim();
50
- return line.length > 0 ? bounded(line) : "a command this copy does not recognise";
51
- }
52
- /**
53
- * Whether a hook's command line is one of ours.
54
- *
55
- * This product installs no agent-host hook at all, so in practice every hook
56
- * naming `balladeer` belongs to the older client. The exception is written down
57
- * anyway: a team that wired one of our own published or checkout invocations
58
- * into a hook of their own must not be told their own line is somebody else's
59
- * program.
60
- */
61
- function isOurCommandLine(command) {
62
- if (/balladeer@(?:latest|\d+\.\d+\.\d+)\b/.test(command))
63
- return true;
64
- return /\bnode\b[^\n]*\bpackages[/\\]cli[/\\]dist[/\\]cli\.js\b/.test(command);
65
- }
66
- /** Every `command` string anywhere under an agent host's `hooks` setting. */
67
- function hookCommands(value, found = []) {
68
- if (Array.isArray(value)) {
69
- for (const item of value)
70
- hookCommands(item, found);
71
- return found;
72
- }
73
- if (value === null || typeof value !== "object")
74
- return found;
75
- for (const [key, child] of Object.entries(value)) {
76
- if (key === "command" && typeof child === "string")
77
- found.push(child);
78
- else
79
- hookCommands(child, found);
80
- }
81
- return found;
82
- }
83
- /**
84
- * The older Balladeer's own MCP entry, if the agent host still carries one.
85
- *
86
- * Only the machine-wide block counts. This product writes its entry into the
87
- * repository's `.mcp.json`, and a host that also records a per-project entry
88
- * under `projects` records ours there: reading those as foreign would stop
89
- * setup on the very install it just performed. Under the machine-wide key, an
90
- * entry named `balladeer` that is not one of our recognised shapes is the older
91
- * client, because nothing else has reason to claim that name.
92
- */
93
- function legacyMcpEntry(home, controlPlane) {
94
- const parsed = readJson(join(home, ".claude.json"));
95
- const servers = parsed?.mcpServers;
96
- if (servers === null || typeof servers !== "object")
97
- return undefined;
98
- const entry = servers.balladeer;
99
- if (entry === undefined || entry === null)
100
- return undefined;
101
- if (isOurEntry(entry, controlPlane))
102
- return undefined;
103
- return {
104
- where: join(home, ".claude.json"),
105
- detail: `an MCP server named balladeer, registered for the whole machine, running ${describeEntry(entry)}`,
106
- };
107
- }
108
- /** The older Balladeer's session hook, if the agent host still runs one. */
109
- function legacyHook(home) {
110
- const parsed = readJson(join(home, ".claude", "settings.json"));
111
- const hooks = parsed?.hooks;
112
- if (hooks === undefined)
113
- return undefined;
114
- const command = hookCommands(hooks).find((line) => /balladeer/i.test(line) && !isOurCommandLine(line));
115
- if (command === undefined)
116
- return undefined;
117
- return {
118
- where: join(home, ".claude", "settings.json"),
119
- detail: `a hook that runs ${bounded(command)}`,
120
- };
121
- }
122
- /**
123
- * The older install this machine still carries, or nothing.
124
- *
125
- * Two places, because those are the two the older client owns that reach into a
126
- * session in a repository enrolled today: the machine-wide MCP entry under our
127
- * own name, and the hook that fires at session start.
128
- */
129
- export function findLegacyInstall(environment, controlPlane) {
130
- const home = agentHome(environment);
131
- if (home === undefined)
132
- return undefined;
133
- return legacyMcpEntry(home, controlPlane) ?? legacyHook(home);
134
- }
135
- /** What the person reads, whichever of the two files said so. */
136
- export function legacyMessage(found) {
137
- return [
138
- `An earlier Balladeer is still installed on this machine: ${found.where} carries ${found.detail}.`,
139
- "Both products register an MCP server named balladeer and the earlier one keeps firing its own session hook, so leaving it in place puts two Balladeers in the sessions you open in this repository.",
140
- UNINSTALL_INSTRUCTION,
141
- "Nothing was changed. Run this command again with --force to set up anyway.",
142
- ].join("\n");
143
- }
package/dist/quiet.d.ts DELETED
@@ -1,25 +0,0 @@
1
- /**
2
- * Where a person said "not here".
3
- *
4
- * With Balladeer registered once per laptop, every session in every folder
5
- * asks whether this folder is tracked. In one that is not, the hook says so
6
- * once and names the command that would track it; a person who does not want
7
- * to hear that again in a scratch clone silences the folder, and one who does
8
- * not want it for a whole product silences the remote. The default is the
9
- * folder, because "not here" usually means this checkout and not the repository.
10
- */
11
- export type QuietList = Readonly<{
12
- schemaVersion: 1;
13
- folders: string[];
14
- remotes: string[];
15
- }>;
16
- export declare function quietPath(environment?: NodeJS.ProcessEnv): string;
17
- export declare function readQuiet(environment?: NodeJS.ProcessEnv): QuietList;
18
- /** The folder a session is in, as git names it, so a worktree and a clone are
19
- * each their own folder while the remote stays one thing. */
20
- export declare function folderKey(cwd: string): string;
21
- export declare function isQuiet(cwd: string, environment?: NodeJS.ProcessEnv): boolean;
22
- export declare function setQuiet(cwd: string, scope: "folder" | "repo", quiet: boolean, environment?: NodeJS.ProcessEnv): {
23
- key: string;
24
- changed: boolean;
25
- };
package/dist/quiet.js DELETED
@@ -1,73 +0,0 @@
1
- import { execFileSync } from "node:child_process";
2
- import { existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs";
3
- import { join } from "node:path";
4
- import { writeJsonAtomically } from "./mcp-config.js";
5
- import { repositoryHint } from "./repository.js";
6
- import { configHome } from "./store.js";
7
- const EMPTY = { schemaVersion: 1, folders: [], remotes: [] };
8
- export function quietPath(environment = process.env) {
9
- return join(configHome(environment), "quiet.json");
10
- }
11
- export function readQuiet(environment = process.env) {
12
- const path = quietPath(environment);
13
- if (!existsSync(path))
14
- return EMPTY;
15
- try {
16
- const parsed = JSON.parse(readFileSync(path, "utf8"));
17
- if (parsed.schemaVersion !== 1)
18
- return EMPTY;
19
- return {
20
- schemaVersion: 1,
21
- folders: Array.isArray(parsed.folders) ? parsed.folders.filter(isString) : [],
22
- remotes: Array.isArray(parsed.remotes) ? parsed.remotes.filter(isString) : [],
23
- };
24
- }
25
- catch {
26
- return EMPTY;
27
- }
28
- }
29
- const isString = (value) => typeof value === "string";
30
- function writeQuiet(list, environment) {
31
- mkdirSync(configHome(environment), { recursive: true, mode: 0o700 });
32
- writeJsonAtomically(quietPath(environment), JSON.stringify(list, null, 2) + "\n");
33
- }
34
- /** The folder a session is in, as git names it, so a worktree and a clone are
35
- * each their own folder while the remote stays one thing. */
36
- export function folderKey(cwd) {
37
- try {
38
- return realpathSync(execFileSync("git", ["-C", cwd, "rev-parse", "--show-toplevel"], {
39
- encoding: "utf8",
40
- stdio: ["ignore", "pipe", "ignore"],
41
- timeout: 1000,
42
- }).trim());
43
- }
44
- catch {
45
- return realpathSync(cwd);
46
- }
47
- }
48
- export function isQuiet(cwd, environment = process.env) {
49
- const list = readQuiet(environment);
50
- const folder = folderKey(cwd);
51
- const remote = repositoryHint(cwd);
52
- return (list.folders.includes(folder) ||
53
- (remote !== "unknown/unknown" &&
54
- list.remotes.some((r) => r.toLowerCase() === remote.toLowerCase())));
55
- }
56
- export function setQuiet(cwd, scope, quiet, environment = process.env) {
57
- const list = readQuiet(environment);
58
- const key = scope === "folder" ? folderKey(cwd) : repositoryHint(cwd);
59
- if (scope === "repo" && key === "unknown/unknown")
60
- throw new Error("This folder has no GitHub origin remote, so there is no repository to name.");
61
- const field = scope === "folder" ? "folders" : "remotes";
62
- const has = list[field].some((x) => x.toLowerCase() === key.toLowerCase());
63
- if (has === quiet)
64
- return { key, changed: false };
65
- const next = {
66
- ...list,
67
- [field]: quiet
68
- ? [...list[field], key]
69
- : list[field].filter((x) => x.toLowerCase() !== key.toLowerCase()),
70
- };
71
- writeQuiet(next, environment);
72
- return { key, changed: true };
73
- }