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.
Files changed (60) hide show
  1. package/LICENSE +200 -5
  2. package/README.md +154 -68
  3. package/dist/agent.d.ts +126 -0
  4. package/dist/agent.js +209 -0
  5. package/dist/cli.d.ts +34 -0
  6. package/dist/cli.js +392 -0
  7. package/dist/client.d.ts +44 -0
  8. package/dist/client.js +114 -0
  9. package/dist/commands/affected.d.ts +22 -0
  10. package/dist/commands/affected.js +122 -0
  11. package/dist/commands/check-seals.d.ts +37 -0
  12. package/dist/commands/check-seals.js +289 -0
  13. package/dist/commands/discover.d.ts +68 -0
  14. package/dist/commands/discover.js +395 -0
  15. package/dist/commands/explain.d.ts +35 -0
  16. package/dist/commands/explain.js +90 -0
  17. package/dist/commands/invite.d.ts +24 -0
  18. package/dist/commands/invite.js +197 -0
  19. package/dist/commands/mcp.d.ts +65 -0
  20. package/dist/commands/mcp.js +202 -0
  21. package/dist/commands/propose.d.ts +59 -0
  22. package/dist/commands/propose.js +262 -0
  23. package/dist/commands/repositories.d.ts +18 -0
  24. package/dist/commands/repositories.js +185 -0
  25. package/dist/commands/setup.d.ts +75 -0
  26. package/dist/commands/setup.js +1471 -0
  27. package/dist/commands/status.d.ts +35 -0
  28. package/dist/commands/status.js +482 -0
  29. package/dist/commands/touch-map.d.ts +42 -0
  30. package/dist/commands/touch-map.js +251 -0
  31. package/dist/commands/whoami.d.ts +8 -0
  32. package/dist/commands/whoami.js +79 -0
  33. package/dist/conventions.d.ts +69 -0
  34. package/dist/conventions.js +175 -0
  35. package/dist/copy.d.ts +148 -0
  36. package/dist/copy.js +459 -0
  37. package/dist/currency.d.ts +31 -0
  38. package/dist/currency.js +72 -0
  39. package/dist/gh.d.ts +80 -0
  40. package/dist/gh.js +188 -0
  41. package/dist/git.d.ts +76 -0
  42. package/dist/git.js +203 -0
  43. package/dist/markers.d.ts +76 -0
  44. package/dist/markers.js +125 -0
  45. package/dist/mcp-config.d.ts +99 -0
  46. package/dist/mcp-config.js +230 -0
  47. package/dist/release.d.ts +55 -0
  48. package/dist/release.js +67 -0
  49. package/dist/repository.d.ts +8 -0
  50. package/dist/repository.js +32 -0
  51. package/dist/seals.d.ts +48 -0
  52. package/dist/seals.js +112 -0
  53. package/dist/store.d.ts +98 -0
  54. package/dist/store.js +225 -0
  55. package/dist/touch-map.d.ts +241 -0
  56. package/dist/touch-map.js +487 -0
  57. package/dist/wire.d.ts +588 -0
  58. package/dist/wire.js +20 -0
  59. package/package.json +19 -10
  60. 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;
@@ -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
+ }
@@ -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
+ }