balladeer 1.0.3 → 1.0.4

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/README.md CHANGED
@@ -41,6 +41,15 @@ already enrolled it connects this machine and writes its MCP configuration; when
41
41
  leaves the checkout unchanged and Balladeer shows that enforcement tracking is unavailable until an
42
42
  administrator connects a repository.
43
43
 
44
+ ## If setup cannot save its local connection
45
+
46
+ Setup needs access to its private credential folder, normally `~/.config/balladeer`. If your
47
+ terminal or coding agent blocks that access, allow the command to access the private folder named in
48
+ the error, then repeat the same setup command with the same flags. Keep your existing saved
49
+ credentials; do not delete the store or move it into your repository. Setup checks local write
50
+ access before requesting a new browser pairing and reports a credential-store error separately from
51
+ a connection failure.
52
+
44
53
  ## Requirements
45
54
 
46
55
  - Node 22 or newer. The command uses Node builtins and the pinned `@iarna/toml` parser to preserve
package/dist/cli.js CHANGED
@@ -60,6 +60,10 @@ const USAGE = `balladeer ${CLI_VERSION}
60
60
  skipping quietly; --no-claude-desktop leaves that file alone entirely.
61
61
  Quit and reopen the app afterwards: it reads its configuration at startup.
62
62
 
63
+ If setup cannot access its private credential folder, allow this command
64
+ to access the folder named in the error, then repeat the same setup command.
65
+ Keep existing saved credentials; do not delete the store to retry.
66
+
63
67
  ${CLI_INVOCATION} repositories [--json] [--control-plane <url>]
64
68
  List the repositories this machine's GitHub account can see, marking the
65
69
  one you are standing in and the ones Balladeer already has.
@@ -15,7 +15,7 @@ import { findLegacyInstall, legacyMessage } from "../legacy.js";
15
15
  import { formatInstant } from "../local-time.js";
16
16
  import { checkoutEntryPath, commandLine, runningFromRegistryInstall } from "../release.js";
17
17
  import { hostHint, repositoryHint } from "../repository.js";
18
- import { StoreError, credentialsPath, dropPendingPairing, findPendingPairing, findSession, putAgent, putPendingPairing, putSession, readCredentials, writeCredentials, } from "../store.js";
18
+ import { StoreError, assertStoreWritable, credentialsPath, dropPendingPairing, findPendingPairing, findSession, putAgent, putPendingPairing, putSession, readCredentials, writeCredentials, } from "../store.js";
19
19
  import { CLI_VERSION, DELEGATED_SCOPES, } from "../wire.js";
20
20
  import { explainText } from "./explain.js";
21
21
  import { probeAgentConnection } from "./mcp.js";
@@ -168,10 +168,11 @@ function pendingLines(pending, repository, host, waiting, approvalUri) {
168
168
  ...JOIN_OR_CREATE.split("\n").map((line) => (line.length === 0 ? "" : ` ${line}`)),
169
169
  ];
170
170
  }
171
- function storeSession(credentials, controlPlane, token, session) {
171
+ function storeSession(credentials, controlPlane, token, session, workspaceChoice) {
172
172
  return putSession(dropPendingPairing(credentials, controlPlane), {
173
173
  controlPlane,
174
174
  sessionId: session.sessionId,
175
+ ...(workspaceChoice?.startsWith("create:") ? { workspaceChoice } : {}),
175
176
  token,
176
177
  workspaceId: session.workspaceId,
177
178
  workspaceName: session.workspaceName,
@@ -290,23 +291,29 @@ export async function runSetup(options) {
290
291
  catch (error) {
291
292
  return storeFailure(options, error);
292
293
  }
293
- // Workspace choice is explicit. Preserve all agent connections and other
294
- // origins; persist the session replacement only when a new pairing starts.
294
+ // A completed create intent resumes only its own authenticated session. The
295
+ // name is local intent, never a lookup or grant of workspace authority. An
296
+ // explicit chooser always starts a switch. Keep the old session until the
297
+ // new pairing is claimed, so a failed start or claim loses no credentials.
295
298
  const workspaceChoice = options.createWorkspace !== undefined
296
299
  ? `create:${carriedName}`
297
300
  : options.chooseWorkspace === true
298
301
  ? "choose"
299
302
  : undefined;
303
+ const previousSession = findSession(credentials, controlPlane);
304
+ const freshChoice = workspaceChoice !== undefined &&
305
+ (options.chooseWorkspace === true || previousSession?.workspaceChoice !== workspaceChoice);
300
306
  if (workspaceChoice !== undefined) {
301
307
  credentials = {
302
308
  ...credentials,
303
- sessions: credentials.sessions.filter((entry) => entry.controlPlane !== controlPlane),
304
309
  pendingPairings: credentials.pendingPairings.filter((entry) => entry.controlPlane !== controlPlane || entry.workspaceChoice === workspaceChoice),
305
310
  };
311
+ }
312
+ if (freshChoice) {
306
313
  say(options, "Choose the workspace in the new browser pairing. Existing agent connections remain saved.");
307
314
  }
308
315
  // A still-valid session carries straight on to the steps that need it.
309
- const stored = findSession(credentials, controlPlane);
316
+ const stored = freshChoice ? undefined : previousSession;
310
317
  if (stored && Date.parse(stored.expiresAt) > now().getTime()) {
311
318
  try {
312
319
  const answer = await request(controlPlane, {
@@ -378,6 +385,9 @@ export async function runSetup(options) {
378
385
  }
379
386
  let started;
380
387
  try {
388
+ // An unsaved claim secret cannot be recovered by retrying the browser link.
389
+ // Check local access before creating an unapproved remote pairing.
390
+ assertStoreWritable(options.environment);
381
391
  const { verifier, digest } = newVerifier();
382
392
  started = await request(controlPlane, {
383
393
  method: "POST",
@@ -450,7 +460,7 @@ async function claimOnce(options, credentials, pending) {
450
460
  if (answer.status === "pending") {
451
461
  return { kind: "pending", pollIntervalSeconds: answer.pollIntervalSeconds };
452
462
  }
453
- const updated = storeSession(credentials, options.controlPlane, answer.token, answer.session);
463
+ const updated = storeSession(credentials, options.controlPlane, answer.token, answer.session, pending.workspaceChoice);
454
464
  writeCredentials(updated, options.environment);
455
465
  reportPaired(options, answer.session);
456
466
  const session = findSession(updated, options.controlPlane);
package/dist/store.d.ts CHANGED
@@ -15,6 +15,8 @@ export type PendingPairing = Readonly<{
15
15
  workspaceChoice?: string;
16
16
  }>;
17
17
  export type StoredSession = Readonly<{
18
+ /** Claimed local create intent, not workspace identity or server authority. */
19
+ workspaceChoice?: string;
18
20
  controlPlane: string;
19
21
  sessionId: string;
20
22
  token: string;
@@ -76,6 +78,8 @@ export declare function credentialsPath(environment?: NodeJS.ProcessEnv): string
76
78
  */
77
79
  export declare function assertSafeStoreLocation(directory: string, environment?: NodeJS.ProcessEnv, cwd?: string): void;
78
80
  export declare function ensureStoreDirectory(environment?: NodeJS.ProcessEnv): string;
81
+ /** Check actual write permission before requesting a pairing whose secret must be saved. */
82
+ export declare function assertStoreWritable(environment?: NodeJS.ProcessEnv): void;
79
83
  export declare function readCredentials(environment?: NodeJS.ProcessEnv): Credentials;
80
84
  /**
81
85
  * Atomic, and never through a path an attacker could have pre-planted: the
package/dist/store.js CHANGED
@@ -86,12 +86,38 @@ function assertPrivate(path, kind) {
86
86
  throw new StoreError("credential_store_unsafe", `${path} is readable by other users. Run: chmod ${kind === "directory" ? "700" : "600"} ${path}`);
87
87
  }
88
88
  }
89
+ function storeAccessError(error, path, operation) {
90
+ if (error instanceof StoreError)
91
+ return error;
92
+ const code = error?.code;
93
+ const permissionDenied = code === "EACCES" || code === "EPERM";
94
+ return new StoreError(operation === "read" ? "credential_store_unreadable" : "credential_store_unwritable", permissionDenied
95
+ ? `Balladeer cannot ${operation} its local credential store at ${path}. Allow this command to access that private folder in your terminal or coding agent, then rerun the same command. Existing saved credentials were not replaced.`
96
+ : `Balladeer could not ${operation} its local credential store at ${path} (${code ?? "filesystem error"}). Check that this location is a private, accessible directory, then retry. Existing saved credentials were not replaced.`, { cause: error });
97
+ }
89
98
  export function ensureStoreDirectory(environment = process.env) {
90
99
  const directory = configHome(environment);
91
- assertSafeStoreLocation(directory, environment);
92
- mkdirSync(directory, { recursive: true, mode: 0o700 });
93
- assertPrivate(directory, "directory");
94
- return directory;
100
+ try {
101
+ assertSafeStoreLocation(directory, environment);
102
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
103
+ assertPrivate(directory, "directory");
104
+ return directory;
105
+ }
106
+ catch (error) {
107
+ throw storeAccessError(error, directory, "write");
108
+ }
109
+ }
110
+ /** Check actual write permission before requesting a pairing whose secret must be saved. */
111
+ export function assertStoreWritable(environment = process.env) {
112
+ const probe = `.write-check-${randomBytes(8).toString("hex")}`;
113
+ const path = join(configHome(environment), probe);
114
+ try {
115
+ writeStoreFile(probe, "", environment);
116
+ unlinkSync(path);
117
+ }
118
+ catch (error) {
119
+ throw storeAccessError(error, configHome(environment), "write");
120
+ }
95
121
  }
96
122
  export function readCredentials(environment = process.env) {
97
123
  const path = credentialsPath(environment);
@@ -101,9 +127,10 @@ export function readCredentials(environment = process.env) {
101
127
  raw = readFileSync(path, "utf8");
102
128
  }
103
129
  catch (error) {
104
- if (error instanceof StoreError)
105
- throw error;
106
- return { ...EMPTY, pendingPairings: [], sessions: [], agents: [] };
130
+ if (error?.code === "ENOENT") {
131
+ return { ...EMPTY, pendingPairings: [], sessions: [], agents: [] };
132
+ }
133
+ throw storeAccessError(error, path, "read");
107
134
  }
108
135
  try {
109
136
  const parsed = JSON.parse(raw);
@@ -164,9 +191,7 @@ export function writeStoreFile(fileName, contents, environment = process.env) {
164
191
  catch {
165
192
  // The temporary file may never have been created.
166
193
  }
167
- if (error instanceof StoreError)
168
- throw error;
169
- throw new StoreError("credential_store_unwritable", `Balladeer could not write ${path}: ${error instanceof Error ? error.message : "unknown reason"}`, { cause: error });
194
+ throw storeAccessError(error, path, "write");
170
195
  }
171
196
  }
172
197
  /** Origins, not URL strings, so `https://host/../` cannot alias a stored entry. */
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.3";
8
+ export declare const CLI_VERSION = "1.0.4";
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.3";
11
+ export declare const CLIENT_HEADER_VALUE = "balladeer/1.0.4";
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[];
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.3";
8
+ export const CLI_VERSION = "1.0.4";
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.3",
3
+ "version": "1.0.4",
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,