@stigmer/cli 3.16.0 → 3.16.1-dev.20260917224426
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/commands/agent-exec-flags.d.ts +2 -5
- package/commands/agent-exec-flags.d.ts.map +1 -1
- package/commands/agent-exec-flags.js +6 -13
- package/commands/agent-exec-flags.js.map +1 -1
- package/commands/connect.js +1 -1
- package/commands/connect.js.map +1 -1
- package/commands/install.d.ts +3 -0
- package/commands/install.d.ts.map +1 -0
- package/commands/install.js +68 -0
- package/commands/install.js.map +1 -0
- package/commands/marketplace.d.ts +3 -0
- package/commands/marketplace.d.ts.map +1 -0
- package/commands/marketplace.js +94 -0
- package/commands/marketplace.js.map +1 -0
- package/commands/push.js +2 -7
- package/commands/push.js.map +1 -1
- package/commands/run.js +0 -3
- package/commands/run.js.map +1 -1
- package/config/config.d.ts +19 -0
- package/config/config.d.ts.map +1 -1
- package/config/config.js +15 -0
- package/config/config.js.map +1 -1
- package/config/index.d.ts +1 -1
- package/config/index.d.ts.map +1 -1
- package/config/index.js.map +1 -1
- package/local/bootstrap.d.ts +21 -0
- package/local/bootstrap.d.ts.map +1 -0
- package/local/bootstrap.js +88 -0
- package/local/bootstrap.js.map +1 -0
- package/local/daemon/launch.d.ts +2 -2
- package/local/daemon/launch.d.ts.map +1 -1
- package/local/daemon/launch.js +4 -34
- package/local/daemon/launch.js.map +1 -1
- package/local/daemon/process.d.ts +1 -1
- package/local/plugins/defaults.d.ts +35 -0
- package/local/plugins/defaults.d.ts.map +1 -0
- package/local/plugins/defaults.js +83 -0
- package/local/plugins/defaults.js.map +1 -0
- package/local/seedpack/content.d.ts +2 -1
- package/local/seedpack/content.d.ts.map +1 -1
- package/local/seedpack/content.js +5 -34
- package/local/seedpack/content.js.map +1 -1
- package/marketplace/config.d.ts +59 -0
- package/marketplace/config.d.ts.map +1 -0
- package/marketplace/config.js +162 -0
- package/marketplace/config.js.map +1 -0
- package/marketplace/github.d.ts +31 -0
- package/marketplace/github.d.ts.map +1 -0
- package/marketplace/github.js +203 -0
- package/marketplace/github.js.map +1 -0
- package/marketplace/index.d.ts +8 -0
- package/marketplace/index.d.ts.map +1 -0
- package/marketplace/index.js +12 -0
- package/marketplace/index.js.map +1 -0
- package/marketplace/install.d.ts +28 -0
- package/marketplace/install.d.ts.map +1 -0
- package/marketplace/install.js +109 -0
- package/marketplace/install.js.map +1 -0
- package/marketplace/materialize.d.ts +19 -0
- package/marketplace/materialize.d.ts.map +1 -0
- package/marketplace/materialize.js +53 -0
- package/marketplace/materialize.js.map +1 -0
- package/marketplace/official.d.ts +28 -0
- package/marketplace/official.d.ts.map +1 -0
- package/marketplace/official.js +86 -0
- package/marketplace/official.js.map +1 -0
- package/marketplace/read.d.ts +17 -0
- package/marketplace/read.d.ts.map +1 -0
- package/marketplace/read.js +44 -0
- package/marketplace/read.js.map +1 -0
- package/marketplace/ref.d.ts +13 -0
- package/marketplace/ref.d.ts.map +1 -0
- package/marketplace/ref.js +54 -0
- package/marketplace/ref.js.map +1 -0
- package/marketplace/render.d.ts +22 -0
- package/marketplace/render.d.ts.map +1 -0
- package/marketplace/render.js +118 -0
- package/marketplace/render.js.map +1 -0
- package/output/ndjson.js +1 -1
- package/output/ndjson.js.map +1 -1
- package/package.json +7 -6
- package/program.d.ts.map +1 -1
- package/program.js +4 -2
- package/program.js.map +1 -1
- package/resources/ignore/index.d.ts +2 -4
- package/resources/ignore/index.d.ts.map +1 -1
- package/resources/ignore/index.js +5 -5
- package/resources/ignore/index.js.map +1 -1
- package/resources/ignore/matcher.d.ts +2 -35
- package/resources/ignore/matcher.d.ts.map +1 -1
- package/resources/ignore/matcher.js +25 -109
- package/resources/ignore/matcher.js.map +1 -1
- package/resources/plugin.d.ts +50 -18
- package/resources/plugin.d.ts.map +1 -1
- package/resources/plugin.js +73 -74
- package/resources/plugin.js.map +1 -1
- package/resources/run/agent-exec.js +1 -1
- package/resources/run/agent-exec.js.map +1 -1
- package/resources/run/prepare.d.ts +8 -19
- package/resources/run/prepare.d.ts.map +1 -1
- package/resources/run/prepare.js +4 -4
- package/resources/run/prepare.js.map +1 -1
- package/resources/skill.d.ts +2 -2
- package/resources/skill.d.ts.map +1 -1
- package/resources/skill.js +6 -10
- package/resources/skill.js.map +1 -1
- package/src/commands/agent-exec-flags.ts +6 -12
- package/src/commands/connect.ts +1 -1
- package/src/commands/install.test.ts +289 -0
- package/src/commands/install.ts +108 -0
- package/src/commands/marketplace.test.ts +200 -0
- package/src/commands/marketplace.ts +131 -0
- package/src/commands/push.ts +5 -10
- package/src/commands/run.ts +0 -3
- package/src/config/config.ts +37 -0
- package/src/config/index.ts +1 -0
- package/src/local/bootstrap.test.ts +71 -0
- package/src/local/bootstrap.ts +133 -0
- package/src/local/daemon/launch.ts +5 -40
- package/src/local/daemon/process.ts +1 -1
- package/src/local/plugins/defaults.test.ts +238 -0
- package/src/local/plugins/defaults.ts +135 -0
- package/src/local/seedpack/content.ts +11 -42
- package/src/marketplace/__fixtures__/cursor-marketplace.ts +62 -0
- package/src/marketplace/config.test.ts +245 -0
- package/src/marketplace/config.ts +236 -0
- package/src/marketplace/github.test.ts +318 -0
- package/src/marketplace/github.ts +296 -0
- package/src/marketplace/index.ts +54 -0
- package/src/marketplace/install.test.ts +194 -0
- package/src/marketplace/install.ts +159 -0
- package/src/marketplace/materialize.ts +80 -0
- package/src/marketplace/official.test.ts +107 -0
- package/src/marketplace/official.ts +125 -0
- package/src/marketplace/read.test.ts +94 -0
- package/src/marketplace/read.ts +76 -0
- package/src/marketplace/ref.test.ts +179 -0
- package/src/marketplace/ref.ts +74 -0
- package/src/marketplace/render.ts +154 -0
- package/src/output/ndjson.ts +1 -1
- package/src/program.test.ts +4 -13
- package/src/program.ts +4 -2
- package/src/resources/ignore/ignore.test.ts +23 -100
- package/src/resources/ignore/index.ts +11 -8
- package/src/resources/ignore/matcher.ts +33 -136
- package/src/resources/plugin.test.ts +35 -6
- package/src/resources/plugin.ts +132 -85
- package/src/resources/run/agent-exec.ts +1 -1
- package/src/resources/run/prepare.test.ts +4 -18
- package/src/resources/run/prepare.ts +12 -24
- package/src/resources/skill.ts +6 -10
- package/commands/draft.d.ts +0 -3
- package/commands/draft.d.ts.map +0 -1
- package/commands/draft.js +0 -115
- package/commands/draft.js.map +0 -1
- package/resources/ignore/defaults.d.ts +0 -2
- package/resources/ignore/defaults.d.ts.map +0 -1
- package/resources/ignore/defaults.js +0 -140
- package/resources/ignore/defaults.js.map +0 -1
- package/resources/ignore/match.d.ts +0 -3
- package/resources/ignore/match.d.ts.map +0 -1
- package/resources/ignore/match.js +0 -166
- package/resources/ignore/match.js.map +0 -1
- package/resources/ignore/pattern.d.ts +0 -13
- package/resources/ignore/pattern.d.ts.map +0 -1
- package/resources/ignore/pattern.js +0 -118
- package/resources/ignore/pattern.js.map +0 -1
- package/src/commands/draft.ts +0 -189
- package/src/resources/ignore/defaults.ts +0 -155
- package/src/resources/ignore/match.ts +0 -158
- package/src/resources/ignore/pattern.ts +0 -131
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// Pins how a ref becomes a prepared push: an offered entry prepares through
|
|
2
|
+
// the same walker `push plugin` uses (one digest for one tree); a version
|
|
3
|
+
// pin is an assertion; a bare name is searched across the configured
|
|
4
|
+
// marketplaces and must land in exactly one; every temp tree a search opened
|
|
5
|
+
// is disposed, whatever the outcome.
|
|
6
|
+
|
|
7
|
+
import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { zipSync } from "fflate";
|
|
11
|
+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
12
|
+
import { preparePluginPush } from "../resources/plugin.js";
|
|
13
|
+
import { writeCursorMarketplace } from "./__fixtures__/cursor-marketplace.js";
|
|
14
|
+
import { type MarketplaceListing, OFFICIAL_MARKETPLACE } from "./config.js";
|
|
15
|
+
import { assertVersion, locateEntry, prepareEntry } from "./install.js";
|
|
16
|
+
import { readMarketplaceTree } from "./read.js";
|
|
17
|
+
import { parseInstallRef } from "./ref.js";
|
|
18
|
+
|
|
19
|
+
let root: string;
|
|
20
|
+
|
|
21
|
+
beforeAll(() => {
|
|
22
|
+
root = mkdtempSync(join(tmpdir(), "stigmer-marketplace-install-"));
|
|
23
|
+
writeCursorMarketplace(root);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
afterAll(() => {
|
|
27
|
+
rmSync(root, { recursive: true, force: true });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("prepareEntry", () => {
|
|
31
|
+
it("prepares an offered entry exactly as push plugin would prepare its directory", async () => {
|
|
32
|
+
const tree = readMarketplaceTree(root);
|
|
33
|
+
const viaMarketplace = await prepareEntry(tree, "github");
|
|
34
|
+
const viaFolder = await preparePluginPush(join(root, "third_party", "github"));
|
|
35
|
+
expect(viaMarketplace.plugin.name).toBe("github");
|
|
36
|
+
expect(viaMarketplace.digest).toBe(viaFolder.digest);
|
|
37
|
+
expect(
|
|
38
|
+
Buffer.from(viaMarketplace.archive).equals(
|
|
39
|
+
Buffer.from(viaFolder.archive),
|
|
40
|
+
),
|
|
41
|
+
).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("refuses an entry the marketplace does not offer, quoting the drop sentence when there is one", async () => {
|
|
45
|
+
const tree = readMarketplaceTree(root);
|
|
46
|
+
await expect(prepareEntry(tree, "ghost")).rejects.toThrow(
|
|
47
|
+
/does not offer a plugin named 'ghost'\n\n.*ghost.*\n\nRun 'stigmer marketplace show cursor-plugins' to see the 2 it offers/s,
|
|
48
|
+
);
|
|
49
|
+
await expect(prepareEntry(tree, "nope")).rejects.toThrow(
|
|
50
|
+
/does not offer a plugin named 'nope'\n\nRun 'stigmer marketplace show/,
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("assertVersion", () => {
|
|
56
|
+
it("passes without a pin or with the offered version, refuses another naming the offered one", async () => {
|
|
57
|
+
const prepared = await prepareEntry(readMarketplaceTree(root), "thermos");
|
|
58
|
+
expect(() => assertVersion(prepared, undefined, "thermos")).not.toThrow();
|
|
59
|
+
expect(() =>
|
|
60
|
+
assertVersion(prepared, "1.0.0", "thermos@1.0.0"),
|
|
61
|
+
).not.toThrow();
|
|
62
|
+
expect(() =>
|
|
63
|
+
assertVersion(prepared, "9.9.9", "cursor-plugins/thermos@9.9.9"),
|
|
64
|
+
).toThrow(
|
|
65
|
+
/pins version 9\.9\.9, but the marketplace offers 'thermos' at 1\.0\.0\n\nInstall it as 'cursor-plugins\/thermos@1\.0\.0'/,
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("locateEntry", () => {
|
|
71
|
+
const encoder = new TextEncoder();
|
|
72
|
+
|
|
73
|
+
/** A GitHub source served from memory: the fixture tree zipped under one top directory. */
|
|
74
|
+
function servingZipOf(
|
|
75
|
+
files: Record<string, string>,
|
|
76
|
+
top = "r-HEAD",
|
|
77
|
+
): typeof globalThis.fetch {
|
|
78
|
+
const tree: Record<string, Uint8Array> = {};
|
|
79
|
+
for (const [path, content] of Object.entries(files))
|
|
80
|
+
tree[`${top}/${path}`] = encoder.encode(content);
|
|
81
|
+
const bytes = zipSync(tree);
|
|
82
|
+
return async () => new Response(bytes, { status: 200 });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const remoteFiles = {
|
|
86
|
+
"marketplace.json": JSON.stringify({
|
|
87
|
+
name: "remote",
|
|
88
|
+
plugins: [{ name: "thermos", source: "./thermos" }],
|
|
89
|
+
}),
|
|
90
|
+
"thermos/plugin.json": JSON.stringify({
|
|
91
|
+
name: "thermos",
|
|
92
|
+
version: "3.0.0",
|
|
93
|
+
}),
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
function listing(...extra: MarketplaceListing["known"]): MarketplaceListing {
|
|
97
|
+
return { known: [OFFICIAL_MARKETPLACE, ...extra], unreadable: [] };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
it("opens the marketplace a prefixed ref names and refuses an unknown prefix", async () => {
|
|
101
|
+
const local = {
|
|
102
|
+
name: "cursor-plugins",
|
|
103
|
+
source: { type: "local", path: root } as const,
|
|
104
|
+
};
|
|
105
|
+
const located = await locateEntry(
|
|
106
|
+
parseInstallRef("cursor-plugins/thermos"),
|
|
107
|
+
listing(local),
|
|
108
|
+
);
|
|
109
|
+
try {
|
|
110
|
+
expect(located.marketplace.name).toBe("cursor-plugins");
|
|
111
|
+
expect(located.tree.root).toBe(root);
|
|
112
|
+
} finally {
|
|
113
|
+
located.open.dispose();
|
|
114
|
+
}
|
|
115
|
+
await expect(
|
|
116
|
+
locateEntry(parseInstallRef("nowhere/thermos"), listing(local)),
|
|
117
|
+
).rejects.toThrow(/no marketplace named 'nowhere' is configured/);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("searches a bare name across every marketplace and lands in the one holder, disposing the rest", async () => {
|
|
121
|
+
const local = {
|
|
122
|
+
name: "cursor-plugins",
|
|
123
|
+
source: { type: "local", path: root } as const,
|
|
124
|
+
};
|
|
125
|
+
const located = await locateEntry(
|
|
126
|
+
parseInstallRef("github"),
|
|
127
|
+
listing(local),
|
|
128
|
+
);
|
|
129
|
+
try {
|
|
130
|
+
expect(located.marketplace.name).toBe("cursor-plugins");
|
|
131
|
+
} finally {
|
|
132
|
+
located.open.dispose();
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("refuses a bare name nobody offers, naming what was searched and what could not be read", async () => {
|
|
137
|
+
const local = {
|
|
138
|
+
name: "cursor-plugins",
|
|
139
|
+
source: { type: "local", path: root } as const,
|
|
140
|
+
};
|
|
141
|
+
await expect(
|
|
142
|
+
locateEntry(parseInstallRef("nope"), {
|
|
143
|
+
known: [OFFICIAL_MARKETPLACE, local],
|
|
144
|
+
unreadable: [{ name: "broken", reason: "x" }],
|
|
145
|
+
}),
|
|
146
|
+
).rejects.toThrow(
|
|
147
|
+
/no configured marketplace offers a plugin named 'nope'\n\nSearched: stigmer, cursor-plugins\. Not searched.*broken/,
|
|
148
|
+
);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("refuses a bare name two marketplaces offer, naming each qualified ref, and leaves no temp tree behind", async () => {
|
|
152
|
+
const local = {
|
|
153
|
+
name: "cursor-plugins",
|
|
154
|
+
source: { type: "local", path: root } as const,
|
|
155
|
+
};
|
|
156
|
+
const remote = {
|
|
157
|
+
name: "remote",
|
|
158
|
+
source: { type: "github", repo: "a/b" } as const,
|
|
159
|
+
};
|
|
160
|
+
const before = countTempTrees();
|
|
161
|
+
await expect(
|
|
162
|
+
locateEntry(parseInstallRef("thermos"), listing(local, remote), {
|
|
163
|
+
fetchImpl: servingZipOf(remoteFiles),
|
|
164
|
+
}),
|
|
165
|
+
).rejects.toThrow(
|
|
166
|
+
/offered by more than one marketplace: cursor-plugins, remote\n\nName the one you mean: cursor-plugins\/thermos or remote\/thermos/,
|
|
167
|
+
);
|
|
168
|
+
expect(countTempTrees()).toBe(before);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("disposes a fetched tree when the located entry is released", async () => {
|
|
172
|
+
const remote = {
|
|
173
|
+
name: "remote",
|
|
174
|
+
source: { type: "github", repo: "a/b" } as const,
|
|
175
|
+
};
|
|
176
|
+
const located = await locateEntry(
|
|
177
|
+
parseInstallRef("remote/thermos"),
|
|
178
|
+
listing(remote),
|
|
179
|
+
{
|
|
180
|
+
fetchImpl: servingZipOf(remoteFiles),
|
|
181
|
+
},
|
|
182
|
+
);
|
|
183
|
+
expect(existsSync(located.tree.root)).toBe(true);
|
|
184
|
+
located.open.dispose();
|
|
185
|
+
expect(existsSync(located.tree.root)).toBe(false);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
/** The temp trees `openMarketplace` creates (`stigmer-marketplace-` plus mkdtemp's suffix and nothing else). */
|
|
190
|
+
function countTempTrees(): number {
|
|
191
|
+
return readdirSync(tmpdir()).filter((name) =>
|
|
192
|
+
/^stigmer-marketplace-[A-Za-z0-9]+$/.test(name),
|
|
193
|
+
).length;
|
|
194
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// An entry of an open marketplace becomes a prepared push. This is the one
|
|
2
|
+
// place that happens, for both callers: `stigmer install <ref>` and the
|
|
3
|
+
// `stigmer up` default-set step. Neither re-derives the entry's directory or
|
|
4
|
+
// the archive's digest; both hand the prepared value to `pushPrepared` (or,
|
|
5
|
+
// for `up`, compare its digest first). The entry's directory goes through the
|
|
6
|
+
// same walker `push plugin <dir>` uses, so a marketplace install and a push
|
|
7
|
+
// of the same checked-out folder yield one digest for one tree.
|
|
8
|
+
|
|
9
|
+
import { type MarketplaceEntry } from "@stigmer/plugin-package";
|
|
10
|
+
import { UsageError } from "../errors/index.js";
|
|
11
|
+
import {
|
|
12
|
+
type PreparedPluginPush,
|
|
13
|
+
preparePluginPush,
|
|
14
|
+
} from "../resources/plugin.js";
|
|
15
|
+
import {
|
|
16
|
+
type KnownMarketplace,
|
|
17
|
+
type MarketplaceListing,
|
|
18
|
+
describeSource,
|
|
19
|
+
} from "./config.js";
|
|
20
|
+
import {
|
|
21
|
+
type OpenMarketplace,
|
|
22
|
+
type OpenMarketplaceOptions,
|
|
23
|
+
openMarketplace,
|
|
24
|
+
} from "./materialize.js";
|
|
25
|
+
import {
|
|
26
|
+
type ReadMarketplaceTree,
|
|
27
|
+
entryDirectory,
|
|
28
|
+
findEntry,
|
|
29
|
+
readMarketplaceTree,
|
|
30
|
+
} from "./read.js";
|
|
31
|
+
import { type InstallRef, formatInstallRef } from "./ref.js";
|
|
32
|
+
|
|
33
|
+
/** A ref resolved to the one marketplace that offers it, with that marketplace open on disk. */
|
|
34
|
+
export interface LocatedEntry {
|
|
35
|
+
readonly marketplace: KnownMarketplace;
|
|
36
|
+
readonly open: OpenMarketplace;
|
|
37
|
+
readonly tree: ReadMarketplaceTree;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Find the marketplace a ref names. A prefixed ref names it outright; a bare
|
|
42
|
+
* name is searched across every configured marketplace in listing order
|
|
43
|
+
* (the official one first), and exactly one must offer it: none refuses
|
|
44
|
+
* toward `marketplace show`, more than one refuses by naming each holder,
|
|
45
|
+
* because "the first one wins" would make the answer depend on the order
|
|
46
|
+
* the user added marketplaces in. The caller owns `open` and disposes it.
|
|
47
|
+
*/
|
|
48
|
+
export async function locateEntry(
|
|
49
|
+
ref: InstallRef,
|
|
50
|
+
listing: MarketplaceListing,
|
|
51
|
+
options: OpenMarketplaceOptions = {},
|
|
52
|
+
): Promise<LocatedEntry> {
|
|
53
|
+
if (ref.marketplace !== undefined) {
|
|
54
|
+
const marketplace = listing.known.find(
|
|
55
|
+
(known) => known.name === ref.marketplace,
|
|
56
|
+
);
|
|
57
|
+
if (marketplace === undefined) {
|
|
58
|
+
throw new UsageError(
|
|
59
|
+
`no marketplace named '${ref.marketplace}' is configured\n\n` +
|
|
60
|
+
"Run 'stigmer marketplace list' to see the configured names, or add one with 'stigmer marketplace add <source>'.",
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
const open = await openMarketplace(marketplace.source, options);
|
|
64
|
+
try {
|
|
65
|
+
const tree = readMarketplaceTree(
|
|
66
|
+
open.root,
|
|
67
|
+
describeSource(marketplace.source),
|
|
68
|
+
);
|
|
69
|
+
if (findEntry(tree.marketplace, ref.name) === undefined)
|
|
70
|
+
throw entryNotOffered(tree, ref.name);
|
|
71
|
+
return { marketplace, open, tree };
|
|
72
|
+
} catch (error) {
|
|
73
|
+
open.dispose();
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const holders: LocatedEntry[] = [];
|
|
79
|
+
for (const marketplace of listing.known) {
|
|
80
|
+
const open = await openMarketplace(marketplace.source, options);
|
|
81
|
+
let tree: ReadMarketplaceTree;
|
|
82
|
+
try {
|
|
83
|
+
tree = readMarketplaceTree(open.root, describeSource(marketplace.source));
|
|
84
|
+
} catch (error) {
|
|
85
|
+
open.dispose();
|
|
86
|
+
for (const holder of holders) holder.open.dispose();
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
if (findEntry(tree.marketplace, ref.name) === undefined) {
|
|
90
|
+
open.dispose();
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
holders.push({ marketplace, open, tree });
|
|
94
|
+
}
|
|
95
|
+
if (holders.length === 1) return holders[0]!;
|
|
96
|
+
for (const holder of holders) holder.open.dispose();
|
|
97
|
+
if (holders.length === 0) {
|
|
98
|
+
const skipped =
|
|
99
|
+
listing.unreadable.length === 0
|
|
100
|
+
? ""
|
|
101
|
+
: ` Not searched, because its entry cannot be read: ${listing.unreadable.map((entry) => entry.name).join(", ")} (see 'stigmer marketplace list').`;
|
|
102
|
+
throw new UsageError(
|
|
103
|
+
`no configured marketplace offers a plugin named '${ref.name}'\n\n` +
|
|
104
|
+
`Searched: ${listing.known.map((known) => known.name).join(", ")}.${skipped} ` +
|
|
105
|
+
"Run 'stigmer marketplace show <name>' to see what each offers, or add the marketplace that has it.",
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
throw new UsageError(
|
|
109
|
+
`'${ref.name}' is offered by more than one marketplace: ${holders.map((holder) => holder.marketplace.name).join(", ")}\n\n` +
|
|
110
|
+
`Name the one you mean: ${holders.map((holder) => formatInstallRef({ ...ref, marketplace: holder.marketplace.name })).join(" or ")}.`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Read, validate and zip the entry called `name`; refuses an entry the marketplace does not offer. */
|
|
115
|
+
export async function prepareEntry(
|
|
116
|
+
tree: ReadMarketplaceTree,
|
|
117
|
+
name: string,
|
|
118
|
+
): Promise<PreparedPluginPush> {
|
|
119
|
+
const entry = findEntry(tree.marketplace, name);
|
|
120
|
+
if (entry === undefined) throw entryNotOffered(tree, name);
|
|
121
|
+
return preparePluginPush(entryDirectory(tree, entry));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* `@version` in a ref is an assertion, not a selector: a marketplace tree
|
|
126
|
+
* holds one version of each plugin, so the ref either names it or is wrong.
|
|
127
|
+
*/
|
|
128
|
+
export function assertVersion(
|
|
129
|
+
prepared: PreparedPluginPush,
|
|
130
|
+
wanted: string | undefined,
|
|
131
|
+
ref: string,
|
|
132
|
+
): void {
|
|
133
|
+
if (wanted === undefined) return;
|
|
134
|
+
const offered = prepared.plugin.version;
|
|
135
|
+
if (offered === wanted) return;
|
|
136
|
+
throw new UsageError(
|
|
137
|
+
offered === undefined
|
|
138
|
+
? `'${ref}' pins version ${wanted}, but the marketplace's '${prepared.plugin.name}' declares no version\n\nInstall it without '@${wanted}'.`
|
|
139
|
+
: `'${ref}' pins version ${wanted}, but the marketplace offers '${prepared.plugin.name}' at ${offered}\n\nInstall it as '${ref.slice(0, ref.lastIndexOf("@"))}@${offered}', or without a version.`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function entryNotOffered(tree: ReadMarketplaceTree, name: string): UsageError {
|
|
144
|
+
const dropped = tree.warnings.find((warning) => warning.subject === name);
|
|
145
|
+
const offered = tree.marketplace.plugins.map(
|
|
146
|
+
(entry: MarketplaceEntry) => entry.name,
|
|
147
|
+
);
|
|
148
|
+
const lines = [
|
|
149
|
+
`marketplace '${tree.marketplace.name}' does not offer a plugin named '${name}'`,
|
|
150
|
+
];
|
|
151
|
+
if (dropped !== undefined) lines.push("", dropped.message);
|
|
152
|
+
lines.push(
|
|
153
|
+
"",
|
|
154
|
+
offered.length === 0
|
|
155
|
+
? "It offers no plugins this CLI can install."
|
|
156
|
+
: `Run 'stigmer marketplace show ${tree.marketplace.name}' to see the ${offered.length} it offers.`,
|
|
157
|
+
);
|
|
158
|
+
return new UsageError(lines.join("\n"));
|
|
159
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Every marketplace source becomes a directory, and every directory is read by
|
|
2
|
+
// the one plugin walker. This module is where a source turns into that
|
|
3
|
+
// directory and where the directory's lifetime is owned: a local path is used
|
|
4
|
+
// where it is; the official tree is resolved or acquired; a GitHub zipball is
|
|
5
|
+
// extracted under a temp directory that `dispose` removes. One owner for the
|
|
6
|
+
// lifetime means `add`, `show` and `install` cannot leak a temp tree between
|
|
7
|
+
// them, whatever path an error takes.
|
|
8
|
+
|
|
9
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import type { MarketplaceSource } from "./config.js";
|
|
13
|
+
import { fetchGitHubTree } from "./github.js";
|
|
14
|
+
import {
|
|
15
|
+
type ResolveOfficialOptions,
|
|
16
|
+
resolveOfficialMarketplace,
|
|
17
|
+
} from "./official.js";
|
|
18
|
+
|
|
19
|
+
/** A marketplace tree on disk, for as long as the caller holds it. */
|
|
20
|
+
export interface OpenMarketplace {
|
|
21
|
+
/** Absolute path to the tree root: the directory the marketplace file sits in. */
|
|
22
|
+
readonly root: string;
|
|
23
|
+
/** Release whatever the open created (a temp directory); a no-op for a tree that was already there. */
|
|
24
|
+
dispose(): void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface OpenMarketplaceOptions {
|
|
28
|
+
/** The HTTP client for GitHub sources (injectable for tests). */
|
|
29
|
+
readonly fetchImpl?: typeof globalThis.fetch;
|
|
30
|
+
/** How the official tree is found (injectable for tests). */
|
|
31
|
+
readonly official?: ResolveOfficialOptions;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function openMarketplace(
|
|
35
|
+
source: MarketplaceSource,
|
|
36
|
+
options: OpenMarketplaceOptions = {},
|
|
37
|
+
): Promise<OpenMarketplace> {
|
|
38
|
+
switch (source.type) {
|
|
39
|
+
case "local":
|
|
40
|
+
return { root: source.path, dispose: () => {} };
|
|
41
|
+
case "official":
|
|
42
|
+
return {
|
|
43
|
+
root: resolveOfficialMarketplace(options.official).dir,
|
|
44
|
+
dispose: () => {},
|
|
45
|
+
};
|
|
46
|
+
case "github": {
|
|
47
|
+
const root = mkdtempSync(join(tmpdir(), "stigmer-marketplace-"));
|
|
48
|
+
const dispose = (): void =>
|
|
49
|
+
rmSync(root, { recursive: true, force: true });
|
|
50
|
+
try {
|
|
51
|
+
await fetchGitHubTree(source, {
|
|
52
|
+
destDir: root,
|
|
53
|
+
fetchImpl: options.fetchImpl,
|
|
54
|
+
});
|
|
55
|
+
} catch (error) {
|
|
56
|
+
dispose();
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
return { root, dispose };
|
|
60
|
+
}
|
|
61
|
+
default: {
|
|
62
|
+
const exhaustive: never = source;
|
|
63
|
+
return exhaustive;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Open, hand the tree to `use`, and dispose whatever the outcome. */
|
|
69
|
+
export async function withMarketplace<T>(
|
|
70
|
+
source: MarketplaceSource,
|
|
71
|
+
use: (open: OpenMarketplace) => Promise<T> | T,
|
|
72
|
+
options: OpenMarketplaceOptions = {},
|
|
73
|
+
): Promise<T> {
|
|
74
|
+
const open = await openMarketplace(source, options);
|
|
75
|
+
try {
|
|
76
|
+
return await use(open);
|
|
77
|
+
} finally {
|
|
78
|
+
open.dispose();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Pins the official marketplace's resolution: the repo tree wins when this
|
|
2
|
+
// module runs from a checkout; otherwise the package is acquired once at the
|
|
3
|
+
// CLI's version into the runtimes root, reused on the next call, and refused
|
|
4
|
+
// for a non-release build that has nothing installed.
|
|
5
|
+
|
|
6
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
10
|
+
import {
|
|
11
|
+
acquireOfficialMarketplace,
|
|
12
|
+
resolveOfficialMarketplace,
|
|
13
|
+
} from "./official.js";
|
|
14
|
+
|
|
15
|
+
let home: string;
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
home = mkdtempSync(join(tmpdir(), "stigmer-official-marketplace-"));
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
rmSync(home, { recursive: true, force: true });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
function fakeInstall(installDir: string): void {
|
|
26
|
+
const pkg = join(installDir, "node_modules", "@stigmer", "plugins");
|
|
27
|
+
mkdirSync(pkg, { recursive: true });
|
|
28
|
+
writeFileSync(
|
|
29
|
+
join(pkg, "marketplace.json"),
|
|
30
|
+
JSON.stringify({ name: "stigmer", plugins: [] }),
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("resolveOfficialMarketplace", () => {
|
|
35
|
+
it("uses the repo tree when running from a checkout", () => {
|
|
36
|
+
const resolved = resolveOfficialMarketplace({ home });
|
|
37
|
+
expect(resolved.source).toBe("repo");
|
|
38
|
+
expect(resolved.dir.endsWith(join("", "plugins"))).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("acquires the package when there is no checkout, once, and reuses it", () => {
|
|
42
|
+
let installs = 0;
|
|
43
|
+
const install = (installDir: string): void => {
|
|
44
|
+
installs += 1;
|
|
45
|
+
fakeInstall(installDir);
|
|
46
|
+
};
|
|
47
|
+
const first = resolveOfficialMarketplace({
|
|
48
|
+
home,
|
|
49
|
+
version: "1.2.3",
|
|
50
|
+
install,
|
|
51
|
+
repoDir: () => null,
|
|
52
|
+
});
|
|
53
|
+
const second = resolveOfficialMarketplace({
|
|
54
|
+
home,
|
|
55
|
+
version: "1.2.3",
|
|
56
|
+
install,
|
|
57
|
+
repoDir: () => null,
|
|
58
|
+
});
|
|
59
|
+
expect(first.source).toBe("package");
|
|
60
|
+
expect(first.dir).toBe(
|
|
61
|
+
join(
|
|
62
|
+
home,
|
|
63
|
+
".stigmer",
|
|
64
|
+
"runtimes",
|
|
65
|
+
"1.2.3",
|
|
66
|
+
"node_modules",
|
|
67
|
+
"@stigmer",
|
|
68
|
+
"plugins",
|
|
69
|
+
),
|
|
70
|
+
);
|
|
71
|
+
expect(second.dir).toBe(first.dir);
|
|
72
|
+
expect(installs).toBe(1);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("acquireOfficialMarketplace", () => {
|
|
77
|
+
it("refuses to acquire for a non-release build with nothing installed", () => {
|
|
78
|
+
expect(() =>
|
|
79
|
+
acquireOfficialMarketplace({
|
|
80
|
+
home,
|
|
81
|
+
version: "0.0.0-dev",
|
|
82
|
+
install: () => {},
|
|
83
|
+
}),
|
|
84
|
+
).toThrow(/cannot acquire @stigmer\/plugins for a non-release build/);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("uses already-installed content even for a non-release build", () => {
|
|
88
|
+
const installDir = join(home, ".stigmer", "runtimes", "0.0.0-dev");
|
|
89
|
+
fakeInstall(installDir);
|
|
90
|
+
let installs = 0;
|
|
91
|
+
const dir = acquireOfficialMarketplace({
|
|
92
|
+
home,
|
|
93
|
+
version: "0.0.0-dev",
|
|
94
|
+
install: () => {
|
|
95
|
+
installs += 1;
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
expect(dir).toBe(join(installDir, "node_modules", "@stigmer", "plugins"));
|
|
99
|
+
expect(installs).toBe(0);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("refuses when the install did not produce the marketplace file", () => {
|
|
103
|
+
expect(() =>
|
|
104
|
+
acquireOfficialMarketplace({ home, version: "1.2.3", install: () => {} }),
|
|
105
|
+
).toThrow(/install did not produce/);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Resolution and on-demand acquisition of the official marketplace.
|
|
2
|
+
//
|
|
3
|
+
// The official catalogue (`plugins/` in the OSS repository, published as
|
|
4
|
+
// `@stigmer/plugins`) is the content `stigmer up` bootstraps a backend with,
|
|
5
|
+
// so it follows the seedpack's rule: acquired at the CLI's OWN version, so
|
|
6
|
+
// the plugins a fresh install gets match the control plane that installs
|
|
7
|
+
// them. A lean `npx @stigmer/cli` does not carry the content; it is fetched
|
|
8
|
+
// once into ~/.stigmer/runtimes/<version>/ like the server and the runner.
|
|
9
|
+
//
|
|
10
|
+
// Two sources, tried in order (mirrors runtime/server.ts and the seedpack):
|
|
11
|
+
// 1. A repo-tree checkout (dev): `<repo>/plugins`, the tree itself.
|
|
12
|
+
// 2. The published `@stigmer/plugins@<cli-version>`, whose package root IS
|
|
13
|
+
// the marketplace tree (`publish-libs.mjs` publishes from `dist/`, where
|
|
14
|
+
// `stage-content.mjs` placed `marketplace.json` and every listed plugin).
|
|
15
|
+
//
|
|
16
|
+
// Presence beats acquirability (the server acquirer's rule): installed content
|
|
17
|
+
// is used whatever its version string; only an install we would have to
|
|
18
|
+
// perform is refused for a non-release build. The all-in-one image relies on
|
|
19
|
+
// this: it bakes the package at a dev-stamped version npm never published.
|
|
20
|
+
|
|
21
|
+
import { existsSync } from "node:fs";
|
|
22
|
+
import { homedir } from "node:os";
|
|
23
|
+
import { dirname, join } from "node:path";
|
|
24
|
+
import { fileURLToPath } from "node:url";
|
|
25
|
+
import { MARKETPLACE_LOCATIONS } from "@stigmer/plugin-package";
|
|
26
|
+
import { CliExitError } from "../errors/cli-exit-error.js";
|
|
27
|
+
import { ExitCode } from "../errors/exit-codes.js";
|
|
28
|
+
import { log } from "../logger.js";
|
|
29
|
+
import { runtimesDir } from "../local/paths.js";
|
|
30
|
+
import {
|
|
31
|
+
ensureRuntimesRoot,
|
|
32
|
+
isAcquirableRelease,
|
|
33
|
+
npmInstallIntoRuntimes,
|
|
34
|
+
type NpmInstall,
|
|
35
|
+
} from "../local/runtime/runtimes-install.js";
|
|
36
|
+
import { VERSION } from "../version.js";
|
|
37
|
+
|
|
38
|
+
const PLUGINS_PACKAGE = "@stigmer/plugins";
|
|
39
|
+
|
|
40
|
+
/** The file that makes a directory the official tree: Stigmer's own marketplace dialect at the root. */
|
|
41
|
+
const OFFICIAL_MARKER = MARKETPLACE_LOCATIONS.stigmer;
|
|
42
|
+
|
|
43
|
+
export interface OfficialMarketplaceDir {
|
|
44
|
+
/** Absolute path to the tree root (holds `marketplace.json`). */
|
|
45
|
+
readonly dir: string;
|
|
46
|
+
/** Where the tree came from, for diagnostics. */
|
|
47
|
+
readonly source: "repo" | "package";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ResolveOfficialOptions {
|
|
51
|
+
home?: string;
|
|
52
|
+
/** Release version of `@stigmer/plugins` to acquire (defaults to the CLI version). */
|
|
53
|
+
version?: string;
|
|
54
|
+
/** npm install implementation (injectable for tests). */
|
|
55
|
+
install?: NpmInstall;
|
|
56
|
+
/** The repo-tree probe (injectable for tests; the default walks up from this module). */
|
|
57
|
+
repoDir?: () => string | null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The official marketplace's directory: a repo-tree checkout in dev, else the
|
|
62
|
+
* on-demand-acquired package. Throws actionable guidance when acquisition is
|
|
63
|
+
* not possible (a non-release build with no checkout, a failed install).
|
|
64
|
+
*/
|
|
65
|
+
export function resolveOfficialMarketplace(
|
|
66
|
+
opts: ResolveOfficialOptions = {},
|
|
67
|
+
): OfficialMarketplaceDir {
|
|
68
|
+
const repo = (opts.repoDir ?? repoPluginsDir)();
|
|
69
|
+
if (repo !== null) return { dir: repo, source: "repo" };
|
|
70
|
+
return { dir: acquireOfficialMarketplace(opts), source: "package" };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Acquire `@stigmer/plugins@<version>` into ~/.stigmer/runtimes/<version>/
|
|
75
|
+
* (idempotent) and return the package directory, which is the tree root.
|
|
76
|
+
*/
|
|
77
|
+
export function acquireOfficialMarketplace(
|
|
78
|
+
opts: ResolveOfficialOptions = {},
|
|
79
|
+
): string {
|
|
80
|
+
const home = opts.home ?? homedir();
|
|
81
|
+
const version = opts.version ?? VERSION;
|
|
82
|
+
|
|
83
|
+
const installDir = join(runtimesDir(home), version);
|
|
84
|
+
const pkgDir = join(installDir, "node_modules", "@stigmer", "plugins");
|
|
85
|
+
const marker = join(pkgDir, OFFICIAL_MARKER);
|
|
86
|
+
|
|
87
|
+
if (!existsSync(marker)) {
|
|
88
|
+
if (!isAcquirableRelease(version)) {
|
|
89
|
+
throw new CliExitError(
|
|
90
|
+
`cannot acquire ${PLUGINS_PACKAGE} for a non-release build (${version})`,
|
|
91
|
+
ExitCode.General,
|
|
92
|
+
[
|
|
93
|
+
"Run from the repo (the plugins/ tree is used directly in dev).",
|
|
94
|
+
"On-demand acquisition is only available for published releases.",
|
|
95
|
+
],
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
log.info(`acquiring ${PLUGINS_PACKAGE}`, { version, dir: installDir });
|
|
99
|
+
ensureRuntimesRoot(installDir);
|
|
100
|
+
const install = opts.install ?? npmInstallIntoRuntimes;
|
|
101
|
+
install(installDir, `${PLUGINS_PACKAGE}@${version}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!existsSync(marker)) {
|
|
105
|
+
throw new CliExitError(
|
|
106
|
+
`${PLUGINS_PACKAGE} install did not produce ${marker}`,
|
|
107
|
+
ExitCode.General,
|
|
108
|
+
[`Remove ${installDir} and retry.`],
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
return pkgDir;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Walk up from this module to a repo root whose plugins/ holds the marker.
|
|
115
|
+
function repoPluginsDir(): string | null {
|
|
116
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
117
|
+
for (let i = 0; i < 10; i += 1) {
|
|
118
|
+
const candidate = join(dir, "plugins");
|
|
119
|
+
if (existsSync(join(candidate, OFFICIAL_MARKER))) return candidate;
|
|
120
|
+
const parent = dirname(dir);
|
|
121
|
+
if (parent === dir) break;
|
|
122
|
+
dir = parent;
|
|
123
|
+
}
|
|
124
|
+
return null;
|
|
125
|
+
}
|