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.
- package/LICENSE +200 -5
- package/README.md +167 -68
- package/dist/agent.d.ts +126 -0
- package/dist/agent.js +209 -0
- package/dist/cli.d.ts +48 -0
- package/dist/cli.js +531 -0
- package/dist/client.d.ts +66 -0
- package/dist/client.js +142 -0
- package/dist/commands/affected.d.ts +22 -0
- package/dist/commands/affected.js +123 -0
- package/dist/commands/check-seals.d.ts +37 -0
- package/dist/commands/check-seals.js +289 -0
- package/dist/commands/discover.d.ts +68 -0
- package/dist/commands/discover.js +403 -0
- package/dist/commands/explain.d.ts +35 -0
- package/dist/commands/explain.js +90 -0
- package/dist/commands/invite.d.ts +24 -0
- package/dist/commands/invite.js +198 -0
- package/dist/commands/mcp.d.ts +65 -0
- package/dist/commands/mcp.js +202 -0
- package/dist/commands/prepare.d.ts +74 -0
- package/dist/commands/prepare.js +217 -0
- package/dist/commands/propose.d.ts +69 -0
- package/dist/commands/propose.js +284 -0
- package/dist/commands/repositories.d.ts +18 -0
- package/dist/commands/repositories.js +185 -0
- package/dist/commands/session.d.ts +35 -0
- package/dist/commands/session.js +118 -0
- package/dist/commands/setup.d.ts +98 -0
- package/dist/commands/setup.js +1600 -0
- package/dist/commands/status.d.ts +51 -0
- package/dist/commands/status.js +542 -0
- package/dist/commands/touch-map.d.ts +42 -0
- package/dist/commands/touch-map.js +251 -0
- package/dist/commands/whoami.d.ts +8 -0
- package/dist/commands/whoami.js +80 -0
- package/dist/conventions.d.ts +77 -0
- package/dist/conventions.js +183 -0
- package/dist/copy.d.ts +224 -0
- package/dist/copy.js +641 -0
- package/dist/currency.d.ts +31 -0
- package/dist/currency.js +72 -0
- package/dist/desktop-config.d.ts +85 -0
- package/dist/desktop-config.js +217 -0
- package/dist/gh.d.ts +80 -0
- package/dist/gh.js +188 -0
- package/dist/git.d.ts +91 -0
- package/dist/git.js +226 -0
- package/dist/legacy.d.ts +41 -0
- package/dist/legacy.js +143 -0
- package/dist/local-time.d.ts +66 -0
- package/dist/local-time.js +84 -0
- package/dist/markers.d.ts +76 -0
- package/dist/markers.js +125 -0
- package/dist/mcp-config.d.ts +109 -0
- package/dist/mcp-config.js +234 -0
- package/dist/release.d.ts +55 -0
- package/dist/release.js +67 -0
- package/dist/repository.d.ts +8 -0
- package/dist/repository.js +32 -0
- package/dist/seals.d.ts +48 -0
- package/dist/seals.js +112 -0
- package/dist/session.d.ts +84 -0
- package/dist/session.js +135 -0
- package/dist/store.d.ts +108 -0
- package/dist/store.js +237 -0
- package/dist/touch-map.d.ts +241 -0
- package/dist/touch-map.js +487 -0
- package/dist/wire.d.ts +674 -0
- package/dist/wire.js +20 -0
- package/package.json +19 -10
- package/bin/balladeer.js +0 -161
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which of a promise's scope markers no longer name anything in this checkout.
|
|
3
|
+
*
|
|
4
|
+
* A rename is the ordinary way a promise stops being retrievable. Somebody
|
|
5
|
+
* moves `src/import/preview.ts` to `src/ingest/preview.ts`, the code keeps
|
|
6
|
+
* working, the check keeps passing, and the promise about it quietly stops
|
|
7
|
+
* overlapping any path an agent will ever name. Nothing on the server can see
|
|
8
|
+
* that: Balladeer holds no token for the repository and never reads a file, so
|
|
9
|
+
* the only place the fact exists is the working tree in front of the runner.
|
|
10
|
+
*
|
|
11
|
+
* So it is observed here, locally, and reported as attention rather than
|
|
12
|
+
* repaired. A marker is a piece of approved meaning; moving one is a semantic
|
|
13
|
+
* revision a named person makes, and a command that rewrote markers because a
|
|
14
|
+
* file moved would be editing what somebody agreed to on the strength of a
|
|
15
|
+
* `git mv`.
|
|
16
|
+
*
|
|
17
|
+
* What leaves this machine is nothing. The observation is printed where the
|
|
18
|
+
* person ran the command.
|
|
19
|
+
*/
|
|
20
|
+
/** How many missing markers one run reports, and how many it will even look at. */
|
|
21
|
+
export declare const MARKER_OBSERVATION_LIMITS: {
|
|
22
|
+
/** Markers checked on disk in one run. Beyond this the run says it stopped counting. */
|
|
23
|
+
readonly maxChecked: 500;
|
|
24
|
+
/** Promises named in the printed row. The rest are counted, not listed. */
|
|
25
|
+
readonly maxNamed: 5;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* A marker is a path when it names one.
|
|
29
|
+
*
|
|
30
|
+
* The grammar admits both `src/import/preview.ts` and `supplier-import-preview`.
|
|
31
|
+
* Only the first can be missing from a checkout; the second is a topic, and
|
|
32
|
+
* reporting it as a missing file would tell a person to go looking for
|
|
33
|
+
* something that was never a file.
|
|
34
|
+
*/
|
|
35
|
+
export declare function isPathShapedMarker(marker: string): boolean;
|
|
36
|
+
export type MarkerObservation = Readonly<{
|
|
37
|
+
promiseId: string;
|
|
38
|
+
title: string;
|
|
39
|
+
/** The markers this promise names that are not in this checkout. */
|
|
40
|
+
missing: readonly string[];
|
|
41
|
+
}>;
|
|
42
|
+
export type MarkerObservationResult = Readonly<{
|
|
43
|
+
/** Promises with at least one missing path marker, in the order they were given. */
|
|
44
|
+
observations: readonly MarkerObservation[];
|
|
45
|
+
/** How many path-shaped markers were actually looked at. */
|
|
46
|
+
checked: number;
|
|
47
|
+
/** True when the run stopped at its ceiling and did not look at every marker. */
|
|
48
|
+
stoppedEarly: boolean;
|
|
49
|
+
}>;
|
|
50
|
+
export type MarkerSubject = Readonly<{
|
|
51
|
+
promiseId: string;
|
|
52
|
+
title: string;
|
|
53
|
+
surfaces?: readonly string[];
|
|
54
|
+
labels?: readonly string[];
|
|
55
|
+
}>;
|
|
56
|
+
/**
|
|
57
|
+
* Look for each promise's path markers in this working tree.
|
|
58
|
+
*
|
|
59
|
+
* Bounded twice over: it stops after `maxChecked` markers whatever remains, and
|
|
60
|
+
* it says that it stopped. A repository with a thousand promises must not turn
|
|
61
|
+
* `status` into a filesystem walk, and a run that silently examined the first
|
|
62
|
+
* few hundred would report "nothing missing" about a catalog it never read.
|
|
63
|
+
*/
|
|
64
|
+
export declare function observeMissingMarkers(repositoryRoot: string, subjects: readonly MarkerSubject[], limits?: Readonly<{
|
|
65
|
+
maxChecked: number;
|
|
66
|
+
}>, exists?: (path: string) => boolean): MarkerObservationResult;
|
|
67
|
+
/**
|
|
68
|
+
* The attention row a person reads, or nothing at all when every marker is
|
|
69
|
+
* still there.
|
|
70
|
+
*
|
|
71
|
+
* It names what to do and who does it: a marker is approved meaning, so the
|
|
72
|
+
* remedy is a revision somebody agrees to, never an edit this command makes.
|
|
73
|
+
*/
|
|
74
|
+
export declare function staleMarkerRow(result: MarkerObservationResult, limits?: Readonly<{
|
|
75
|
+
maxNamed: number;
|
|
76
|
+
}>): string | undefined;
|
package/dist/markers.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, join, normalize, sep } from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* Which of a promise's scope markers no longer name anything in this checkout.
|
|
5
|
+
*
|
|
6
|
+
* A rename is the ordinary way a promise stops being retrievable. Somebody
|
|
7
|
+
* moves `src/import/preview.ts` to `src/ingest/preview.ts`, the code keeps
|
|
8
|
+
* working, the check keeps passing, and the promise about it quietly stops
|
|
9
|
+
* overlapping any path an agent will ever name. Nothing on the server can see
|
|
10
|
+
* that: Balladeer holds no token for the repository and never reads a file, so
|
|
11
|
+
* the only place the fact exists is the working tree in front of the runner.
|
|
12
|
+
*
|
|
13
|
+
* So it is observed here, locally, and reported as attention rather than
|
|
14
|
+
* repaired. A marker is a piece of approved meaning; moving one is a semantic
|
|
15
|
+
* revision a named person makes, and a command that rewrote markers because a
|
|
16
|
+
* file moved would be editing what somebody agreed to on the strength of a
|
|
17
|
+
* `git mv`.
|
|
18
|
+
*
|
|
19
|
+
* What leaves this machine is nothing. The observation is printed where the
|
|
20
|
+
* person ran the command.
|
|
21
|
+
*/
|
|
22
|
+
/** How many missing markers one run reports, and how many it will even look at. */
|
|
23
|
+
export const MARKER_OBSERVATION_LIMITS = {
|
|
24
|
+
/** Markers checked on disk in one run. Beyond this the run says it stopped counting. */
|
|
25
|
+
maxChecked: 500,
|
|
26
|
+
/** Promises named in the printed row. The rest are counted, not listed. */
|
|
27
|
+
maxNamed: 5,
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* A marker is a path when it names one.
|
|
31
|
+
*
|
|
32
|
+
* The grammar admits both `src/import/preview.ts` and `supplier-import-preview`.
|
|
33
|
+
* Only the first can be missing from a checkout; the second is a topic, and
|
|
34
|
+
* reporting it as a missing file would tell a person to go looking for
|
|
35
|
+
* something that was never a file.
|
|
36
|
+
*/
|
|
37
|
+
export function isPathShapedMarker(marker) {
|
|
38
|
+
const trimmed = marker.trim();
|
|
39
|
+
if (trimmed === "")
|
|
40
|
+
return false;
|
|
41
|
+
if (trimmed.includes("/"))
|
|
42
|
+
return true;
|
|
43
|
+
return /^[^/]+\.[A-Za-z0-9]{1,8}$/.test(trimmed);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* A marker refused rather than resolved.
|
|
47
|
+
*
|
|
48
|
+
* A marker that escapes the repository root, or that arrives absolute, is not a
|
|
49
|
+
* marker this checkout can answer for. It is skipped rather than reported
|
|
50
|
+
* missing: telling a person a path is gone because the command would not look
|
|
51
|
+
* outside their repository is a false alarm about their catalog.
|
|
52
|
+
*/
|
|
53
|
+
function resolveInside(root, marker) {
|
|
54
|
+
const cleaned = marker.trim().replaceAll("\\", "/");
|
|
55
|
+
if (cleaned === "" || isAbsolute(cleaned))
|
|
56
|
+
return undefined;
|
|
57
|
+
const full = normalize(join(root, cleaned));
|
|
58
|
+
const bounded = root.endsWith(sep) ? root : `${root}${sep}`;
|
|
59
|
+
return full === root || full.startsWith(bounded) ? full : undefined;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Look for each promise's path markers in this working tree.
|
|
63
|
+
*
|
|
64
|
+
* Bounded twice over: it stops after `maxChecked` markers whatever remains, and
|
|
65
|
+
* it says that it stopped. A repository with a thousand promises must not turn
|
|
66
|
+
* `status` into a filesystem walk, and a run that silently examined the first
|
|
67
|
+
* few hundred would report "nothing missing" about a catalog it never read.
|
|
68
|
+
*/
|
|
69
|
+
export function observeMissingMarkers(repositoryRoot, subjects, limits = MARKER_OBSERVATION_LIMITS, exists = existsSync) {
|
|
70
|
+
const root = normalize(repositoryRoot);
|
|
71
|
+
const observations = [];
|
|
72
|
+
let checked = 0;
|
|
73
|
+
let stoppedEarly = false;
|
|
74
|
+
for (const subject of subjects) {
|
|
75
|
+
const missing = [];
|
|
76
|
+
for (const marker of [...(subject.surfaces ?? []), ...(subject.labels ?? [])]) {
|
|
77
|
+
if (!isPathShapedMarker(marker))
|
|
78
|
+
continue;
|
|
79
|
+
if (checked >= limits.maxChecked) {
|
|
80
|
+
stoppedEarly = true;
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
checked += 1;
|
|
84
|
+
const resolved = resolveInside(root, marker);
|
|
85
|
+
if (resolved === undefined)
|
|
86
|
+
continue;
|
|
87
|
+
if (!exists(resolved))
|
|
88
|
+
missing.push(marker.trim());
|
|
89
|
+
}
|
|
90
|
+
if (missing.length > 0) {
|
|
91
|
+
observations.push({ promiseId: subject.promiseId, title: subject.title, missing });
|
|
92
|
+
}
|
|
93
|
+
if (stoppedEarly)
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
return { observations, checked, stoppedEarly };
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The attention row a person reads, or nothing at all when every marker is
|
|
100
|
+
* still there.
|
|
101
|
+
*
|
|
102
|
+
* It names what to do and who does it: a marker is approved meaning, so the
|
|
103
|
+
* remedy is a revision somebody agrees to, never an edit this command makes.
|
|
104
|
+
*/
|
|
105
|
+
export function staleMarkerRow(result, limits = MARKER_OBSERVATION_LIMITS) {
|
|
106
|
+
const { observations } = result;
|
|
107
|
+
if (observations.length === 0)
|
|
108
|
+
return undefined;
|
|
109
|
+
const named = observations.slice(0, Math.max(0, limits.maxNamed));
|
|
110
|
+
const lines = named.map((observation) => ` ${observation.promiseId}: ${observation.missing.join(", ")} (${observation.title})`);
|
|
111
|
+
const rest = observations.length - named.length;
|
|
112
|
+
const head = observations.length === 1
|
|
113
|
+
? " Attention: 1 promise names a path that is not in this checkout, so it will not be found by the paths a change touches."
|
|
114
|
+
: ` Attention: ${observations.length} promises name a path that is not in this checkout, so they will not be found by the paths a change touches.`;
|
|
115
|
+
const tail = [
|
|
116
|
+
...(rest > 0 ? [` and ${rest} more.`] : []),
|
|
117
|
+
...(result.stoppedEarly
|
|
118
|
+
? [
|
|
119
|
+
` This run looked at ${result.checked} markers and stopped there, so there may be more.`,
|
|
120
|
+
]
|
|
121
|
+
: []),
|
|
122
|
+
" A marker is part of what somebody agreed to. Propose a revision and let its owner decide; nothing here changed it.",
|
|
123
|
+
];
|
|
124
|
+
return [head, ...lines, ...tail].join("\n");
|
|
125
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
export declare const MCP_CONFIG_FILE = ".mcp.json";
|
|
2
|
+
export type StdioMcpEntry = Readonly<{
|
|
3
|
+
command: string;
|
|
4
|
+
args: readonly string[];
|
|
5
|
+
env?: Readonly<Record<string, string>>;
|
|
6
|
+
}>;
|
|
7
|
+
export type HttpMcpEntry = Readonly<{
|
|
8
|
+
url: string;
|
|
9
|
+
headers: Readonly<Record<string, string>>;
|
|
10
|
+
}>;
|
|
11
|
+
export type McpEntry = StdioMcpEntry | HttpMcpEntry;
|
|
12
|
+
export type MergeResult = Readonly<{
|
|
13
|
+
kind: "written";
|
|
14
|
+
changed: boolean;
|
|
15
|
+
}> | Readonly<{
|
|
16
|
+
kind: "refused";
|
|
17
|
+
reason: string;
|
|
18
|
+
block: string;
|
|
19
|
+
}>;
|
|
20
|
+
/**
|
|
21
|
+
* The entry this command writes: this command's own stdio forwarder, bound to
|
|
22
|
+
* one repository.
|
|
23
|
+
*
|
|
24
|
+
* The forwarder reads the bearer from the credential store, so the file carries
|
|
25
|
+
* no secret and the connection works the moment the step finishes. The remote
|
|
26
|
+
* form the web setup page hands out is the alternative, and it is deliberately
|
|
27
|
+
* not what this command writes: it reads the bearer from an environment
|
|
28
|
+
* variable, which means a person has to open a 600-mode file, copy a credential
|
|
29
|
+
* out of it by hand, and restart their agent host before the agent this step
|
|
30
|
+
* just called "connected" can do anything. That is a third human act in a
|
|
31
|
+
* product whose whole claim is that there are two. `isOurEntry` still
|
|
32
|
+
* recognises that form, so a team who set a host up in the browser and then ran
|
|
33
|
+
* this command is never told their own entry is foreign.
|
|
34
|
+
*
|
|
35
|
+
* Before publication the entry names this checkout's built entry point by
|
|
36
|
+
* absolute path, because there is no registry specifier that resolves to this
|
|
37
|
+
* program. After publication it names the release tag rather than a version, so
|
|
38
|
+
* the same block works on a machine that never had a checkout and so the host
|
|
39
|
+
* resolves the newest published command every time it starts the server. A
|
|
40
|
+
* version written here is a version the repository keeps running until somebody
|
|
41
|
+
* remembers to edit the file, which is how a customer ends up months behind.
|
|
42
|
+
*
|
|
43
|
+
* `npm_config_prefer_online` is the belt to that brace. npm already revalidates
|
|
44
|
+
* a dist-tag against the registry on every `npx` run, which was measured rather
|
|
45
|
+
* than assumed, but a machine whose npm configuration sets `prefer-offline` or
|
|
46
|
+
* `offline` would resolve the tag out of its own cache and never ask. Setting
|
|
47
|
+
* the variable in the entry's own environment overrides that for this one
|
|
48
|
+
* process, so the check happens on the machines where it would otherwise be
|
|
49
|
+
* skipped. With no network the cached copy still runs, and the server then tells
|
|
50
|
+
* it that it is behind.
|
|
51
|
+
*/
|
|
52
|
+
export declare function stdioEntry(repositoryId: string, publishedVersion: string | null | undefined): StdioMcpEntry;
|
|
53
|
+
/**
|
|
54
|
+
* Whether an entry already under our key is provably ours.
|
|
55
|
+
*
|
|
56
|
+
* Three shapes count, and nothing else. The HTTP form is ours when its URL
|
|
57
|
+
* points at the very control plane this session paired with; a URL anywhere else
|
|
58
|
+
* under our key is somebody's redirect, not our server. The published stdio form
|
|
59
|
+
* is ours when it runs `npx` on `balladeer@latest`, which is what this command
|
|
60
|
+
* writes now, or on an exact `balladeer@<major>.<minor>.<patch>` release, which
|
|
61
|
+
* is what it wrote before and what a repository set up earlier still carries;
|
|
62
|
+
* `balladeer@github:someone/thing`, `balladeer@https://...` and
|
|
63
|
+
* `balladeer@file:../x` are all valid npm specifiers that a looser check would
|
|
64
|
+
* have accepted, and each of them runs somebody else's program under our name.
|
|
65
|
+
* Recognising the old pinned form is what lets `setup --refresh` repair a stale
|
|
66
|
+
* install in place rather than refusing it as foreign. The checkout stdio form
|
|
67
|
+
* is ours when it runs `node` on
|
|
68
|
+
* an absolute path whose file is this command's entry point, with `mcp` as its
|
|
69
|
+
* subcommand: a relative path, a different file name, or a different interpreter
|
|
70
|
+
* is somebody else's program and this command will not silently replace it.
|
|
71
|
+
*
|
|
72
|
+
* Recognising the HTTP form matters as much as refusing the impostor: it is the
|
|
73
|
+
* entry the web setup page already hands people, so a team that onboarded
|
|
74
|
+
* through the browser and then runs this command must not be told their own
|
|
75
|
+
* Balladeer entry is foreign.
|
|
76
|
+
*/
|
|
77
|
+
export declare function isOurEntry(entry: unknown, controlPlane: string): boolean;
|
|
78
|
+
/**
|
|
79
|
+
* Atomic, and never through a path something could have pre-planted: the
|
|
80
|
+
* temporary file is opened with `wx` under a random name in the same directory,
|
|
81
|
+
* flushed, and renamed over the target.
|
|
82
|
+
*
|
|
83
|
+
* Exported because the Claude desktop configuration is written under the same
|
|
84
|
+
* rule and for a stronger reason: that file belongs to a running application,
|
|
85
|
+
* and a half-written one is a chat client that starts with no servers at all.
|
|
86
|
+
*/
|
|
87
|
+
export declare function writeJsonAtomically(path: string, contents: string): void;
|
|
88
|
+
/**
|
|
89
|
+
* Merges our entry into the repository's `.mcp.json`, or refuses.
|
|
90
|
+
*
|
|
91
|
+
* An unparseable file is never overwritten: it is somebody's configuration and
|
|
92
|
+
* this command cannot tell what is in it. A foreign entry under our key is never
|
|
93
|
+
* replaced either; the block to merge is printed instead, so a person decides.
|
|
94
|
+
*/
|
|
95
|
+
export declare function mergeMcpConfig(repositoryRoot: string, entry: McpEntry, controlPlane: string): MergeResult;
|
|
96
|
+
/**
|
|
97
|
+
* The repository id an entry already under our key names, or nothing.
|
|
98
|
+
*
|
|
99
|
+
* `setup --refresh` runs where a stale install is, and the machine it runs on
|
|
100
|
+
* may hold no credential for this repository: the person who paired it was a
|
|
101
|
+
* colleague, or the store was cleared. The entry itself still says which
|
|
102
|
+
* repository this checkout was connected for, and rewriting the entry around
|
|
103
|
+
* the id it already carries repairs the file without guessing.
|
|
104
|
+
*/
|
|
105
|
+
export declare function entryRepositoryId(entry: unknown): string | undefined;
|
|
106
|
+
/** The entry currently under our key in a parsed `.mcp.json`, or nothing. */
|
|
107
|
+
export declare function currentEntry(parsed: unknown): unknown;
|
|
108
|
+
/** The parsed `.mcp.json` of a repository, or nothing when there is none to read. */
|
|
109
|
+
export declare function readMcpConfig(repositoryRoot: string): unknown;
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { closeSync, fsyncSync, openSync, readFileSync, renameSync, unlinkSync, writeSync, } from "node:fs";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { basename, dirname, isAbsolute, join } from "node:path";
|
|
4
|
+
import { PUBLISHED_SPECIFIER, checkoutEntryPath } from "./release.js";
|
|
5
|
+
export const MCP_CONFIG_FILE = ".mcp.json";
|
|
6
|
+
/**
|
|
7
|
+
* The entry this command writes: this command's own stdio forwarder, bound to
|
|
8
|
+
* one repository.
|
|
9
|
+
*
|
|
10
|
+
* The forwarder reads the bearer from the credential store, so the file carries
|
|
11
|
+
* no secret and the connection works the moment the step finishes. The remote
|
|
12
|
+
* form the web setup page hands out is the alternative, and it is deliberately
|
|
13
|
+
* not what this command writes: it reads the bearer from an environment
|
|
14
|
+
* variable, which means a person has to open a 600-mode file, copy a credential
|
|
15
|
+
* out of it by hand, and restart their agent host before the agent this step
|
|
16
|
+
* just called "connected" can do anything. That is a third human act in a
|
|
17
|
+
* product whose whole claim is that there are two. `isOurEntry` still
|
|
18
|
+
* recognises that form, so a team who set a host up in the browser and then ran
|
|
19
|
+
* this command is never told their own entry is foreign.
|
|
20
|
+
*
|
|
21
|
+
* Before publication the entry names this checkout's built entry point by
|
|
22
|
+
* absolute path, because there is no registry specifier that resolves to this
|
|
23
|
+
* program. After publication it names the release tag rather than a version, so
|
|
24
|
+
* the same block works on a machine that never had a checkout and so the host
|
|
25
|
+
* resolves the newest published command every time it starts the server. A
|
|
26
|
+
* version written here is a version the repository keeps running until somebody
|
|
27
|
+
* remembers to edit the file, which is how a customer ends up months behind.
|
|
28
|
+
*
|
|
29
|
+
* `npm_config_prefer_online` is the belt to that brace. npm already revalidates
|
|
30
|
+
* a dist-tag against the registry on every `npx` run, which was measured rather
|
|
31
|
+
* than assumed, but a machine whose npm configuration sets `prefer-offline` or
|
|
32
|
+
* `offline` would resolve the tag out of its own cache and never ask. Setting
|
|
33
|
+
* the variable in the entry's own environment overrides that for this one
|
|
34
|
+
* process, so the check happens on the machines where it would otherwise be
|
|
35
|
+
* skipped. With no network the cached copy still runs, and the server then tells
|
|
36
|
+
* it that it is behind.
|
|
37
|
+
*/
|
|
38
|
+
export function stdioEntry(repositoryId, publishedVersion) {
|
|
39
|
+
return publishedVersion === null || publishedVersion === undefined
|
|
40
|
+
? { command: "node", args: [checkoutEntryPath(), "mcp", "--repository", repositoryId] }
|
|
41
|
+
: {
|
|
42
|
+
command: "npx",
|
|
43
|
+
args: ["-y", PUBLISHED_SPECIFIER, "mcp", "--repository", repositoryId],
|
|
44
|
+
env: { npm_config_prefer_online: "true" },
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
function sameOrigin(left, right) {
|
|
48
|
+
try {
|
|
49
|
+
return new URL(left).origin === new URL(right).origin;
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** `node`, however the host spells the path to it, and nothing else. */
|
|
56
|
+
function isNodeCommand(command) {
|
|
57
|
+
const name = basename(command);
|
|
58
|
+
return name === "node" || name === "node.exe";
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Whether an entry already under our key is provably ours.
|
|
62
|
+
*
|
|
63
|
+
* Three shapes count, and nothing else. The HTTP form is ours when its URL
|
|
64
|
+
* points at the very control plane this session paired with; a URL anywhere else
|
|
65
|
+
* under our key is somebody's redirect, not our server. The published stdio form
|
|
66
|
+
* is ours when it runs `npx` on `balladeer@latest`, which is what this command
|
|
67
|
+
* writes now, or on an exact `balladeer@<major>.<minor>.<patch>` release, which
|
|
68
|
+
* is what it wrote before and what a repository set up earlier still carries;
|
|
69
|
+
* `balladeer@github:someone/thing`, `balladeer@https://...` and
|
|
70
|
+
* `balladeer@file:../x` are all valid npm specifiers that a looser check would
|
|
71
|
+
* have accepted, and each of them runs somebody else's program under our name.
|
|
72
|
+
* Recognising the old pinned form is what lets `setup --refresh` repair a stale
|
|
73
|
+
* install in place rather than refusing it as foreign. The checkout stdio form
|
|
74
|
+
* is ours when it runs `node` on
|
|
75
|
+
* an absolute path whose file is this command's entry point, with `mcp` as its
|
|
76
|
+
* subcommand: a relative path, a different file name, or a different interpreter
|
|
77
|
+
* is somebody else's program and this command will not silently replace it.
|
|
78
|
+
*
|
|
79
|
+
* Recognising the HTTP form matters as much as refusing the impostor: it is the
|
|
80
|
+
* entry the web setup page already hands people, so a team that onboarded
|
|
81
|
+
* through the browser and then runs this command must not be told their own
|
|
82
|
+
* Balladeer entry is foreign.
|
|
83
|
+
*/
|
|
84
|
+
export function isOurEntry(entry, controlPlane) {
|
|
85
|
+
if (entry === null || typeof entry !== "object")
|
|
86
|
+
return false;
|
|
87
|
+
const record = entry;
|
|
88
|
+
if (typeof record.url === "string")
|
|
89
|
+
return sameOrigin(record.url, controlPlane);
|
|
90
|
+
if (typeof record.command !== "string" || !Array.isArray(record.args))
|
|
91
|
+
return false;
|
|
92
|
+
const args = record.args;
|
|
93
|
+
if (record.command === "npx") {
|
|
94
|
+
const specifier = args[1];
|
|
95
|
+
return (typeof specifier === "string" &&
|
|
96
|
+
/^balladeer@(?:latest|\d+\.\d+\.\d+)$/.test(specifier) &&
|
|
97
|
+
args[2] === "mcp");
|
|
98
|
+
}
|
|
99
|
+
if (isNodeCommand(record.command)) {
|
|
100
|
+
const script = args[0];
|
|
101
|
+
return (typeof script === "string" &&
|
|
102
|
+
isAbsolute(script) &&
|
|
103
|
+
basename(script) === "cli.js" &&
|
|
104
|
+
args[1] === "mcp");
|
|
105
|
+
}
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
function renderBlock(entry) {
|
|
109
|
+
return JSON.stringify({ mcpServers: { balladeer: entry } }, null, 2);
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Atomic, and never through a path something could have pre-planted: the
|
|
113
|
+
* temporary file is opened with `wx` under a random name in the same directory,
|
|
114
|
+
* flushed, and renamed over the target.
|
|
115
|
+
*
|
|
116
|
+
* Exported because the Claude desktop configuration is written under the same
|
|
117
|
+
* rule and for a stronger reason: that file belongs to a running application,
|
|
118
|
+
* and a half-written one is a chat client that starts with no servers at all.
|
|
119
|
+
*/
|
|
120
|
+
export function writeJsonAtomically(path, contents) {
|
|
121
|
+
const temporary = join(dirname(path), `.balladeer.${randomBytes(8).toString("hex")}.tmp`);
|
|
122
|
+
let descriptor;
|
|
123
|
+
try {
|
|
124
|
+
descriptor = openSync(temporary, "wx", 0o644);
|
|
125
|
+
writeSync(descriptor, contents);
|
|
126
|
+
fsyncSync(descriptor);
|
|
127
|
+
closeSync(descriptor);
|
|
128
|
+
descriptor = undefined;
|
|
129
|
+
renameSync(temporary, path);
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
if (descriptor !== undefined) {
|
|
133
|
+
try {
|
|
134
|
+
closeSync(descriptor);
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
// The unlink below is the cleanup that matters.
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
try {
|
|
141
|
+
unlinkSync(temporary);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
// The temporary file may never have been created.
|
|
145
|
+
}
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Merges our entry into the repository's `.mcp.json`, or refuses.
|
|
151
|
+
*
|
|
152
|
+
* An unparseable file is never overwritten: it is somebody's configuration and
|
|
153
|
+
* this command cannot tell what is in it. A foreign entry under our key is never
|
|
154
|
+
* replaced either; the block to merge is printed instead, so a person decides.
|
|
155
|
+
*/
|
|
156
|
+
export function mergeMcpConfig(repositoryRoot, entry, controlPlane) {
|
|
157
|
+
const path = join(repositoryRoot, MCP_CONFIG_FILE);
|
|
158
|
+
const block = renderBlock(entry);
|
|
159
|
+
let existing = "";
|
|
160
|
+
try {
|
|
161
|
+
existing = readFileSync(path, "utf8");
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
writeJsonAtomically(path, `${JSON.stringify({ mcpServers: { balladeer: entry } }, null, 2)}\n`);
|
|
165
|
+
return { kind: "written", changed: true };
|
|
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: `The repository's ${MCP_CONFIG_FILE} could not be parsed; I did not change it.`,
|
|
179
|
+
block,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
const servers = parsed.mcpServers !== null &&
|
|
183
|
+
typeof parsed.mcpServers === "object" &&
|
|
184
|
+
!Array.isArray(parsed.mcpServers)
|
|
185
|
+
? { ...parsed.mcpServers }
|
|
186
|
+
: {};
|
|
187
|
+
const current = servers.balladeer;
|
|
188
|
+
if (current !== undefined && !isOurEntry(current, controlPlane)) {
|
|
189
|
+
return {
|
|
190
|
+
kind: "refused",
|
|
191
|
+
reason: `${MCP_CONFIG_FILE} already has an entry named balladeer that is not this workspace's server; I did not change it.`,
|
|
192
|
+
block,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
servers.balladeer = entry;
|
|
196
|
+
const next = `${JSON.stringify({ ...parsed, mcpServers: servers }, null, 2)}\n`;
|
|
197
|
+
if (next === existing)
|
|
198
|
+
return { kind: "written", changed: false };
|
|
199
|
+
writeJsonAtomically(path, next);
|
|
200
|
+
return { kind: "written", changed: true };
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* The repository id an entry already under our key names, or nothing.
|
|
204
|
+
*
|
|
205
|
+
* `setup --refresh` runs where a stale install is, and the machine it runs on
|
|
206
|
+
* may hold no credential for this repository: the person who paired it was a
|
|
207
|
+
* colleague, or the store was cleared. The entry itself still says which
|
|
208
|
+
* repository this checkout was connected for, and rewriting the entry around
|
|
209
|
+
* the id it already carries repairs the file without guessing.
|
|
210
|
+
*/
|
|
211
|
+
export function entryRepositoryId(entry) {
|
|
212
|
+
const args = entry?.args;
|
|
213
|
+
if (!Array.isArray(args))
|
|
214
|
+
return undefined;
|
|
215
|
+
const at = args.indexOf("--repository");
|
|
216
|
+
const value = at === -1 ? undefined : args[at + 1];
|
|
217
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
218
|
+
}
|
|
219
|
+
/** The entry currently under our key in a parsed `.mcp.json`, or nothing. */
|
|
220
|
+
export function currentEntry(parsed) {
|
|
221
|
+
const servers = parsed?.mcpServers;
|
|
222
|
+
if (servers === null || typeof servers !== "object")
|
|
223
|
+
return undefined;
|
|
224
|
+
return servers.balladeer;
|
|
225
|
+
}
|
|
226
|
+
/** The parsed `.mcp.json` of a repository, or nothing when there is none to read. */
|
|
227
|
+
export function readMcpConfig(repositoryRoot) {
|
|
228
|
+
try {
|
|
229
|
+
return JSON.parse(readFileSync(join(repositoryRoot, MCP_CONFIG_FILE), "utf8"));
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
return undefined;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How to say "run this command again", in a way that actually runs it.
|
|
3
|
+
*
|
|
4
|
+
* The package name `balladeer` is taken on npm by an unrelated 0.0.x package
|
|
5
|
+
* that installs a different program, and until the publish happens the version
|
|
6
|
+
* this repository builds is not on the registry at all. So this command never
|
|
7
|
+
* writes a registry invocation as a literal. It asks the server, which reads
|
|
8
|
+
* the publication from one setting, and falls back to the checkout form
|
|
9
|
+
* whenever it has not been told otherwise. A sentence that names a command a
|
|
10
|
+
* person cannot run is worse than no sentence at all.
|
|
11
|
+
*
|
|
12
|
+
* What that setting no longer decides is the specifier. Nothing this product
|
|
13
|
+
* hands a customer pins a version any more: a pinned line is what left legacy
|
|
14
|
+
* customers running a months-old command, because the line in their config and
|
|
15
|
+
* the line on their screen both named the version that was newest the day they
|
|
16
|
+
* ran setup. The tag is resolved against the registry on every invocation, so a
|
|
17
|
+
* publish reaches a machine at its next session start.
|
|
18
|
+
*/
|
|
19
|
+
/** What a person runs from the root of a built checkout. */
|
|
20
|
+
export declare const CHECKOUT_COMMAND = "node packages/cli/dist/cli.js";
|
|
21
|
+
/** The specifier every published surface names, which is a tag and never a pin. */
|
|
22
|
+
export declare const PUBLISHED_SPECIFIER = "balladeer@latest";
|
|
23
|
+
/**
|
|
24
|
+
* How to invoke this command, with no subcommand attached.
|
|
25
|
+
*
|
|
26
|
+
* The parameter still says whether the package is published, because until it
|
|
27
|
+
* is, the tag resolves to somebody else's package. It no longer says which
|
|
28
|
+
* version to name.
|
|
29
|
+
*/
|
|
30
|
+
export declare function commandPrefix(publishedVersion: string | null | undefined): string;
|
|
31
|
+
/** One runnable line. */
|
|
32
|
+
export declare function commandLine(publishedVersion: string | null | undefined, subcommand: string): string;
|
|
33
|
+
/**
|
|
34
|
+
* The absolute path an agent host runs to reach this checkout's built command.
|
|
35
|
+
*
|
|
36
|
+
* Resolved from this module rather than from the working directory, because the
|
|
37
|
+
* `.mcp.json` entry is executed later, by another program, from a directory
|
|
38
|
+
* this command never sees. It is the same path whether this file is running
|
|
39
|
+
* from `src` under a loader or from `dist` after a build, because both sit one
|
|
40
|
+
* directory below the package root.
|
|
41
|
+
*/
|
|
42
|
+
export declare function checkoutEntryPath(): string;
|
|
43
|
+
/**
|
|
44
|
+
* Whether this copy is running out of a registry install rather than a
|
|
45
|
+
* checkout.
|
|
46
|
+
*
|
|
47
|
+
* `setup --refresh` has to write the same invocation the first setup wrote, and
|
|
48
|
+
* it has to do it without a server to ask: a repair runs where a stale install
|
|
49
|
+
* is, which may be a laptop that cannot reach the control plane at that moment.
|
|
50
|
+
* The honest local answer is how this copy was itself obtained. npm and npx
|
|
51
|
+
* both unpack a registry install under a `node_modules` directory, and a
|
|
52
|
+
* checkout of this repository never has one on the path to its own entry point,
|
|
53
|
+
* so the presence of that segment is what separates the two.
|
|
54
|
+
*/
|
|
55
|
+
export declare function runningFromRegistryInstall(): boolean;
|
package/dist/release.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { dirname, join, resolve } from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
/**
|
|
4
|
+
* How to say "run this command again", in a way that actually runs it.
|
|
5
|
+
*
|
|
6
|
+
* The package name `balladeer` is taken on npm by an unrelated 0.0.x package
|
|
7
|
+
* that installs a different program, and until the publish happens the version
|
|
8
|
+
* this repository builds is not on the registry at all. So this command never
|
|
9
|
+
* writes a registry invocation as a literal. It asks the server, which reads
|
|
10
|
+
* the publication from one setting, and falls back to the checkout form
|
|
11
|
+
* whenever it has not been told otherwise. A sentence that names a command a
|
|
12
|
+
* person cannot run is worse than no sentence at all.
|
|
13
|
+
*
|
|
14
|
+
* What that setting no longer decides is the specifier. Nothing this product
|
|
15
|
+
* hands a customer pins a version any more: a pinned line is what left legacy
|
|
16
|
+
* customers running a months-old command, because the line in their config and
|
|
17
|
+
* the line on their screen both named the version that was newest the day they
|
|
18
|
+
* ran setup. The tag is resolved against the registry on every invocation, so a
|
|
19
|
+
* publish reaches a machine at its next session start.
|
|
20
|
+
*/
|
|
21
|
+
/** What a person runs from the root of a built checkout. */
|
|
22
|
+
export const CHECKOUT_COMMAND = "node packages/cli/dist/cli.js";
|
|
23
|
+
/** The specifier every published surface names, which is a tag and never a pin. */
|
|
24
|
+
export const PUBLISHED_SPECIFIER = "balladeer@latest";
|
|
25
|
+
/**
|
|
26
|
+
* How to invoke this command, with no subcommand attached.
|
|
27
|
+
*
|
|
28
|
+
* The parameter still says whether the package is published, because until it
|
|
29
|
+
* is, the tag resolves to somebody else's package. It no longer says which
|
|
30
|
+
* version to name.
|
|
31
|
+
*/
|
|
32
|
+
export function commandPrefix(publishedVersion) {
|
|
33
|
+
return publishedVersion === null || publishedVersion === undefined
|
|
34
|
+
? CHECKOUT_COMMAND
|
|
35
|
+
: `npx -y ${PUBLISHED_SPECIFIER}`;
|
|
36
|
+
}
|
|
37
|
+
/** One runnable line. */
|
|
38
|
+
export function commandLine(publishedVersion, subcommand) {
|
|
39
|
+
return `${commandPrefix(publishedVersion)} ${subcommand}`;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The absolute path an agent host runs to reach this checkout's built command.
|
|
43
|
+
*
|
|
44
|
+
* Resolved from this module rather than from the working directory, because the
|
|
45
|
+
* `.mcp.json` entry is executed later, by another program, from a directory
|
|
46
|
+
* this command never sees. It is the same path whether this file is running
|
|
47
|
+
* from `src` under a loader or from `dist` after a build, because both sit one
|
|
48
|
+
* directory below the package root.
|
|
49
|
+
*/
|
|
50
|
+
export function checkoutEntryPath() {
|
|
51
|
+
return join(resolve(dirname(fileURLToPath(import.meta.url)), ".."), "dist", "cli.js");
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Whether this copy is running out of a registry install rather than a
|
|
55
|
+
* checkout.
|
|
56
|
+
*
|
|
57
|
+
* `setup --refresh` has to write the same invocation the first setup wrote, and
|
|
58
|
+
* it has to do it without a server to ask: a repair runs where a stale install
|
|
59
|
+
* is, which may be a laptop that cannot reach the control plane at that moment.
|
|
60
|
+
* The honest local answer is how this copy was itself obtained. npm and npx
|
|
61
|
+
* both unpack a registry install under a `node_modules` directory, and a
|
|
62
|
+
* checkout of this repository never has one on the path to its own entry point,
|
|
63
|
+
* so the presence of that segment is what separates the two.
|
|
64
|
+
*/
|
|
65
|
+
export function runningFromRegistryInstall() {
|
|
66
|
+
return checkoutEntryPath().split(/[\\/]/).includes("node_modules");
|
|
67
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two display hints, and nothing else. They exist so a person can recognise
|
|
3
|
+
* their own pairing on the approval page, and the server refuses anything that
|
|
4
|
+
* is not the shape of an owner/name or a hostname, so a hint can never be
|
|
5
|
+
* written to read like Balladeer's own assertion.
|
|
6
|
+
*/
|
|
7
|
+
export declare function repositoryHint(cwd?: string): string;
|
|
8
|
+
export declare function hostHint(): string;
|