balladeer 0.0.4 → 1.0.0
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 +154 -68
- package/dist/agent.d.ts +126 -0
- package/dist/agent.js +209 -0
- package/dist/cli.d.ts +34 -0
- package/dist/cli.js +392 -0
- package/dist/client.d.ts +44 -0
- package/dist/client.js +114 -0
- package/dist/commands/affected.d.ts +22 -0
- package/dist/commands/affected.js +122 -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 +395 -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 +197 -0
- package/dist/commands/mcp.d.ts +65 -0
- package/dist/commands/mcp.js +202 -0
- package/dist/commands/propose.d.ts +59 -0
- package/dist/commands/propose.js +262 -0
- package/dist/commands/repositories.d.ts +18 -0
- package/dist/commands/repositories.js +185 -0
- package/dist/commands/setup.d.ts +75 -0
- package/dist/commands/setup.js +1471 -0
- package/dist/commands/status.d.ts +35 -0
- package/dist/commands/status.js +482 -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 +79 -0
- package/dist/conventions.d.ts +69 -0
- package/dist/conventions.js +175 -0
- package/dist/copy.d.ts +148 -0
- package/dist/copy.js +459 -0
- package/dist/currency.d.ts +31 -0
- package/dist/currency.js +72 -0
- package/dist/gh.d.ts +80 -0
- package/dist/gh.js +188 -0
- package/dist/git.d.ts +76 -0
- package/dist/git.js +203 -0
- package/dist/markers.d.ts +76 -0
- package/dist/markers.js +125 -0
- package/dist/mcp-config.d.ts +99 -0
- package/dist/mcp-config.js +230 -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/store.d.ts +98 -0
- package/dist/store.js +225 -0
- package/dist/touch-map.d.ts +241 -0
- package/dist/touch-map.js +487 -0
- package/dist/wire.d.ts +588 -0
- package/dist/wire.js +20 -0
- package/package.json +19 -10
- package/bin/balladeer.js +0 -136
|
@@ -0,0 +1,99 @@
|
|
|
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
|
+
* Merges our entry into the repository's `.mcp.json`, or refuses.
|
|
80
|
+
*
|
|
81
|
+
* An unparseable file is never overwritten: it is somebody's configuration and
|
|
82
|
+
* this command cannot tell what is in it. A foreign entry under our key is never
|
|
83
|
+
* replaced either; the block to merge is printed instead, so a person decides.
|
|
84
|
+
*/
|
|
85
|
+
export declare function mergeMcpConfig(repositoryRoot: string, entry: McpEntry, controlPlane: string): MergeResult;
|
|
86
|
+
/**
|
|
87
|
+
* The repository id an entry already under our key names, or nothing.
|
|
88
|
+
*
|
|
89
|
+
* `setup --refresh` runs where a stale install is, and the machine it runs on
|
|
90
|
+
* may hold no credential for this repository: the person who paired it was a
|
|
91
|
+
* colleague, or the store was cleared. The entry itself still says which
|
|
92
|
+
* repository this checkout was connected for, and rewriting the entry around
|
|
93
|
+
* the id it already carries repairs the file without guessing.
|
|
94
|
+
*/
|
|
95
|
+
export declare function entryRepositoryId(entry: unknown): string | undefined;
|
|
96
|
+
/** The entry currently under our key in a parsed `.mcp.json`, or nothing. */
|
|
97
|
+
export declare function currentEntry(parsed: unknown): unknown;
|
|
98
|
+
/** The parsed `.mcp.json` of a repository, or nothing when there is none to read. */
|
|
99
|
+
export declare function readMcpConfig(repositoryRoot: string): unknown;
|
|
@@ -0,0 +1,230 @@
|
|
|
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
|
+
function writeAtomically(path, contents) {
|
|
117
|
+
const temporary = join(dirname(path), `.mcp.${randomBytes(8).toString("hex")}.tmp`);
|
|
118
|
+
let descriptor;
|
|
119
|
+
try {
|
|
120
|
+
descriptor = openSync(temporary, "wx", 0o644);
|
|
121
|
+
writeSync(descriptor, contents);
|
|
122
|
+
fsyncSync(descriptor);
|
|
123
|
+
closeSync(descriptor);
|
|
124
|
+
descriptor = undefined;
|
|
125
|
+
renameSync(temporary, path);
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
if (descriptor !== undefined) {
|
|
129
|
+
try {
|
|
130
|
+
closeSync(descriptor);
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
// The unlink below is the cleanup that matters.
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
unlinkSync(temporary);
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
// The temporary file may never have been created.
|
|
141
|
+
}
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Merges our entry into the repository's `.mcp.json`, or refuses.
|
|
147
|
+
*
|
|
148
|
+
* An unparseable file is never overwritten: it is somebody's configuration and
|
|
149
|
+
* this command cannot tell what is in it. A foreign entry under our key is never
|
|
150
|
+
* replaced either; the block to merge is printed instead, so a person decides.
|
|
151
|
+
*/
|
|
152
|
+
export function mergeMcpConfig(repositoryRoot, entry, controlPlane) {
|
|
153
|
+
const path = join(repositoryRoot, MCP_CONFIG_FILE);
|
|
154
|
+
const block = renderBlock(entry);
|
|
155
|
+
let existing = "";
|
|
156
|
+
try {
|
|
157
|
+
existing = readFileSync(path, "utf8");
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
writeAtomically(path, `${JSON.stringify({ mcpServers: { balladeer: entry } }, null, 2)}\n`);
|
|
161
|
+
return { kind: "written", changed: true };
|
|
162
|
+
}
|
|
163
|
+
let parsed;
|
|
164
|
+
try {
|
|
165
|
+
const value = JSON.parse(existing);
|
|
166
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
167
|
+
throw new Error("not an object");
|
|
168
|
+
}
|
|
169
|
+
parsed = value;
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return {
|
|
173
|
+
kind: "refused",
|
|
174
|
+
reason: `The repository's ${MCP_CONFIG_FILE} could not be parsed; I did not change it.`,
|
|
175
|
+
block,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
const servers = parsed.mcpServers !== null &&
|
|
179
|
+
typeof parsed.mcpServers === "object" &&
|
|
180
|
+
!Array.isArray(parsed.mcpServers)
|
|
181
|
+
? { ...parsed.mcpServers }
|
|
182
|
+
: {};
|
|
183
|
+
const current = servers.balladeer;
|
|
184
|
+
if (current !== undefined && !isOurEntry(current, controlPlane)) {
|
|
185
|
+
return {
|
|
186
|
+
kind: "refused",
|
|
187
|
+
reason: `${MCP_CONFIG_FILE} already has an entry named balladeer that is not this workspace's server; I did not change it.`,
|
|
188
|
+
block,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
servers.balladeer = entry;
|
|
192
|
+
const next = `${JSON.stringify({ ...parsed, mcpServers: servers }, null, 2)}\n`;
|
|
193
|
+
if (next === existing)
|
|
194
|
+
return { kind: "written", changed: false };
|
|
195
|
+
writeAtomically(path, next);
|
|
196
|
+
return { kind: "written", changed: true };
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* The repository id an entry already under our key names, or nothing.
|
|
200
|
+
*
|
|
201
|
+
* `setup --refresh` runs where a stale install is, and the machine it runs on
|
|
202
|
+
* may hold no credential for this repository: the person who paired it was a
|
|
203
|
+
* colleague, or the store was cleared. The entry itself still says which
|
|
204
|
+
* repository this checkout was connected for, and rewriting the entry around
|
|
205
|
+
* the id it already carries repairs the file without guessing.
|
|
206
|
+
*/
|
|
207
|
+
export function entryRepositoryId(entry) {
|
|
208
|
+
const args = entry?.args;
|
|
209
|
+
if (!Array.isArray(args))
|
|
210
|
+
return undefined;
|
|
211
|
+
const at = args.indexOf("--repository");
|
|
212
|
+
const value = at === -1 ? undefined : args[at + 1];
|
|
213
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
214
|
+
}
|
|
215
|
+
/** The entry currently under our key in a parsed `.mcp.json`, or nothing. */
|
|
216
|
+
export function currentEntry(parsed) {
|
|
217
|
+
const servers = parsed?.mcpServers;
|
|
218
|
+
if (servers === null || typeof servers !== "object")
|
|
219
|
+
return undefined;
|
|
220
|
+
return servers.balladeer;
|
|
221
|
+
}
|
|
222
|
+
/** The parsed `.mcp.json` of a repository, or nothing when there is none to read. */
|
|
223
|
+
export function readMcpConfig(repositoryRoot) {
|
|
224
|
+
try {
|
|
225
|
+
return JSON.parse(readFileSync(join(repositoryRoot, MCP_CONFIG_FILE), "utf8"));
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
return undefined;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
@@ -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;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { hostname } from "node:os";
|
|
3
|
+
const REPOSITORY_HINT_PATTERN = /^[A-Za-z0-9._-]{1,39}\/[A-Za-z0-9._-]{1,100}$/;
|
|
4
|
+
const HOST_HINT_PATTERN = /^[A-Za-z0-9.-]{1,63}$/;
|
|
5
|
+
/**
|
|
6
|
+
* The two display hints, and nothing else. They exist so a person can recognise
|
|
7
|
+
* their own pairing on the approval page, and the server refuses anything that
|
|
8
|
+
* is not the shape of an owner/name or a hostname, so a hint can never be
|
|
9
|
+
* written to read like Balladeer's own assertion.
|
|
10
|
+
*/
|
|
11
|
+
export function repositoryHint(cwd = process.cwd()) {
|
|
12
|
+
let remote;
|
|
13
|
+
try {
|
|
14
|
+
remote = execFileSync("git", ["-C", cwd, "remote", "get-url", "origin"], {
|
|
15
|
+
encoding: "utf8",
|
|
16
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
17
|
+
}).trim();
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return "unknown/unknown";
|
|
21
|
+
}
|
|
22
|
+
const match = /github\.com[:/]+([A-Za-z0-9._-]{1,39})\/([A-Za-z0-9._-]{1,100}?)(?:\.git)?$/.exec(remote);
|
|
23
|
+
if (!match)
|
|
24
|
+
return "unknown/unknown";
|
|
25
|
+
const hint = `${match[1]}/${match[2]}`;
|
|
26
|
+
return REPOSITORY_HINT_PATTERN.test(hint) ? hint : "unknown/unknown";
|
|
27
|
+
}
|
|
28
|
+
export function hostHint() {
|
|
29
|
+
const raw = hostname().split(".")[0] ?? "";
|
|
30
|
+
const cleaned = raw.replace(/[^A-Za-z0-9.-]/g, "").slice(0, 63);
|
|
31
|
+
return HOST_HINT_PATTERN.test(cleaned) ? cleaned : "unknown";
|
|
32
|
+
}
|
package/dist/seals.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a promise's sealed files live, and where the package that seals them
|
|
3
|
+
* lives. Both are fixed by the runner: `materials[].path` is refused unless it
|
|
4
|
+
* starts with `.continuity/promises/<promise-id>/`, and the sealed package is
|
|
5
|
+
* written to `.continuity/packages/<promise-id>.json`.
|
|
6
|
+
*/
|
|
7
|
+
export declare const PROMISE_TREE_ROOT = ".continuity/promises";
|
|
8
|
+
export declare const PACKAGE_DIRECTORY = ".continuity/packages";
|
|
9
|
+
/** How many sealed directories one report names before it says how many more. */
|
|
10
|
+
export declare const SEALED_PATHS_SHOWN = 10;
|
|
11
|
+
/**
|
|
12
|
+
* One promise's seal, as this checkout carries it.
|
|
13
|
+
*
|
|
14
|
+
* Everything here is read out of the customer's own sealed package file. None
|
|
15
|
+
* of it is a judgement about whether the seal still holds: that verdict comes
|
|
16
|
+
* from the pinned runner, which is the only thing entitled to give it.
|
|
17
|
+
*/
|
|
18
|
+
export type SealedPromise = Readonly<{
|
|
19
|
+
promiseId: string;
|
|
20
|
+
/** The directory whose every file the package digest-locks, with a trailing slash. */
|
|
21
|
+
sealedPath: string;
|
|
22
|
+
/** The sealed package file, repository-relative. */
|
|
23
|
+
packageFile: string;
|
|
24
|
+
title?: string;
|
|
25
|
+
owner?: string;
|
|
26
|
+
promiseUrl?: string;
|
|
27
|
+
}>;
|
|
28
|
+
/**
|
|
29
|
+
* Every promise this checkout has a sealed package for, in id order.
|
|
30
|
+
*
|
|
31
|
+
* It reads the package files and nothing else, so it works with no network, no
|
|
32
|
+
* credential, and no runner checkout. A file that is not a readable sealed
|
|
33
|
+
* package is skipped rather than reported: a half-written draft in that
|
|
34
|
+
* directory is not a seal, and refusing to say anything about the other
|
|
35
|
+
* promises because of it would be worse than leaving it out.
|
|
36
|
+
*/
|
|
37
|
+
export declare function sealedPromises(repositoryRoot: string): SealedPromise[];
|
|
38
|
+
/**
|
|
39
|
+
* Which promise a changed path belongs to, or nothing when the path is
|
|
40
|
+
* ordinary source.
|
|
41
|
+
*
|
|
42
|
+
* Two paths belong to a promise: a file inside its sealed directory, whose
|
|
43
|
+
* bytes the package locks, and the sealed package itself, which is what a
|
|
44
|
+
* reseal rewrites. A change to either one is the change worth checking; every
|
|
45
|
+
* other path in a repository is somebody's ordinary work and this says so by
|
|
46
|
+
* answering nothing.
|
|
47
|
+
*/
|
|
48
|
+
export declare function promiseForPath(path: string): string | undefined;
|
package/dist/seals.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* Where a promise's sealed files live, and where the package that seals them
|
|
5
|
+
* lives. Both are fixed by the runner: `materials[].path` is refused unless it
|
|
6
|
+
* starts with `.continuity/promises/<promise-id>/`, and the sealed package is
|
|
7
|
+
* written to `.continuity/packages/<promise-id>.json`.
|
|
8
|
+
*/
|
|
9
|
+
export const PROMISE_TREE_ROOT = ".continuity/promises";
|
|
10
|
+
export const PACKAGE_DIRECTORY = ".continuity/packages";
|
|
11
|
+
/** How many sealed directories one report names before it says how many more. */
|
|
12
|
+
export const SEALED_PATHS_SHOWN = 10;
|
|
13
|
+
/** A promise id as the runner spells it. Anything else is not one of ours. */
|
|
14
|
+
const PROMISE_ID = /^prom_[a-z0-9]{8,64}$/;
|
|
15
|
+
function readableString(value, limit) {
|
|
16
|
+
if (typeof value !== "string")
|
|
17
|
+
return undefined;
|
|
18
|
+
const trimmed = value.trim();
|
|
19
|
+
if (trimmed === "")
|
|
20
|
+
return undefined;
|
|
21
|
+
// Bounded, and stripped of anything that could reflow a terminal: this text
|
|
22
|
+
// came out of a file in the customer's repository and is about to be printed.
|
|
23
|
+
return trimmed.replace(/[\u0000-\u001f\u007f]/g, " ").slice(0, limit);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Every promise this checkout has a sealed package for, in id order.
|
|
27
|
+
*
|
|
28
|
+
* It reads the package files and nothing else, so it works with no network, no
|
|
29
|
+
* credential, and no runner checkout. A file that is not a readable sealed
|
|
30
|
+
* package is skipped rather than reported: a half-written draft in that
|
|
31
|
+
* directory is not a seal, and refusing to say anything about the other
|
|
32
|
+
* promises because of it would be worse than leaving it out.
|
|
33
|
+
*/
|
|
34
|
+
export function sealedPromises(repositoryRoot) {
|
|
35
|
+
let entries;
|
|
36
|
+
try {
|
|
37
|
+
entries = readdirSync(join(repositoryRoot, PACKAGE_DIRECTORY));
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
const found = [];
|
|
43
|
+
for (const entry of entries.sort()) {
|
|
44
|
+
if (!entry.endsWith(".json"))
|
|
45
|
+
continue;
|
|
46
|
+
let parsed;
|
|
47
|
+
try {
|
|
48
|
+
parsed = JSON.parse(readFileSync(join(repositoryRoot, PACKAGE_DIRECTORY, entry), "utf8"));
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const promise = parsed.promise;
|
|
54
|
+
const attribution = parsed.attribution;
|
|
55
|
+
const promiseId = typeof promise?.id === "string" ? promise.id : undefined;
|
|
56
|
+
if (promiseId === undefined || !PROMISE_ID.test(promiseId))
|
|
57
|
+
continue;
|
|
58
|
+
if (!Array.isArray(parsed.materials) || parsed.materials.length === 0)
|
|
59
|
+
continue;
|
|
60
|
+
found.push({
|
|
61
|
+
promiseId,
|
|
62
|
+
sealedPath: `${PROMISE_TREE_ROOT}/${promiseId}/`,
|
|
63
|
+
packageFile: `${PACKAGE_DIRECTORY}/${entry}`,
|
|
64
|
+
...(readableString(promise?.title, 160) === undefined
|
|
65
|
+
? {}
|
|
66
|
+
: { title: readableString(promise?.title, 160) }),
|
|
67
|
+
...(readableString(attribution?.owner, 160) === undefined
|
|
68
|
+
? {}
|
|
69
|
+
: { owner: readableString(attribution?.owner, 160) }),
|
|
70
|
+
...(readableString(attribution?.promiseUrl, 300) === undefined
|
|
71
|
+
? {}
|
|
72
|
+
: { promiseUrl: readableString(attribution?.promiseUrl, 300) }),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
return found.sort((left, right) => left.promiseId.localeCompare(right.promiseId));
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Which promise a changed path belongs to, or nothing when the path is
|
|
79
|
+
* ordinary source.
|
|
80
|
+
*
|
|
81
|
+
* Two paths belong to a promise: a file inside its sealed directory, whose
|
|
82
|
+
* bytes the package locks, and the sealed package itself, which is what a
|
|
83
|
+
* reseal rewrites. A change to either one is the change worth checking; every
|
|
84
|
+
* other path in a repository is somebody's ordinary work and this says so by
|
|
85
|
+
* answering nothing.
|
|
86
|
+
*/
|
|
87
|
+
export function promiseForPath(path) {
|
|
88
|
+
const normalized = path.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
89
|
+
const treePrefix = `${PROMISE_TREE_ROOT}/`;
|
|
90
|
+
const packagePrefix = `${PACKAGE_DIRECTORY}/`;
|
|
91
|
+
if (normalized.startsWith(treePrefix)) {
|
|
92
|
+
const rest = normalized.slice(treePrefix.length);
|
|
93
|
+
const slash = rest.indexOf("/");
|
|
94
|
+
// A file directly under the tree root belongs to no promise: the id is a
|
|
95
|
+
// directory, and something dropped beside those directories is not sealed.
|
|
96
|
+
if (slash > 0 && rest.length > slash + 1) {
|
|
97
|
+
const id = rest.slice(0, slash);
|
|
98
|
+
if (PROMISE_ID.test(id))
|
|
99
|
+
return id;
|
|
100
|
+
}
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
if (normalized.startsWith(packagePrefix)) {
|
|
104
|
+
const rest = normalized.slice(packagePrefix.length);
|
|
105
|
+
if (rest.endsWith(".json") && !rest.slice(0, -5).includes("/")) {
|
|
106
|
+
const id = rest.slice(0, -5);
|
|
107
|
+
if (PROMISE_ID.test(id))
|
|
108
|
+
return id;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|