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.
- package/dist/agent.d.ts +1 -1
- package/dist/agent.js +12 -0
- package/dist/cli.d.ts +3 -2
- package/dist/cli.js +120 -56
- package/dist/commands/discover.js +2 -2
- package/dist/commands/guidance.d.ts +11 -1
- package/dist/commands/guidance.js +24 -11
- package/dist/commands/mcp.js +2 -2
- package/dist/commands/prepare.js +2 -2
- package/dist/commands/propose.js +2 -2
- package/dist/commands/session.js +2 -2
- package/dist/commands/setup.d.ts +16 -0
- package/dist/commands/setup.js +86 -18
- package/dist/commands/status.js +2 -2
- package/dist/currency.d.ts +3 -0
- package/dist/currency.js +8 -0
- package/dist/guidance-hook.mjs +99 -46
- package/dist/guidance.js +4 -0
- package/dist/install.d.ts +2 -0
- package/dist/install.js +2 -0
- package/dist/open-browser.d.ts +13 -0
- package/dist/open-browser.js +35 -0
- package/dist/release.d.ts +6 -0
- package/dist/release.js +6 -0
- package/dist/remove-earlier.d.ts +68 -0
- package/dist/remove-earlier.js +378 -0
- package/dist/repository.d.ts +9 -0
- package/dist/repository.js +31 -5
- package/dist/self-update.d.ts +11 -0
- package/dist/self-update.js +59 -0
- package/dist/user-scope.d.ts +23 -7
- package/dist/user-scope.js +72 -18
- package/dist/wire.d.ts +16 -3
- package/dist/wire.js +1 -1
- package/package.json +1 -1
- package/dist/legacy.d.ts +0 -41
- package/dist/legacy.js +0 -143
- package/dist/quiet.d.ts +0 -25
- package/dist/quiet.js +0 -73
package/dist/commands/setup.js
CHANGED
|
@@ -12,16 +12,18 @@ import { MCP_CONFIG_FILE, currentEntry, entryRepositoryId, isOurEntry, mergeMcpC
|
|
|
12
12
|
import { DESKTOP_CONFIG_FILE, desktopConfigLocation, desktopServerKey, desktopStdioEntry, mergeDesktopConfig, } from "../desktop-config.js";
|
|
13
13
|
import { selectAgent } from "../agent.js";
|
|
14
14
|
import { installGuidanceLoader } from "../guidance-install.js";
|
|
15
|
-
import {
|
|
15
|
+
import { describeEarlier, describeRemoval, findEarlier, removeEarlier } from "../remove-earlier.js";
|
|
16
|
+
import { userHome } from "../user-scope.js";
|
|
16
17
|
import { formatInstant } from "../local-time.js";
|
|
17
18
|
import { checkoutEntryPath, commandLine, runningFromRegistryInstall } from "../release.js";
|
|
18
|
-
import { hostHint, repositoryHint } from "../repository.js";
|
|
19
|
+
import { hostHint, repositoryHint, repositoryHints } from "../repository.js";
|
|
19
20
|
import { StoreError, assertStoreWritable, credentialsPath, dropPendingPairing, findPendingPairing, findSession, putAgent, putPendingPairing, putSession, readCredentials, writeCredentials, } from "../store.js";
|
|
20
21
|
import { CLI_VERSION, DELEGATED_SCOPES, } from "../wire.js";
|
|
21
22
|
import { explainText } from "./explain.js";
|
|
22
23
|
import { probeAgentConnection } from "./mcp.js";
|
|
23
24
|
const REFUSAL_SENTENCE = "It may not agree to what a promise means, agree a change to what a promise means, grant an exception, transfer ownership, supersede, retire, or offboard anything.";
|
|
24
25
|
const MAX_WAIT_MS = 10 * 60 * 1000;
|
|
26
|
+
const AGENT_WAIT_MS = 90 * 1000;
|
|
25
27
|
/** The bound the control plane's own name check holds, refused here so a name
|
|
26
28
|
* nobody could create never costs a pairing code. */
|
|
27
29
|
export const CREATE_WORKSPACE_MAX_LENGTH = 120;
|
|
@@ -143,13 +145,15 @@ function pairedLines(session) {
|
|
|
143
145
|
* scrolled, or an agent whose context was compacted, is told to approve in the
|
|
144
146
|
* browser, and no other command in this release can produce that URL again.
|
|
145
147
|
*/
|
|
146
|
-
function pendingLines(pending, repository, host, waiting, approvalUri) {
|
|
148
|
+
function pendingLines(pending, repository, host, waiting, approvalUri, opened) {
|
|
147
149
|
const opening = waiting
|
|
148
150
|
? ["Step 1 of 5 Waiting for approval", ` Nobody has approved ${pending.userCode} yet.`]
|
|
149
151
|
: ["Step 1 of 5 Sign in and approve this session"];
|
|
150
152
|
return [
|
|
151
153
|
...opening,
|
|
152
|
-
|
|
154
|
+
opened
|
|
155
|
+
? ` FOR THE PERSON AT THIS LAPTOP: a browser tab has opened at ${approvalUri}. Click Approve there. If no tab appeared, open that link yourself.`
|
|
156
|
+
: ` FOR THE PERSON AT THIS LAPTOP: open ${approvalUri} in your browser and click Approve.`,
|
|
153
157
|
` The page will show this code: ${pending.userCode}`,
|
|
154
158
|
// A person who belongs to two workspaces approves into whichever one their
|
|
155
159
|
// browser is signed into, and twice that was not the one they meant: the
|
|
@@ -159,7 +163,9 @@ function pendingLines(pending, repository, host, waiting, approvalUri) {
|
|
|
159
163
|
` ${APPROVE_IN_THE_RIGHT_WORKSPACE}`,
|
|
160
164
|
` Sent to Balladeer so far: a random code, the name "${repository}", and the hostname "${host}". Nothing else.`,
|
|
161
165
|
` This pairing expires at ${formatInstant(pending.expiresAt)}.`,
|
|
162
|
-
|
|
166
|
+
opened
|
|
167
|
+
? " This command waits here for the approval and finishes on its own; if it returns first, run it again to finish."
|
|
168
|
+
: " Approve in the browser, then run this command again to finish.",
|
|
163
169
|
"",
|
|
164
170
|
// Printed on the pairing step and nowhere else, because this is the one
|
|
165
171
|
// moment the answer matters: the person is standing in front of a page that
|
|
@@ -259,11 +265,29 @@ export async function runSetup(options) {
|
|
|
259
265
|
// that still carries the July client gets one instruction and this run stops.
|
|
260
266
|
// Refresh is inside the check rather than outside it, because a repair writes
|
|
261
267
|
// the same two files a first setup writes.
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
268
|
+
// The earlier Balladeer, still on this machine, is taken off first: both
|
|
269
|
+
// products answer to the name `balladeer`, and while the older entry stands
|
|
270
|
+
// the host shows its server and refuses ours. Every file changed is copied
|
|
271
|
+
// beside itself first and the copies are named. `--force` sets up alongside
|
|
272
|
+
// it instead, for whoever has decided to run both.
|
|
273
|
+
if (options.force !== true) {
|
|
274
|
+
const earlier = findEarlier({
|
|
275
|
+
home: userHome(options.environment),
|
|
276
|
+
controlPlane: options.controlPlane,
|
|
277
|
+
environment: options.environment,
|
|
278
|
+
cwd: options.cwd,
|
|
279
|
+
});
|
|
280
|
+
if (earlier.length > 0) {
|
|
281
|
+
say(options, "An earlier Balladeer is on this machine:");
|
|
282
|
+
for (const line of describeEarlier(earlier))
|
|
283
|
+
say(options, line);
|
|
284
|
+
const report = removeEarlier(earlier);
|
|
285
|
+
for (const line of describeRemoval(report))
|
|
286
|
+
say(options, line);
|
|
287
|
+
emit(options, { step: "earlier", removed: report.removed, failed: report.failed });
|
|
288
|
+
if (report.failed.length > 0)
|
|
289
|
+
return fail(options, "earlier_balladeer_remains", `${report.failed.length} of the earlier Balladeer's entries could not be removed; see above. Fix those by hand, or run this command again with --force to set up alongside it.`, 6);
|
|
290
|
+
}
|
|
267
291
|
}
|
|
268
292
|
if (options.refresh === true)
|
|
269
293
|
return runRefresh(options);
|
|
@@ -272,11 +296,23 @@ export async function runSetup(options) {
|
|
|
272
296
|
say(options, `Balladeer setup, version ${CLI_VERSION}.\n`);
|
|
273
297
|
say(options, `Agent client: ${options.client === "codex" ? "Codex" : "Claude"}. Use --client codex or --client claude to choose.`);
|
|
274
298
|
emit(options, { step: "explain", version: CLI_VERSION });
|
|
275
|
-
say(options, explainText(controlPlane));
|
|
276
299
|
const chosen = chooseRepositories(namedRepositories(options), options.cwd);
|
|
277
300
|
if (chosen.kind === "mismatch") {
|
|
278
301
|
return fail(options, "repository_mismatch", chosen.message, 4);
|
|
279
302
|
}
|
|
303
|
+
// Where this run is, said before anything else. People paste this command
|
|
304
|
+
// into an agent whose shell may be in no repository at all; the run still
|
|
305
|
+
// pairs the laptop, and the first line says what it will and will not do.
|
|
306
|
+
const inCheckout = repositoryHint(options.cwd) !== "unknown/unknown";
|
|
307
|
+
if (!inCheckout && (options.repositories ?? []).length === 0)
|
|
308
|
+
say(options, "This folder isn't a repository checkout. This run pairs this laptop and installs Balladeer for every session; to connect a repository, run this same command inside a checkout of it afterwards.");
|
|
309
|
+
// The boundary explanation is for a person reading a terminal. An agent
|
|
310
|
+
// shell gets one line and `balladeer explain`, so the approval link is the
|
|
311
|
+
// first actionable thing it sees and relays.
|
|
312
|
+
if (options.interactive === true)
|
|
313
|
+
say(options, explainText(controlPlane));
|
|
314
|
+
else
|
|
315
|
+
say(options, "What Balladeer can and cannot see, and what this run does: `balladeer explain`.");
|
|
280
316
|
// Before the pairing is started, not after: a name the control plane would
|
|
281
317
|
// refuse must cost nobody a code they then have to abandon, and this run has
|
|
282
318
|
// sent nothing yet.
|
|
@@ -371,7 +407,8 @@ export async function runSetup(options) {
|
|
|
371
407
|
return outcome.exitCode;
|
|
372
408
|
// Printed before the wait as well as instead of it: a run that blocks for ten
|
|
373
409
|
// minutes without ever naming the link is the run whose person cannot act.
|
|
374
|
-
|
|
410
|
+
const opened = openForPerson(options, approvalLink(options, pending.verificationUri));
|
|
411
|
+
for (const line of pendingLines(pending, repository, host, true, approvalLink(options, pending.verificationUri), opened))
|
|
375
412
|
say(options, line);
|
|
376
413
|
emit(options, {
|
|
377
414
|
step: "pair",
|
|
@@ -380,7 +417,7 @@ export async function runSetup(options) {
|
|
|
380
417
|
verificationUri: approvalLink(options, pending.verificationUri),
|
|
381
418
|
expiresAt: pending.expiresAt,
|
|
382
419
|
});
|
|
383
|
-
if (!options.wait) {
|
|
420
|
+
if (!options.wait && !opened) {
|
|
384
421
|
pairingOnlyReceipt(options, "Nothing yet: this pairing is still waiting for someone to approve it.");
|
|
385
422
|
return 0;
|
|
386
423
|
}
|
|
@@ -422,7 +459,8 @@ export async function runSetup(options) {
|
|
|
422
459
|
expiresAt: lapsed.expiresAt,
|
|
423
460
|
});
|
|
424
461
|
}
|
|
425
|
-
|
|
462
|
+
const opened = openForPerson(options, approvalLink(options, pending.verificationUri));
|
|
463
|
+
for (const line of pendingLines(pending, repository, host, false, approvalLink(options, pending.verificationUri), opened))
|
|
426
464
|
say(options, line);
|
|
427
465
|
emit(options, {
|
|
428
466
|
step: "pair",
|
|
@@ -431,7 +469,7 @@ export async function runSetup(options) {
|
|
|
431
469
|
verificationUri: approvalLink(options, pending.verificationUri),
|
|
432
470
|
expiresAt: pending.expiresAt,
|
|
433
471
|
});
|
|
434
|
-
if (options.wait) {
|
|
472
|
+
if (options.wait || opened) {
|
|
435
473
|
return waitForApproval(options, credentials, pending, started.pollIntervalSeconds);
|
|
436
474
|
}
|
|
437
475
|
pairingOnlyReceipt(options, "Started a pairing and printed the link. Nobody has approved it yet.");
|
|
@@ -564,11 +602,32 @@ function terminalRefusal(pending, code) {
|
|
|
564
602
|
}
|
|
565
603
|
return undefined;
|
|
566
604
|
}
|
|
605
|
+
/**
|
|
606
|
+
* The link, opened for a person at the terminal and for nobody else. Whether a
|
|
607
|
+
* browser appeared is not known here; the line beside the link says what to do
|
|
608
|
+
* if none did.
|
|
609
|
+
*/
|
|
610
|
+
export function openForPerson(options, url) {
|
|
611
|
+
// An agent's shell is still on the person's laptop, so the page can open
|
|
612
|
+
// there too; only --json and --no-open keep it closed.
|
|
613
|
+
// Only an opener the entry point handed over is used: a test, or any
|
|
614
|
+
// caller that passed none, never reaches a browser and never waits.
|
|
615
|
+
if (options.json || !options.openLink)
|
|
616
|
+
return false;
|
|
617
|
+
return options.openLink(url);
|
|
618
|
+
}
|
|
619
|
+
/** How long a run waits for the click: a person at a terminal has ten
|
|
620
|
+
* minutes; an agent's shell, whose tool may cut the command at two, gets
|
|
621
|
+
* ninety seconds and the pairing is saved either way. */
|
|
622
|
+
function waitBudgetMs(options) {
|
|
623
|
+
return options.interactive === true ? MAX_WAIT_MS : AGENT_WAIT_MS;
|
|
624
|
+
}
|
|
567
625
|
async function waitForApproval(options, credentials, pending, pollIntervalSeconds) {
|
|
568
626
|
const sleep = options.sleep ?? ((ms) => new Promise((done) => setTimeout(done, ms)));
|
|
569
627
|
const now = options.now ?? (() => new Date());
|
|
570
|
-
const
|
|
571
|
-
|
|
628
|
+
const budget = waitBudgetMs(options);
|
|
629
|
+
const deadline = now().getTime() + budget;
|
|
630
|
+
say(options, ` Waiting up to ${budget >= 60_000 ? `${budget / 60_000} minutes` : `${budget / 1000} seconds`} for someone to approve ${pending.userCode} at ${approvalLink(options, pending.verificationUri)}. Press Ctrl+C to stop; the pairing is saved either way, and the code stays approvable until ${formatInstant(pending.expiresAt)}.`);
|
|
572
631
|
let interval = pollIntervalSeconds;
|
|
573
632
|
while (now().getTime() < deadline) {
|
|
574
633
|
await sleep(Math.max(interval, 2) * 1000);
|
|
@@ -599,6 +658,15 @@ function refusalSentence(error) {
|
|
|
599
658
|
return error instanceof Error ? error.message : String(error);
|
|
600
659
|
}
|
|
601
660
|
async function runRemainingSteps(options, credentials, session) {
|
|
661
|
+
// Said to a JSON reader here, after the pairing object, so the step order
|
|
662
|
+
// every caller relies on is unchanged; a person read it as the first line.
|
|
663
|
+
if (repositoryHint(options.cwd) === "unknown/unknown" &&
|
|
664
|
+
(options.repositories ?? []).length === 0)
|
|
665
|
+
emit(options, {
|
|
666
|
+
step: "warning",
|
|
667
|
+
code: "not_in_checkout",
|
|
668
|
+
message: "This folder isn't a repository checkout; this run paired the laptop and installed Balladeer for every session, and connects no repository.",
|
|
669
|
+
});
|
|
602
670
|
let state;
|
|
603
671
|
try {
|
|
604
672
|
state = await request(options.controlPlane, {
|
|
@@ -1676,7 +1744,7 @@ async function runRefresh(input) {
|
|
|
1676
1744
|
let storedName;
|
|
1677
1745
|
try {
|
|
1678
1746
|
const credentials = readCredentials(options.environment);
|
|
1679
|
-
const selection = selectAgent(credentials.agents, options.controlPlane, undefined,
|
|
1747
|
+
const selection = selectAgent(credentials.agents, options.controlPlane, undefined, repositoryHints(options.cwd));
|
|
1680
1748
|
if (selection.kind === "agent") {
|
|
1681
1749
|
stored = selection.agent.repositoryId;
|
|
1682
1750
|
storedName = selection.agent.repository;
|
package/dist/commands/status.js
CHANGED
|
@@ -4,7 +4,7 @@ import { repositoryRoot } from "../git.js";
|
|
|
4
4
|
import { MARKER_OBSERVATION_LIMITS, observeMissingMarkers, staleMarkerRow, } from "../markers.js";
|
|
5
5
|
import { formatInstant } from "../local-time.js";
|
|
6
6
|
import { commandLine } from "../release.js";
|
|
7
|
-
import { repositoryHint } from "../repository.js";
|
|
7
|
+
import { repositoryHint, repositoryHints } from "../repository.js";
|
|
8
8
|
import { SEALED_PATHS_SHOWN, sealedPromises } from "../seals.js";
|
|
9
9
|
import { StoreError, findSession, readCredentials } from "../store.js";
|
|
10
10
|
import { CLI_INVOCATION } from "../wire.js";
|
|
@@ -103,7 +103,7 @@ async function reportBalladeer(options) {
|
|
|
103
103
|
return fail(error instanceof StoreError ? error.code : "credential_store_unusable", error instanceof StoreError ? error.message : String(error), 4);
|
|
104
104
|
}
|
|
105
105
|
const here = options.repo ?? repositoryHint(options.cwd);
|
|
106
|
-
const selection = selectAgent(credentials.agents, options.controlPlane, options.repository,
|
|
106
|
+
const selection = selectAgent(credentials.agents, options.controlPlane, options.repository, options.repo ?? repositoryHints(options.cwd));
|
|
107
107
|
const storedSession = findSession(credentials, options.controlPlane);
|
|
108
108
|
const repositoryScoped = options.repo !== undefined ||
|
|
109
109
|
options.repository !== undefined ||
|
package/dist/currency.d.ts
CHANGED
|
@@ -27,5 +27,8 @@ export declare const CLI_ADVICE_HEADER = "x-balladeer-cli-advice";
|
|
|
27
27
|
export declare function noteServerVersion(headers: Headers): void;
|
|
28
28
|
/** The one line this run prints about itself, or nothing when it is current. */
|
|
29
29
|
export declare function updateNotice(): string | undefined;
|
|
30
|
+
/** The newer version the server named, when it named one; nothing when this
|
|
31
|
+
* copy is current or no server has answered yet. */
|
|
32
|
+
export declare function newerVersionPublished(): string | undefined;
|
|
30
33
|
/** For tests, and for a process that runs more than one command. */
|
|
31
34
|
export declare function resetUpdateNotice(): void;
|
package/dist/currency.js
CHANGED
|
@@ -24,6 +24,7 @@ export const CLI_ADVICE_HEADER = "x-balladeer-cli-advice";
|
|
|
24
24
|
const ADVICE_LIMIT = 300;
|
|
25
25
|
const VERSION = /^\d{1,4}\.\d{1,4}\.\d{1,4}$/;
|
|
26
26
|
let notice;
|
|
27
|
+
let newestSeen;
|
|
27
28
|
function older(left, right) {
|
|
28
29
|
const [a, b] = [left.split(".").map(Number), right.split(".").map(Number)];
|
|
29
30
|
for (let index = 0; index < 3; index += 1) {
|
|
@@ -55,6 +56,7 @@ export function noteServerVersion(headers) {
|
|
|
55
56
|
return;
|
|
56
57
|
if (!older(CLI_VERSION, newest))
|
|
57
58
|
return;
|
|
59
|
+
newestSeen = newest;
|
|
58
60
|
const advice = headers.get(CLI_ADVICE_HEADER);
|
|
59
61
|
const line = advice === null ? "" : bounded(advice);
|
|
60
62
|
notice =
|
|
@@ -66,7 +68,13 @@ export function noteServerVersion(headers) {
|
|
|
66
68
|
export function updateNotice() {
|
|
67
69
|
return notice;
|
|
68
70
|
}
|
|
71
|
+
/** The newer version the server named, when it named one; nothing when this
|
|
72
|
+
* copy is current or no server has answered yet. */
|
|
73
|
+
export function newerVersionPublished() {
|
|
74
|
+
return newestSeen;
|
|
75
|
+
}
|
|
69
76
|
/** For tests, and for a process that runs more than one command. */
|
|
70
77
|
export function resetUpdateNotice() {
|
|
71
78
|
notice = undefined;
|
|
79
|
+
newestSeen = undefined;
|
|
72
80
|
}
|
package/dist/guidance-hook.mjs
CHANGED
|
@@ -2027,7 +2027,7 @@ var require_toml = __commonJS({
|
|
|
2027
2027
|
});
|
|
2028
2028
|
|
|
2029
2029
|
// src/wire.ts
|
|
2030
|
-
var CLI_VERSION = "1.0.
|
|
2030
|
+
var CLI_VERSION = "1.0.12";
|
|
2031
2031
|
var CLIENT_HEADER = "x-balladeer-client";
|
|
2032
2032
|
var CLIENT_HEADER_VALUE = `balladeer/${CLI_VERSION}`;
|
|
2033
2033
|
var DEFAULT_CONTROL_PLANE = "https://envelopes.balladeer.ai";
|
|
@@ -2119,6 +2119,37 @@ function readCredentials(environment = process.env) {
|
|
|
2119
2119
|
}
|
|
2120
2120
|
}
|
|
2121
2121
|
|
|
2122
|
+
// src/currency.ts
|
|
2123
|
+
var CLI_NEWEST_HEADER = "x-balladeer-cli-newest";
|
|
2124
|
+
var CLI_ADVICE_HEADER = "x-balladeer-cli-advice";
|
|
2125
|
+
var ADVICE_LIMIT = 300;
|
|
2126
|
+
var VERSION = /^\d{1,4}\.\d{1,4}\.\d{1,4}$/;
|
|
2127
|
+
var notice;
|
|
2128
|
+
var newestSeen;
|
|
2129
|
+
function older(left, right) {
|
|
2130
|
+
const [a, b] = [left.split(".").map(Number), right.split(".").map(Number)];
|
|
2131
|
+
for (let index = 0; index < 3; index += 1) {
|
|
2132
|
+
const difference = a[index] - b[index];
|
|
2133
|
+
if (difference !== 0) return difference < 0;
|
|
2134
|
+
}
|
|
2135
|
+
return false;
|
|
2136
|
+
}
|
|
2137
|
+
function bounded(value) {
|
|
2138
|
+
return value.replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, ADVICE_LIMIT);
|
|
2139
|
+
}
|
|
2140
|
+
function noteServerVersion(headers) {
|
|
2141
|
+
const newest = headers.get(CLI_NEWEST_HEADER)?.trim();
|
|
2142
|
+
if (newest === void 0 || !VERSION.test(newest)) return;
|
|
2143
|
+
if (!older(CLI_VERSION, newest)) return;
|
|
2144
|
+
newestSeen = newest;
|
|
2145
|
+
const advice = headers.get(CLI_ADVICE_HEADER);
|
|
2146
|
+
const line = advice === null ? "" : bounded(advice);
|
|
2147
|
+
notice = line === "" ? `A newer Balladeer is available (${newest}; this is ${CLI_VERSION}). Run npx -y balladeer@latest setup --refresh to update the command and instructions, then restart this session.` : line;
|
|
2148
|
+
}
|
|
2149
|
+
function newerVersionPublished() {
|
|
2150
|
+
return newestSeen;
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2122
2153
|
// src/guidance.ts
|
|
2123
2154
|
import { createHash, randomBytes } from "node:crypto";
|
|
2124
2155
|
import {
|
|
@@ -2275,6 +2306,7 @@ async function loadGuidance(input) {
|
|
|
2275
2306
|
...previous ? { "if-none-match": previous.etag } : {}
|
|
2276
2307
|
}
|
|
2277
2308
|
});
|
|
2309
|
+
noteServerVersion(response.headers);
|
|
2278
2310
|
if (response.status === 304) {
|
|
2279
2311
|
if (!previous || response.headers.get("etag") !== previous.etag || response.headers.get("x-balladeer-guidance-revision") !== previous.document.revision) {
|
|
2280
2312
|
throw new Error("invalid_guidance_revalidation");
|
|
@@ -2413,7 +2445,7 @@ import {
|
|
|
2413
2445
|
import { basename, dirname as dirname2, isAbsolute, join as join3 } from "node:path";
|
|
2414
2446
|
|
|
2415
2447
|
// src/release.ts
|
|
2416
|
-
var
|
|
2448
|
+
var HOOK_SPECIFIER = "balladeer@1";
|
|
2417
2449
|
|
|
2418
2450
|
// src/mcp-config.ts
|
|
2419
2451
|
var MCP_CONFIG_FILE = ".mcp.json";
|
|
@@ -2874,6 +2906,16 @@ function validateProjectGuidanceScope(options) {
|
|
|
2874
2906
|
var REPOSITORY_NAME = /^[A-Za-z0-9._-]{1,39}\/[A-Za-z0-9._-]{1,100}$/;
|
|
2875
2907
|
function selectAgent(agents, controlPlane, repositoryId, currentRepository) {
|
|
2876
2908
|
const here = agents.filter((agent) => agent.controlPlane === controlPlane);
|
|
2909
|
+
if (typeof currentRepository !== "string") {
|
|
2910
|
+
const candidates = currentRepository.filter(
|
|
2911
|
+
(name) => REPOSITORY_NAME.test(name) && name !== "unknown/unknown"
|
|
2912
|
+
);
|
|
2913
|
+
for (const name of candidates) {
|
|
2914
|
+
const found = selectAgent(agents, controlPlane, repositoryId, name);
|
|
2915
|
+
if (found.kind !== "refused") return found;
|
|
2916
|
+
}
|
|
2917
|
+
return selectAgent(agents, controlPlane, repositoryId, candidates[0] ?? "unknown/unknown");
|
|
2918
|
+
}
|
|
2877
2919
|
if (repositoryId !== void 0) {
|
|
2878
2920
|
const named = here.find(
|
|
2879
2921
|
(agent) => agent.repositoryId === repositoryId || agent.repository?.toLowerCase() === repositoryId.toLowerCase()
|
|
@@ -2926,73 +2968,80 @@ function withSafeEndpoint(agent) {
|
|
|
2926
2968
|
// src/repository.ts
|
|
2927
2969
|
import { execFileSync as execFileSync2 } from "node:child_process";
|
|
2928
2970
|
var REPOSITORY_HINT_PATTERN = /^[A-Za-z0-9._-]{1,39}\/[A-Za-z0-9._-]{1,100}$/;
|
|
2929
|
-
function
|
|
2930
|
-
let remote;
|
|
2971
|
+
function git(cwd, args) {
|
|
2931
2972
|
try {
|
|
2932
|
-
|
|
2973
|
+
return execFileSync2("git", ["-C", cwd, ...args], {
|
|
2933
2974
|
encoding: "utf8",
|
|
2934
2975
|
stdio: ["ignore", "pipe", "ignore"]
|
|
2935
2976
|
}).trim();
|
|
2936
2977
|
} catch {
|
|
2937
|
-
return
|
|
2978
|
+
return void 0;
|
|
2938
2979
|
}
|
|
2980
|
+
}
|
|
2981
|
+
function ownerName(remote) {
|
|
2939
2982
|
const match = /github\.com[:/]+([A-Za-z0-9._-]{1,39})\/([A-Za-z0-9._-]{1,100}?)(?:\.git)?$/.exec(
|
|
2940
2983
|
remote
|
|
2941
2984
|
);
|
|
2942
|
-
if (!match) return
|
|
2985
|
+
if (!match) return void 0;
|
|
2943
2986
|
const hint = `${match[1]}/${match[2]}`;
|
|
2944
|
-
return REPOSITORY_HINT_PATTERN.test(hint) ? hint :
|
|
2987
|
+
return REPOSITORY_HINT_PATTERN.test(hint) ? hint : void 0;
|
|
2988
|
+
}
|
|
2989
|
+
function repositoryHints(cwd = process.cwd()) {
|
|
2990
|
+
const names = git(cwd, ["remote"])?.split("\n").filter((name) => name.length > 0) ?? [];
|
|
2991
|
+
const ordered = ["origin", ...names.filter((name) => name !== "origin")];
|
|
2992
|
+
const hints = [];
|
|
2993
|
+
for (const name of ordered) {
|
|
2994
|
+
const url = git(cwd, ["remote", "get-url", name]);
|
|
2995
|
+
const hint = url === void 0 ? void 0 : ownerName(url);
|
|
2996
|
+
if (hint !== void 0 && !hints.some((h) => h.toLowerCase() === hint.toLowerCase()))
|
|
2997
|
+
hints.push(hint);
|
|
2998
|
+
}
|
|
2999
|
+
return hints;
|
|
2945
3000
|
}
|
|
2946
3001
|
|
|
2947
|
-
// src/
|
|
2948
|
-
import {
|
|
2949
|
-
import { existsSync as existsSync3, mkdirSync as mkdirSync5, readFileSync as readFileSync6,
|
|
3002
|
+
// src/self-update.ts
|
|
3003
|
+
import { spawn } from "node:child_process";
|
|
3004
|
+
import { existsSync as existsSync3, mkdirSync as mkdirSync5, readFileSync as readFileSync6, writeFileSync as writeFileSync3 } from "node:fs";
|
|
2950
3005
|
import { join as join6 } from "node:path";
|
|
2951
|
-
var
|
|
2952
|
-
|
|
2953
|
-
|
|
3006
|
+
var STAMP = "self-update.json";
|
|
3007
|
+
var DAY_MS = 24 * 60 * 60 * 1e3;
|
|
3008
|
+
function stampPath(environment) {
|
|
3009
|
+
return join6(configHome(environment), STAMP);
|
|
2954
3010
|
}
|
|
2955
|
-
function
|
|
2956
|
-
const path =
|
|
2957
|
-
if (!existsSync3(path)) return
|
|
3011
|
+
function lastStarted(environment) {
|
|
3012
|
+
const path = stampPath(environment);
|
|
3013
|
+
if (!existsSync3(path)) return 0;
|
|
2958
3014
|
try {
|
|
2959
3015
|
const parsed = JSON.parse(readFileSync6(path, "utf8"));
|
|
2960
|
-
|
|
2961
|
-
return {
|
|
2962
|
-
schemaVersion: 1,
|
|
2963
|
-
folders: Array.isArray(parsed.folders) ? parsed.folders.filter(isString) : [],
|
|
2964
|
-
remotes: Array.isArray(parsed.remotes) ? parsed.remotes.filter(isString) : []
|
|
2965
|
-
};
|
|
3016
|
+
return typeof parsed.startedAt === "number" ? parsed.startedAt : 0;
|
|
2966
3017
|
} catch {
|
|
2967
|
-
return
|
|
3018
|
+
return 0;
|
|
2968
3019
|
}
|
|
2969
3020
|
}
|
|
2970
|
-
|
|
2971
|
-
|
|
3021
|
+
function detached(command, args) {
|
|
3022
|
+
const child = spawn(command, [...args], { detached: true, stdio: "ignore" });
|
|
3023
|
+
child.on("error", () => void 0);
|
|
3024
|
+
child.unref();
|
|
3025
|
+
}
|
|
3026
|
+
function startSelfUpdateIfDue(newer, deps) {
|
|
3027
|
+
if (newer === void 0) return false;
|
|
3028
|
+
const now = deps.now ?? Date.now;
|
|
3029
|
+
if (now() - lastStarted(deps.environment) < DAY_MS) return false;
|
|
2972
3030
|
try {
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
|
|
2978
|
-
}).trim()
|
|
3031
|
+
mkdirSync5(configHome(deps.environment), { recursive: true, mode: 448 });
|
|
3032
|
+
writeFileSync3(
|
|
3033
|
+
stampPath(deps.environment),
|
|
3034
|
+
JSON.stringify({ startedAt: now(), toward: newer }) + "\n",
|
|
3035
|
+
{ mode: 384 }
|
|
2979
3036
|
);
|
|
3037
|
+
(deps.run ?? detached)("npx", ["-y", HOOK_SPECIFIER, "install", "--json"]);
|
|
3038
|
+
return true;
|
|
2980
3039
|
} catch {
|
|
2981
|
-
return
|
|
3040
|
+
return false;
|
|
2982
3041
|
}
|
|
2983
3042
|
}
|
|
2984
|
-
function isQuiet(cwd, environment = process.env) {
|
|
2985
|
-
const list = readQuiet(environment);
|
|
2986
|
-
const folder = folderKey(cwd);
|
|
2987
|
-
const remote = repositoryHint(cwd);
|
|
2988
|
-
return list.folders.includes(folder) || remote !== "unknown/unknown" && list.remotes.some((r) => r.toLowerCase() === remote.toLowerCase());
|
|
2989
|
-
}
|
|
2990
3043
|
|
|
2991
3044
|
// src/commands/guidance.ts
|
|
2992
|
-
function untrackedLine(remote) {
|
|
2993
|
-
const here = remote === "unknown/unknown" ? "This folder" : `This folder (${remote})`;
|
|
2994
|
-
return `${here} isn't tracked by Balladeer, so promises can't be tracked from here. To track it, run \`npx -y ${PUBLISHED_SPECIFIER} setup\` in this folder. To stop this message here, run \`npx -y ${PUBLISHED_SPECIFIER} quiet\` (or \`quiet --repo\` for the whole repository).`;
|
|
2995
|
-
}
|
|
2996
3045
|
function readInput(stream) {
|
|
2997
3046
|
return new Promise((resolve2, reject) => {
|
|
2998
3047
|
let bytes = 0;
|
|
@@ -3065,14 +3114,12 @@ async function runGuidance(options) {
|
|
|
3065
3114
|
credentials,
|
|
3066
3115
|
options.controlPlane,
|
|
3067
3116
|
void 0,
|
|
3068
|
-
|
|
3117
|
+
repositoryHints(options.cwd)
|
|
3069
3118
|
);
|
|
3070
3119
|
return selection.kind === "refused" ? [] : [selection.agent];
|
|
3071
3120
|
})();
|
|
3072
3121
|
if (agents.length !== 1 || !agents[0]) {
|
|
3073
3122
|
if (projectScoped) throw new Error("guidance_connection_unavailable");
|
|
3074
|
-
if (event === "SessionStart" && !isQuiet(options.cwd, options.environment))
|
|
3075
|
-
emit(untrackedLine(repositoryHint(options.cwd)));
|
|
3076
3123
|
return 0;
|
|
3077
3124
|
}
|
|
3078
3125
|
const loaded = await loadGuidance({
|
|
@@ -3081,6 +3128,12 @@ async function runGuidance(options) {
|
|
|
3081
3128
|
timeoutMs: 750,
|
|
3082
3129
|
...options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}
|
|
3083
3130
|
});
|
|
3131
|
+
if (event === "SessionStart")
|
|
3132
|
+
startSelfUpdateIfDue(newerVersionPublished(), {
|
|
3133
|
+
environment: options.environment,
|
|
3134
|
+
...options.startUpdate ? { run: options.startUpdate } : {},
|
|
3135
|
+
...options.now ? { now: options.now } : {}
|
|
3136
|
+
});
|
|
3084
3137
|
const sessionId = identifier(record.session_id);
|
|
3085
3138
|
const agentId = identifier(record.agent_id);
|
|
3086
3139
|
recordGuidanceContext(
|
package/dist/guidance.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { noteServerVersion } from "./currency.js";
|
|
1
2
|
import { CLIENT_HEADER, CLIENT_HEADER_VALUE } from "./wire.js";
|
|
2
3
|
import { createHash, randomBytes } from "node:crypto";
|
|
3
4
|
import { constants, closeSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
@@ -182,6 +183,9 @@ export async function loadGuidance(input) {
|
|
|
182
183
|
...(previous ? { "if-none-match": previous.etag } : {}),
|
|
183
184
|
},
|
|
184
185
|
});
|
|
186
|
+
// The server names the newest published release on every answer; the
|
|
187
|
+
// hook is where a laptop learns it is behind, and never where it waits.
|
|
188
|
+
noteServerVersion(response.headers);
|
|
185
189
|
if (response.status === 304) {
|
|
186
190
|
if (!previous ||
|
|
187
191
|
response.headers.get("etag") !== previous.etag ||
|
package/dist/install.d.ts
CHANGED
|
@@ -21,5 +21,7 @@ export declare function runInstall(options: InstallOptions & {
|
|
|
21
21
|
allowPartial?: boolean;
|
|
22
22
|
/** Carried into the one JSON object this prints, never as a second line. */
|
|
23
23
|
extra?: Record<string, unknown>;
|
|
24
|
+
/** Told the durable command's path once it is installed, before the report. */
|
|
25
|
+
onInstalled?: (result: InstallResult) => Record<string, unknown> | undefined;
|
|
24
26
|
}): number;
|
|
25
27
|
export {};
|
package/dist/install.js
CHANGED
|
@@ -333,6 +333,7 @@ export function ensureInstalled(options) {
|
|
|
333
333
|
export function runInstall(options) {
|
|
334
334
|
try {
|
|
335
335
|
const result = ensureInstalled(options);
|
|
336
|
+
const more = options.onInstalled?.(result) ?? {};
|
|
336
337
|
const message = result.manualPathRequired
|
|
337
338
|
? `Balladeer is installed at ${result.command}. Add ${dirname(result.command)} to your shell PATH and reopen the terminal/coding agent. Automatic PATH editing is unavailable for this shell; this setup process can use the command now.`
|
|
338
339
|
: result.restartRequired
|
|
@@ -345,6 +346,7 @@ export function runInstall(options) {
|
|
|
345
346
|
status: result.manualPathRequired ? "path_pending" : result.status,
|
|
346
347
|
message,
|
|
347
348
|
...(options.extra ?? {}),
|
|
349
|
+
...more,
|
|
348
350
|
}) + "\n"
|
|
349
351
|
: message + "\n");
|
|
350
352
|
return result.manualPathRequired && !options.allowPartial ? 4 : 0;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
/**
|
|
3
|
+
* Open a page in the person's default browser, and say whether it was asked.
|
|
4
|
+
*
|
|
5
|
+
* Setup's one link used to be copied by hand from the terminal into a browser,
|
|
6
|
+
* which is the step a person at a keyboard does not want and an agent shell
|
|
7
|
+
* cannot do at all. So this runs only when a person is there (the caller
|
|
8
|
+
* checks the terminal), hands the URL to the operating system's own opener,
|
|
9
|
+
* and does not wait for it: a browser that never appears is reported by the
|
|
10
|
+
* link staying on screen, never by this command hanging. Nothing here reads
|
|
11
|
+
* the page or learns what happened in it.
|
|
12
|
+
*/
|
|
13
|
+
export declare function openInBrowser(url: string, platform?: NodeJS.Platform, run?: typeof spawn, environment?: NodeJS.ProcessEnv): boolean;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
/**
|
|
3
|
+
* Open a page in the person's default browser, and say whether it was asked.
|
|
4
|
+
*
|
|
5
|
+
* Setup's one link used to be copied by hand from the terminal into a browser,
|
|
6
|
+
* which is the step a person at a keyboard does not want and an agent shell
|
|
7
|
+
* cannot do at all. So this runs only when a person is there (the caller
|
|
8
|
+
* checks the terminal), hands the URL to the operating system's own opener,
|
|
9
|
+
* and does not wait for it: a browser that never appears is reported by the
|
|
10
|
+
* link staying on screen, never by this command hanging. Nothing here reads
|
|
11
|
+
* the page or learns what happened in it.
|
|
12
|
+
*/
|
|
13
|
+
export function openInBrowser(url, platform = process.platform, run = spawn, environment = process.env) {
|
|
14
|
+
if (!/^https:\/\//.test(url))
|
|
15
|
+
return false;
|
|
16
|
+
// A Linux box with no display (a CI runner, a server over SSH) has nowhere
|
|
17
|
+
// to open a page, and saying it opened one would make the run wait for a
|
|
18
|
+
// click that cannot come.
|
|
19
|
+
if (platform === "linux" && !environment.DISPLAY && !environment.WAYLAND_DISPLAY)
|
|
20
|
+
return false;
|
|
21
|
+
const [command, args] = platform === "darwin"
|
|
22
|
+
? ["open", [url]]
|
|
23
|
+
: platform === "win32"
|
|
24
|
+
? ["cmd", ["/c", "start", "", url]]
|
|
25
|
+
: ["xdg-open", [url]];
|
|
26
|
+
try {
|
|
27
|
+
const child = run(command, args, { detached: true, stdio: "ignore" });
|
|
28
|
+
child.on("error", () => undefined);
|
|
29
|
+
child.unref();
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
package/dist/release.d.ts
CHANGED
|
@@ -8,6 +8,12 @@
|
|
|
8
8
|
export declare const CHECKOUT_COMMAND = "node packages/cli/dist/cli.js";
|
|
9
9
|
/** The specifier every published surface names, which is a tag and never a pin. */
|
|
10
10
|
export declare const PUBLISHED_SPECIFIER = "balladeer@latest";
|
|
11
|
+
/**
|
|
12
|
+
* What a hook or MCP entry runs when no durable command is installed: the
|
|
13
|
+
* current major, so a 2.x is a choice a person makes and never a surprise at
|
|
14
|
+
* session start. The typed first command stays `@latest`.
|
|
15
|
+
*/
|
|
16
|
+
export declare const HOOK_SPECIFIER = "balladeer@1";
|
|
11
17
|
/**
|
|
12
18
|
* How to invoke this command, with no subcommand attached.
|
|
13
19
|
*
|
package/dist/release.js
CHANGED
|
@@ -10,6 +10,12 @@ import { fileURLToPath } from "node:url";
|
|
|
10
10
|
export const CHECKOUT_COMMAND = "node packages/cli/dist/cli.js";
|
|
11
11
|
/** The specifier every published surface names, which is a tag and never a pin. */
|
|
12
12
|
export const PUBLISHED_SPECIFIER = "balladeer@latest";
|
|
13
|
+
/**
|
|
14
|
+
* What a hook or MCP entry runs when no durable command is installed: the
|
|
15
|
+
* current major, so a 2.x is a choice a person makes and never a surprise at
|
|
16
|
+
* session start. The typed first command stays `@latest`.
|
|
17
|
+
*/
|
|
18
|
+
export const HOOK_SPECIFIER = "balladeer@1";
|
|
13
19
|
/**
|
|
14
20
|
* How to invoke this command, with no subcommand attached.
|
|
15
21
|
*
|