@danypops/pi-packed 0.19.7 → 0.19.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client.d.ts +109 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +1 -0
- package/dist/protocol.d.ts +221 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +1 -0
- package/extension/src/{permission.ts → approval/permission.ts} +1 -1
- package/extension/src/index.ts +1 -1
- package/extension/src/packed.ts +2 -2
- package/extension/src/{discover.ts → tabs/discover.ts} +4 -4
- package/extension/src/{resource-config.ts → tabs/resource-config.ts} +4 -4
- package/extension/src/{security-tui.ts → tabs/security-tui.ts} +3 -3
- package/extension/src/tool-output.ts +1 -1
- package/extension/src/tools.ts +2 -2
- package/extension/src/tui.ts +4 -4
- package/package.json +31 -8
- package/service/schema/pi-setup-v1.schema.json +70 -0
- package/service/setup/danypops-ecosystem.pi-setup.json +15 -0
- package/service/src/adoption/advisories.ts +268 -0
- package/service/src/adoption/check.ts +872 -0
- package/service/src/adoption/commit-freshness.ts +167 -0
- package/service/src/adoption/doctor.ts +135 -0
- package/service/src/adoption/install-validation.ts +187 -0
- package/service/src/adoption/pack.ts +291 -0
- package/service/src/adoption/score.ts +466 -0
- package/service/src/adoption/smoke-child.ts +113 -0
- package/service/src/adoption/smoke.ts +282 -0
- package/service/src/cli/cli.ts +926 -0
- package/service/src/daemon/cleanup.ts +76 -0
- package/service/src/daemon/client.ts +412 -0
- package/service/src/daemon/daemon-service.ts +249 -0
- package/service/src/daemon/daemon.ts +110 -0
- package/service/src/daemon/service.ts +664 -0
- package/service/src/daemon/watcher.ts +92 -0
- package/service/src/index/build-index.ts +256 -0
- package/service/src/packages/catalog.ts +61 -0
- package/service/src/packages/db.ts +224 -0
- package/service/src/packages/install.ts +60 -0
- package/service/src/packages/installed.ts +123 -0
- package/service/src/packages/package.ts +141 -0
- package/service/src/packages/resources.ts +203 -0
- package/service/src/pi/pi-version.ts +171 -0
- package/service/src/public/atomic-json.ts +32 -0
- package/service/src/public/client.ts +277 -0
- package/service/src/public/protocol.ts +169 -0
- package/service/src/publish/publish.ts +855 -0
- package/service/src/registry/registry.ts +246 -0
- package/service/src/security/security.ts +128 -0
- package/service/src/self-update/self-update.ts +148 -0
- package/service/src/setup/setup.ts +761 -0
- package/service/src/shared/atomic-json.ts +33 -0
- package/service/src/shared/cache.ts +21 -0
- package/service/src/shared/constants.ts +73 -0
- package/service/src/shared/log.ts +21 -0
- package/service/src/shared/paths.ts +88 -0
- package/service/src/shared/state.ts +15 -0
- package/service/src/shared/version.ts +46 -0
- package/service/test/advisories.test.ts +287 -0
- package/service/test/check.test.ts +368 -0
- package/service/test/cleanup.test.ts +220 -0
- package/service/test/cli.test.ts +1303 -0
- package/service/test/core.test.ts +181 -0
- package/service/test/daemon-kit-migration.test.ts +181 -0
- package/service/test/daemon-service.test.ts +238 -0
- package/service/test/db.test.ts +178 -0
- package/service/test/doctor.test.ts +234 -0
- package/service/test/domain.test.ts +291 -0
- package/service/test/fixtures/install-validation/broken-package/extension/index.ts +3 -0
- package/service/test/fixtures/install-validation/broken-package/package.json +8 -0
- package/service/test/fixtures/install-validation/healthy-package/extension/index.ts +3 -0
- package/service/test/fixtures/install-validation/healthy-package/package.json +8 -0
- package/service/test/fixtures/install-validation/no-manifest-package/package.json +5 -0
- package/service/test/index.test.ts +353 -0
- package/service/test/install-validation.test.ts +114 -0
- package/service/test/install.test.ts +113 -0
- package/service/test/log.test.ts +42 -0
- package/service/test/pack-score.test.ts +513 -0
- package/service/test/pi-version.test.ts +318 -0
- package/service/test/public-boundary.test.ts +54 -0
- package/service/test/public-client.test.ts +127 -0
- package/service/test/public-consumer.ts +8 -0
- package/service/test/publish.test.ts +333 -0
- package/service/test/registry-contract.test.ts +148 -0
- package/service/test/resources.test.ts +255 -0
- package/service/test/security.test.ts +89 -0
- package/service/test/self-update.test.ts +257 -0
- package/service/test/service.test.ts +555 -0
- package/service/test/setup.test.ts +375 -0
- package/service/test/smoke.test.ts +118 -0
- package/service/test/version.test.ts +37 -0
- package/service/tsconfig.consumer.json +13 -0
- package/service/tsconfig.public.json +12 -0
- /package/extension/src/{reload.ts → approval/reload.ts} +0 -0
- /package/extension/src/{discover-model.ts → tabs/discover-model.ts} +0 -0
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import type { PkgInfo, Registry, SearchPage } from "../src/packages/package.ts";
|
|
6
|
+
import {
|
|
7
|
+
browserOpenCommand,
|
|
8
|
+
npmWebUrl,
|
|
9
|
+
PublishManager,
|
|
10
|
+
renderStageWorkflow,
|
|
11
|
+
runNpmLoginWeb,
|
|
12
|
+
satisfiesRange,
|
|
13
|
+
stageWorkflowFile,
|
|
14
|
+
type TrustStatusCommand,
|
|
15
|
+
type VersionCommand,
|
|
16
|
+
} from "../src/publish/publish.ts";
|
|
17
|
+
|
|
18
|
+
class RegistryFixture implements Registry {
|
|
19
|
+
constructor(private readonly existing = true) {}
|
|
20
|
+
async search(): Promise<SearchPage> {
|
|
21
|
+
return { results: [], total: 0 };
|
|
22
|
+
}
|
|
23
|
+
async searchPage(): Promise<SearchPage> {
|
|
24
|
+
return { results: [], total: 0 };
|
|
25
|
+
}
|
|
26
|
+
async searchAll() {
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
async info(name: string): Promise<PkgInfo> {
|
|
30
|
+
if (!this.existing) throw new Error("npm info: HTTP 404");
|
|
31
|
+
return { name, version: "1.0.0" };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function project(overrides: Record<string, unknown> = {}): string {
|
|
36
|
+
const root = mkdtempSync(join(tmpdir(), "packed-publish-"));
|
|
37
|
+
writeFileSync(
|
|
38
|
+
join(root, "package.json"),
|
|
39
|
+
JSON.stringify({
|
|
40
|
+
name: "@example/pi-demo",
|
|
41
|
+
version: "1.1.0",
|
|
42
|
+
description: "Pi demo package",
|
|
43
|
+
repository: { type: "git", url: "git+https://github.com/example/pi-demo.git" },
|
|
44
|
+
keywords: ["pi-package"],
|
|
45
|
+
scripts: { check: "tsc --noEmit", test: "bun test", build: "bun build src.ts --outdir dist" },
|
|
46
|
+
...overrides,
|
|
47
|
+
}),
|
|
48
|
+
);
|
|
49
|
+
writeFileSync(join(root, "bun.lock"), "{}");
|
|
50
|
+
return root;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const npmVersion: VersionCommand = async () => ({ code: 0, stdout: "11.15.0\n", stderr: "" });
|
|
54
|
+
const loggedIn: VersionCommand = async () => ({ code: 0, stdout: "example-user\n", stderr: "" });
|
|
55
|
+
const trusted: TrustStatusCommand = async () => ({
|
|
56
|
+
code: 0,
|
|
57
|
+
stderr: "",
|
|
58
|
+
stdout: JSON.stringify({
|
|
59
|
+
type: "github",
|
|
60
|
+
repository: "example/pi-demo",
|
|
61
|
+
file: "pi-demo-stage-publish.yml",
|
|
62
|
+
permissions: ["createStagedPackage"],
|
|
63
|
+
}),
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
class MultiRegistryFixture implements Registry {
|
|
67
|
+
constructor(private readonly versions: Record<string, string> = {}) {}
|
|
68
|
+
async search(): Promise<SearchPage> {
|
|
69
|
+
return { results: [], total: 0 };
|
|
70
|
+
}
|
|
71
|
+
async searchPage(): Promise<SearchPage> {
|
|
72
|
+
return { results: [], total: 0 };
|
|
73
|
+
}
|
|
74
|
+
async searchAll() {
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
async info(name: string): Promise<PkgInfo> {
|
|
78
|
+
const version = this.versions[name];
|
|
79
|
+
if (!version) throw new Error("npm info: HTTP 404");
|
|
80
|
+
return { name, version };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** A two-package Bun workspace: packages/core (published, depended on) and
|
|
85
|
+
* packages/ext (the one under test, declaring a dependency on core). */
|
|
86
|
+
function workspace(extDependencyRange = "^1.0.0"): { root: string; corePath: string; extPath: string } {
|
|
87
|
+
const root = mkdtempSync(join(tmpdir(), "packed-workspace-"));
|
|
88
|
+
writeFileSync(join(root, "package.json"), JSON.stringify({ name: "demo-workspace", private: true, workspaces: ["packages/*"] }));
|
|
89
|
+
writeFileSync(join(root, "bun.lock"), "{}");
|
|
90
|
+
const corePath = join(root, "packages", "core");
|
|
91
|
+
const extPath = join(root, "packages", "ext");
|
|
92
|
+
mkdirSync(corePath, { recursive: true });
|
|
93
|
+
mkdirSync(extPath, { recursive: true });
|
|
94
|
+
writeFileSync(
|
|
95
|
+
join(corePath, "package.json"),
|
|
96
|
+
JSON.stringify({
|
|
97
|
+
name: "@example/core",
|
|
98
|
+
version: "1.0.0",
|
|
99
|
+
repository: { type: "git", url: "git+https://github.com/example/pi-demo.git", directory: "packages/core" },
|
|
100
|
+
scripts: { build: "bun build", test: "bun test" },
|
|
101
|
+
}),
|
|
102
|
+
);
|
|
103
|
+
writeFileSync(
|
|
104
|
+
join(extPath, "package.json"),
|
|
105
|
+
JSON.stringify({
|
|
106
|
+
name: "@example/ext",
|
|
107
|
+
version: "1.0.0",
|
|
108
|
+
repository: { type: "git", url: "git+https://github.com/example/pi-demo.git", directory: "packages/ext" },
|
|
109
|
+
dependencies: { "@example/core": extDependencyRange },
|
|
110
|
+
scripts: { test: "bun test" },
|
|
111
|
+
}),
|
|
112
|
+
);
|
|
113
|
+
return { root, corePath, extPath };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
describe("standalone staged publishing", () => {
|
|
117
|
+
it("renders a deterministic least-privilege GitHub OIDC workflow", () => {
|
|
118
|
+
const workflow = renderStageWorkflow({ packageManager: { name: "bun", version: "1.3.14" }, scripts: ["build", "check", "test"] });
|
|
119
|
+
expect(workflow).toContain("workflow_dispatch:");
|
|
120
|
+
expect(workflow).toContain("id-token: write");
|
|
121
|
+
expect(workflow).toContain("contents: read");
|
|
122
|
+
expect(workflow).toContain("timeout-minutes: 20");
|
|
123
|
+
expect(workflow).toContain("npm install --global npm@11.15.0");
|
|
124
|
+
expect(workflow).toContain("npm stage publish --access public --provenance --ignore-scripts");
|
|
125
|
+
expect(workflow).not.toContain("npm publish --");
|
|
126
|
+
expect(workflow).not.toContain("NODE_AUTH_TOKEN");
|
|
127
|
+
expect(workflow).not.toContain("pull_request:");
|
|
128
|
+
expect(workflow).toContain('- "v*"');
|
|
129
|
+
expect(workflow).not.toContain("working-directory");
|
|
130
|
+
expect(workflow).not.toContain("core-first ordering");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("creates the workflow without Pipes, Enigma, npm credentials, or overwrite", async () => {
|
|
134
|
+
const root = project();
|
|
135
|
+
const manager = new PublishManager(new RegistryFixture(), npmVersion);
|
|
136
|
+
const report = await manager.setup(root);
|
|
137
|
+
expect(report.ok).toBe(true);
|
|
138
|
+
expect(report.wrote).toBe(true);
|
|
139
|
+
expect(report.repository).toBe("example/pi-demo");
|
|
140
|
+
expect(report.trustCommand).toBe(
|
|
141
|
+
"npm trust github @example/pi-demo --repo example/pi-demo --file pi-demo-stage-publish.yml --allow-stage-publish",
|
|
142
|
+
);
|
|
143
|
+
expect(report.webUrl).toBe("https://www.npmjs.com/package/@example/pi-demo/access");
|
|
144
|
+
const workflow = readFileSync(report.workflowPath, "utf8");
|
|
145
|
+
expect(workflow).toContain("bun install --frozen-lockfile --ignore-scripts");
|
|
146
|
+
expect(workflow).toContain("bun run build");
|
|
147
|
+
const second = await manager.setup(root);
|
|
148
|
+
expect(second.ok).toBe(false);
|
|
149
|
+
expect(second.diagnostics.map((item) => item.code)).toContain("PUBLISH_WORKFLOW_EXISTS");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("requires an existing public npm package and valid GitHub repository", async () => {
|
|
153
|
+
const missing = await new PublishManager(new RegistryFixture(false), npmVersion).setup(project());
|
|
154
|
+
expect(missing.ok).toBe(false);
|
|
155
|
+
expect(missing.diagnostics.map((item) => item.code)).toContain("PUBLISH_PACKAGE_NOT_FOUND");
|
|
156
|
+
expect(existsSync(missing.workflowPath)).toBe(false);
|
|
157
|
+
const badRepo = await new PublishManager(new RegistryFixture(), npmVersion).setup(
|
|
158
|
+
project({ repository: "https://gitlab.com/example/pi-demo" }),
|
|
159
|
+
);
|
|
160
|
+
expect(badRepo.diagnostics.map((item) => item.code)).toContain("PUBLISH_GITHUB_REPOSITORY_REQUIRED");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("rejects private packages and non-deterministic installs", async () => {
|
|
164
|
+
const restricted = await new PublishManager(new RegistryFixture(), npmVersion).setup(
|
|
165
|
+
project({ publishConfig: { access: "restricted" } }),
|
|
166
|
+
);
|
|
167
|
+
expect(restricted.diagnostics.map((item) => item.code)).toContain("PUBLISH_PRIVATE_PACKAGE");
|
|
168
|
+
const noLock = project();
|
|
169
|
+
rmSync(join(noLock, "bun.lock"));
|
|
170
|
+
const unlocked = await new PublishManager(new RegistryFixture(), npmVersion).setup(noLock);
|
|
171
|
+
expect(unlocked.diagnostics.map((item) => item.code)).toContain("PUBLISH_LOCKFILE_REQUIRED");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("status validates workflow identity, npm floor, package existence, and handoff", async () => {
|
|
175
|
+
const root = project();
|
|
176
|
+
const manager = new PublishManager(new RegistryFixture(), npmVersion, trusted, loggedIn);
|
|
177
|
+
await manager.setup(root);
|
|
178
|
+
const status = await manager.status(root);
|
|
179
|
+
expect(status.ready).toBe(true);
|
|
180
|
+
expect(status.checks).toMatchObject({
|
|
181
|
+
packageExists: true,
|
|
182
|
+
repository: true,
|
|
183
|
+
workflow: true,
|
|
184
|
+
lockfile: true,
|
|
185
|
+
node: true,
|
|
186
|
+
npm: true,
|
|
187
|
+
trustedPublisher: "verified",
|
|
188
|
+
});
|
|
189
|
+
expect(status.nextSteps).toContain(
|
|
190
|
+
"Run: npm trust github @example/pi-demo --repo example/pi-demo --file pi-demo-stage-publish.yml --allow-stage-publish",
|
|
191
|
+
);
|
|
192
|
+
expect(status.nextSteps.join(" ")).toContain("2FA");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("rejects broader or mismatched trusted-publisher grants", async () => {
|
|
196
|
+
const root = project();
|
|
197
|
+
await new PublishManager(new RegistryFixture(), npmVersion).setup(root);
|
|
198
|
+
const broad: TrustStatusCommand = async () => ({
|
|
199
|
+
code: 0,
|
|
200
|
+
stderr: "",
|
|
201
|
+
stdout: JSON.stringify({
|
|
202
|
+
type: "github",
|
|
203
|
+
repository: "other/repo",
|
|
204
|
+
file: "publish.yml",
|
|
205
|
+
permissions: ["createPackage", "createStagedPackage"],
|
|
206
|
+
}),
|
|
207
|
+
});
|
|
208
|
+
const status = await new PublishManager(new RegistryFixture(), npmVersion, broad, loggedIn).status(root);
|
|
209
|
+
expect(status.ready).toBe(false);
|
|
210
|
+
expect(status.checks.trustedPublisher).toBe("not-verified");
|
|
211
|
+
expect(status.diagnostics.map((item) => item.code)).toContain("PUBLISH_TRUST_MISMATCH");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("reports stale workflow and old npm without mutating", async () => {
|
|
215
|
+
const root = project();
|
|
216
|
+
mkdirSync(join(root, ".github/workflows"), { recursive: true });
|
|
217
|
+
writeFileSync(join(root, ".github/workflows/pi-demo-stage-publish.yml"), "name: unsafe\n");
|
|
218
|
+
const manager = new PublishManager(
|
|
219
|
+
new RegistryFixture(),
|
|
220
|
+
async () => ({ code: 0, stdout: "11.14.1", stderr: "" }),
|
|
221
|
+
undefined,
|
|
222
|
+
loggedIn,
|
|
223
|
+
);
|
|
224
|
+
const before = readFileSync(join(root, ".github/workflows/pi-demo-stage-publish.yml"), "utf8");
|
|
225
|
+
const status = await manager.status(root);
|
|
226
|
+
expect(status.ready).toBe(false);
|
|
227
|
+
expect(status.checks).toMatchObject({ workflow: false, npm: false });
|
|
228
|
+
expect(readFileSync(join(root, ".github/workflows/pi-demo-stage-publish.yml"), "utf8")).toBe(before);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("gives sibling workspace packages independent slug-named workflows scoped to their own directory", async () => {
|
|
232
|
+
const { root, extPath } = workspace();
|
|
233
|
+
const manager = new PublishManager(new MultiRegistryFixture({ "@example/core": "1.0.0", "@example/ext": "1.0.0" }), npmVersion);
|
|
234
|
+
const report = await manager.setup(extPath);
|
|
235
|
+
expect(report.ok).toBe(true);
|
|
236
|
+
expect(report.workflowPath).toBe(join(root, ".github/workflows/ext-stage-publish.yml"));
|
|
237
|
+
const workflow = readFileSync(report.workflowPath, "utf8");
|
|
238
|
+
expect(workflow).toContain("bun run --cwd packages/ext test");
|
|
239
|
+
expect(workflow).not.toContain("bun run test\n");
|
|
240
|
+
expect(workflow).toContain("bunx --bun @danypops/pi-packed@latest check packages/ext");
|
|
241
|
+
expect(workflow).toContain('- "ext-v*"');
|
|
242
|
+
expect(workflow).toContain("npm stage publish --access public --provenance --ignore-scripts\n working-directory: packages/ext");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("enforces core-first ordering: the extension's own generated workflow verifies the core dependency is already published", async () => {
|
|
246
|
+
const { extPath } = workspace("^1.0.0");
|
|
247
|
+
const manager = new PublishManager(new MultiRegistryFixture({ "@example/core": "1.0.0", "@example/ext": "1.0.0" }), npmVersion);
|
|
248
|
+
const report = await manager.setup(extPath);
|
|
249
|
+
const workflow = readFileSync(report.workflowPath, "utf8");
|
|
250
|
+
expect(workflow).toContain("Verify @example/core@^1.0.0 is already published (core-first ordering)");
|
|
251
|
+
expect(workflow).toContain('npm view "@example/core@$version" version');
|
|
252
|
+
// core's own workflow declares no internal dependency, so it carries no ordering guard
|
|
253
|
+
const { corePath } = workspace();
|
|
254
|
+
const coreReport = await new PublishManager(new MultiRegistryFixture({ "@example/core": "1.0.0" }), npmVersion).setup(corePath);
|
|
255
|
+
expect(readFileSync(coreReport.workflowPath, "utf8")).not.toContain("core-first ordering");
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("status reports core-first ordering failure when the internal dependency is unpublished or its range no longer matches", async () => {
|
|
259
|
+
const { extPath } = workspace("^1.0.0");
|
|
260
|
+
const manager = new PublishManager(new MultiRegistryFixture({ "@example/ext": "1.0.0" }), npmVersion, trusted, loggedIn);
|
|
261
|
+
await manager.setup(extPath);
|
|
262
|
+
const missing = await manager.status(extPath);
|
|
263
|
+
expect(missing.checks.coreFirst).toBe(false);
|
|
264
|
+
expect(missing.diagnostics.map((item) => item.code)).toContain("PUBLISH_DEPENDENCY_NOT_PUBLISHED");
|
|
265
|
+
expect(missing.ready).toBe(false);
|
|
266
|
+
|
|
267
|
+
const mismatched = await new PublishManager(
|
|
268
|
+
new MultiRegistryFixture({ "@example/core": "2.0.0", "@example/ext": "1.0.0" }),
|
|
269
|
+
npmVersion,
|
|
270
|
+
trusted,
|
|
271
|
+
loggedIn,
|
|
272
|
+
).status(extPath);
|
|
273
|
+
expect(mismatched.checks.coreFirst).toBe(false);
|
|
274
|
+
expect(mismatched.diagnostics.map((item) => item.code)).toContain("PUBLISH_DEPENDENCY_RANGE_MISMATCH");
|
|
275
|
+
|
|
276
|
+
const satisfied = await new PublishManager(
|
|
277
|
+
new MultiRegistryFixture({ "@example/core": "1.0.4", "@example/ext": "1.0.0" }),
|
|
278
|
+
npmVersion,
|
|
279
|
+
trusted,
|
|
280
|
+
loggedIn,
|
|
281
|
+
).status(extPath);
|
|
282
|
+
expect(satisfied.checks.coreFirst).toBe(true);
|
|
283
|
+
expect(satisfied.diagnostics.map((item) => item.code)).not.toContain("PUBLISH_DEPENDENCY_NOT_PUBLISHED");
|
|
284
|
+
expect(satisfied.diagnostics.map((item) => item.code)).not.toContain("PUBLISH_DEPENDENCY_RANGE_MISMATCH");
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("stageWorkflowFile derives one collision-free filename per package name", () => {
|
|
288
|
+
expect(stageWorkflowFile("@danypops/packed")).toBe("packed-stage-publish.yml");
|
|
289
|
+
expect(stageWorkflowFile("@danypops/pi-packed")).toBe("pi-packed-stage-publish.yml");
|
|
290
|
+
expect(stageWorkflowFile("unscoped")).toBe("unscoped-stage-publish.yml");
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("reports the local machine's npm login state as informative, never blocking ready", async () => {
|
|
294
|
+
const root = project();
|
|
295
|
+
const notLoggedIn: VersionCommand = async () => ({ code: 1, stdout: "", stderr: "npm error code ENEEDAUTH" });
|
|
296
|
+
const manager = new PublishManager(new RegistryFixture(), npmVersion, trusted, notLoggedIn);
|
|
297
|
+
await manager.setup(root);
|
|
298
|
+
const status = await manager.status(root);
|
|
299
|
+
expect(status.checks.loggedIn).toBe(false);
|
|
300
|
+
expect(status.diagnostics.map((item) => item.code)).toContain("PUBLISH_NPM_NOT_LOGGED_IN");
|
|
301
|
+
// trust is independently verified in this fixture, so login state alone never flips ready
|
|
302
|
+
expect(status.ready).toBe(true);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("points the trust web handoff at npm's own Trusted Publisher access page, not a CLI command Packed constructs", () => {
|
|
306
|
+
expect(npmWebUrl("@danypops/packed")).toBe("https://www.npmjs.com/package/@danypops/packed/access");
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("exposes runNpmLoginWeb as real, separately invocable subprocess orchestration, never bundled into publish itself", () => {
|
|
310
|
+
expect(typeof runNpmLoginWeb).toBe("function");
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("browserOpenCommand is pure argv construction -- never spawns a real browser, tests never risk a live side effect", () => {
|
|
314
|
+
expect(browserOpenCommand("https://example.com")).toEqual(
|
|
315
|
+
process.platform === "darwin"
|
|
316
|
+
? ["open", "https://example.com"]
|
|
317
|
+
: process.platform === "win32"
|
|
318
|
+
? ["cmd", "/c", "start", "", "https://example.com"]
|
|
319
|
+
: ["xdg-open", "https://example.com"],
|
|
320
|
+
);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("satisfiesRange implements npm's caret/tilde/exact semantics, including 0.x's stricter caret", () => {
|
|
324
|
+
expect(satisfiesRange("0.1.4", "^0.1.0")).toBe(true);
|
|
325
|
+
expect(satisfiesRange("0.2.0", "^0.1.0")).toBe(false);
|
|
326
|
+
expect(satisfiesRange("1.4.0", "^1.0.0")).toBe(true);
|
|
327
|
+
expect(satisfiesRange("2.0.0", "^1.0.0")).toBe(false);
|
|
328
|
+
expect(satisfiesRange("1.0.5", "~1.0.0")).toBe(true);
|
|
329
|
+
expect(satisfiesRange("1.1.0", "~1.0.0")).toBe(false);
|
|
330
|
+
expect(satisfiesRange("1.0.0", "1.0.0")).toBe(true);
|
|
331
|
+
expect(satisfiesRange("1.0.0", "workspace:*")).toBeUndefined();
|
|
332
|
+
});
|
|
333
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
|
2
|
+
import { mkdtempSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import type { Server } from "bun";
|
|
6
|
+
import { DaemonRegistry } from "../src/daemon/client.ts";
|
|
7
|
+
import { createApp } from "../src/daemon/service.ts";
|
|
8
|
+
import type { Installer, Pkg, PkgInfo, Registry, SearchPage, UpdateOutcome } from "../src/packages/package.ts";
|
|
9
|
+
import { HttpRegistry } from "../src/registry/registry.ts";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Mitigates the SOLID audit's one real finding: `resolveRegistry()` swaps
|
|
13
|
+
* transparently between `HttpRegistry` and `DaemonRegistry`, but nothing
|
|
14
|
+
* proved the two honor the same contract. This exercises both real classes
|
|
15
|
+
* against the same fixture data through two different transports (a real
|
|
16
|
+
* `Bun.serve()` npm-mock, and a real mock daemon HTTP server) -- never a
|
|
17
|
+
* hand-rolled fake standing in for either concrete class.
|
|
18
|
+
*/
|
|
19
|
+
const FIXTURE_PACKAGES = [
|
|
20
|
+
{ name: "pi-lsp", version: "0.3.0", description: "LSP" },
|
|
21
|
+
{ name: "@narumitw/pi-lsp", version: "0.9.1", description: "another" },
|
|
22
|
+
{ name: "pi-lsp-lite", version: "1.0.0", description: "lite" },
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
/** Backs the mock daemon's own `reg` dependency -- an in-memory stand-in for
|
|
26
|
+
* npm is fine here (matches this repo's existing daemon-fixture precedent in
|
|
27
|
+
* cli.test.ts's FakeRegistry); the boundary under test is DaemonRegistry's
|
|
28
|
+
* real HTTP+auth round trip, not what serves data on the other end of it. */
|
|
29
|
+
class InMemoryRegistry implements Registry {
|
|
30
|
+
async search(query: string, limit: number): Promise<SearchPage> {
|
|
31
|
+
return this.searchPage(query, 0, limit);
|
|
32
|
+
}
|
|
33
|
+
async searchPage(_query: string, from: number, size: number): Promise<SearchPage> {
|
|
34
|
+
return { results: FIXTURE_PACKAGES.slice(from, from + size), total: FIXTURE_PACKAGES.length };
|
|
35
|
+
}
|
|
36
|
+
async searchAll(): Promise<Pkg[]> {
|
|
37
|
+
return FIXTURE_PACKAGES;
|
|
38
|
+
}
|
|
39
|
+
async info(name: string): Promise<PkgInfo> {
|
|
40
|
+
const match = FIXTURE_PACKAGES.find((p) => p.name === name);
|
|
41
|
+
if (!match) throw new Error(`${name}: not found`);
|
|
42
|
+
return { name: match.name, version: match.version, description: match.description };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
class NoopInstaller implements Installer {
|
|
47
|
+
async install(): Promise<string> {
|
|
48
|
+
throw new Error("not exercised by the registry contract");
|
|
49
|
+
}
|
|
50
|
+
async remove(): Promise<string> {
|
|
51
|
+
throw new Error("not exercised by the registry contract");
|
|
52
|
+
}
|
|
53
|
+
async update(): Promise<UpdateOutcome> {
|
|
54
|
+
throw new Error("not exercised by the registry contract");
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The shared contract: what `resolveRegistry()`'s real callers (CLI search,
|
|
59
|
+
* `packed score`/`packed check`'s target resolution) actually rely on being
|
|
60
|
+
* identical across both implementations -- `search()` and `info()`. */
|
|
61
|
+
function registryContract(label: string, makeRegistry: () => Registry) {
|
|
62
|
+
describe(`Registry contract: ${label}`, () => {
|
|
63
|
+
let registry: Registry;
|
|
64
|
+
beforeAll(() => {
|
|
65
|
+
registry = makeRegistry();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("search returns the fixture's lean results and total", async () => {
|
|
69
|
+
const { results, total } = await registry.search("keywords:pi-package lsp", 10);
|
|
70
|
+
expect(total).toBe(3);
|
|
71
|
+
expect(results.map((p) => p.name)).toEqual(["pi-lsp", "@narumitw/pi-lsp", "pi-lsp-lite"]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("info resolves a known package's shape", async () => {
|
|
75
|
+
const info = await registry.info("pi-lsp");
|
|
76
|
+
expect(info).toMatchObject({ name: "pi-lsp", version: "0.3.0" });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("info rejects an unknown package rather than resolving a guess", async () => {
|
|
80
|
+
expect(registry.info("does-not-exist")).rejects.toThrow();
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
describe("HttpRegistry vs DaemonRegistry", () => {
|
|
86
|
+
let httpServer: Server<undefined>;
|
|
87
|
+
let daemonServer: Server<undefined>;
|
|
88
|
+
const daemonToken = "c".repeat(64);
|
|
89
|
+
|
|
90
|
+
beforeAll(() => {
|
|
91
|
+
httpServer = Bun.serve({
|
|
92
|
+
port: 0,
|
|
93
|
+
fetch(req) {
|
|
94
|
+
const url = new URL(req.url);
|
|
95
|
+
if (url.pathname === "/-/v1/search") {
|
|
96
|
+
const from = Number(url.searchParams.get("from") ?? 0);
|
|
97
|
+
const size = Number(url.searchParams.get("size") ?? 10);
|
|
98
|
+
const page = FIXTURE_PACKAGES.slice(from, from + size);
|
|
99
|
+
return Response.json({ total: FIXTURE_PACKAGES.length, objects: page.map((p) => ({ package: p })) });
|
|
100
|
+
}
|
|
101
|
+
const match = FIXTURE_PACKAGES.find((p) => url.pathname === `/${encodeURIComponent(p.name).replace("%2F", "/")}/latest`);
|
|
102
|
+
return match ? Response.json(match) : new Response("nf", { status: 404 });
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
daemonServer = Bun.serve({
|
|
106
|
+
port: 0,
|
|
107
|
+
hostname: "127.0.0.1",
|
|
108
|
+
fetch: (req) =>
|
|
109
|
+
createApp({
|
|
110
|
+
reg: new InMemoryRegistry(),
|
|
111
|
+
inst: new NoopInstaller(),
|
|
112
|
+
token: daemonToken,
|
|
113
|
+
stateDir: mkdtempSync(join(tmpdir(), "packed-registry-contract-")),
|
|
114
|
+
}).fetch(req),
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
afterAll(() => {
|
|
118
|
+
httpServer.stop(true);
|
|
119
|
+
daemonServer.stop(true);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
registryContract("HttpRegistry (real Bun.serve npm-mock)", () => new HttpRegistry(`http://127.0.0.1:${httpServer.port}`, 2, 0, 1_000));
|
|
123
|
+
registryContract(
|
|
124
|
+
"DaemonRegistry (real mock daemon HTTP server)",
|
|
125
|
+
() => new DaemonRegistry(`http://127.0.0.1:${daemonServer.port}`, daemonToken),
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
// Two confirmed, intentional divergences -- neither is a defect, since no
|
|
129
|
+
// real caller ever relies on them: nothing pages through the daemon with
|
|
130
|
+
// a nonzero offset, and searchAll() is only ever called against a direct
|
|
131
|
+
// HttpRegistry (packages/catalog.ts's own mirror sync), never a daemon
|
|
132
|
+
// proxy. Documented here so either becoming load-bearing later is a
|
|
133
|
+
// deliberate contract change, not a silent behavior gap.
|
|
134
|
+
describe("known Registry divergences (documented, not a defect)", () => {
|
|
135
|
+
it("DaemonRegistry.searchAll is explicitly unsupported", () => {
|
|
136
|
+
const registry = new DaemonRegistry(`http://127.0.0.1:${daemonServer.port}`, daemonToken);
|
|
137
|
+
expect(registry.searchAll()).rejects.toThrow(/not supported via the daemon proxy/);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("DaemonRegistry.searchPage ignores its offset -- package.search has no wire-level offset param", async () => {
|
|
141
|
+
const registry = new DaemonRegistry(`http://127.0.0.1:${daemonServer.port}`, daemonToken);
|
|
142
|
+
const page = await registry.searchPage("keywords:pi-package lsp", 2, 2);
|
|
143
|
+
// HttpRegistry would return just ["pi-lsp-lite"] for from=2; the
|
|
144
|
+
// daemon proxy has no offset to send, so it always returns page 0.
|
|
145
|
+
expect(page.results.map((p) => p.name)).toEqual(["pi-lsp", "@narumitw/pi-lsp"]);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
});
|