@peers-app/peers-sdk 0.18.8 → 0.19.6
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/README.md +74 -1
- package/dist/data/files/file-read-stream.js +7 -0
- package/dist/data/files/file.types.d.ts +6 -0
- package/dist/data/files/file.types.js +18 -0
- package/dist/data/files/files.test.js +50 -7
- package/dist/data/package-version-resolver.d.ts +13 -5
- package/dist/data/package-version-resolver.js +64 -6
- package/dist/data/package-version-resolver.test.d.ts +0 -4
- package/dist/data/package-version-resolver.test.js +127 -5
- package/dist/data/package-versions.d.ts +3 -0
- package/dist/data/package-versions.js +5 -0
- package/dist/data/packages.d.ts +6 -29
- package/dist/data/packages.js +8 -6
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/package-installer/index.d.ts +10 -0
- package/dist/package-installer/index.js +26 -0
- package/dist/package-installer/package-author-signing.d.ts +54 -0
- package/dist/package-installer/package-author-signing.js +82 -0
- package/dist/package-installer/package-author-signing.test.d.ts +1 -0
- package/dist/package-installer/package-author-signing.test.js +189 -0
- package/dist/package-installer/package-cloner.d.ts +16 -0
- package/dist/package-installer/package-cloner.js +115 -0
- package/dist/package-installer/package-cloner.test.d.ts +1 -0
- package/dist/package-installer/package-cloner.test.js +276 -0
- package/dist/package-installer/package-creator.d.ts +22 -0
- package/dist/package-installer/package-creator.js +154 -0
- package/dist/package-installer/package-creator.test.d.ts +1 -0
- package/dist/package-installer/package-creator.test.js +354 -0
- package/dist/package-installer/package-installer.d.ts +32 -0
- package/dist/package-installer/package-installer.js +247 -0
- package/dist/package-installer/package-installer.test.d.ts +1 -0
- package/dist/package-installer/package-installer.test.js +666 -0
- package/dist/package-installer/package-propagation.d.ts +29 -0
- package/dist/package-installer/package-propagation.js +364 -0
- package/dist/package-installer/package-propagation.test.d.ts +1 -0
- package/dist/package-installer/package-propagation.test.js +1145 -0
- package/dist/package-installer/package-publisher.d.ts +55 -0
- package/dist/package-installer/package-publisher.js +71 -0
- package/dist/package-installer/package-publisher.test.d.ts +1 -0
- package/dist/package-installer/package-publisher.test.js +142 -0
- package/dist/package-installer/package-remote-checker.d.ts +54 -0
- package/dist/package-installer/package-remote-checker.js +194 -0
- package/dist/package-installer/package-remote-checker.test.d.ts +1 -0
- package/dist/package-installer/package-remote-checker.test.js +269 -0
- package/dist/package-installer/package-seed-installer.d.ts +45 -0
- package/dist/package-installer/package-seed-installer.js +108 -0
- package/dist/package-installer/package-seed-installer.test.d.ts +1 -0
- package/dist/package-installer/package-seed-installer.test.js +123 -0
- package/dist/package-installer/package-tarball.d.ts +35 -0
- package/dist/package-installer/package-tarball.js +57 -0
- package/dist/package-installer/package-tarball.test.d.ts +1 -0
- package/dist/package-installer/package-tarball.test.js +75 -0
- package/dist/package-installer/types.d.ts +110 -0
- package/dist/package-installer/types.js +2 -0
- package/dist/rpc-types.d.ts +14 -0
- package/dist/rpc-types.js +6 -0
- package/dist/system-ids.d.ts +1 -0
- package/dist/system-ids.js +2 -1
- package/package.json +3 -2
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const package_creator_1 = require("./package-creator");
|
|
4
|
+
// --- Mocks for SDK internals ---
|
|
5
|
+
const mockSignAndSavePkg = jest.fn();
|
|
6
|
+
jest.mock("../data/packages", () => ({
|
|
7
|
+
Packages: () => ({
|
|
8
|
+
signAndSave: mockSignAndSavePkg,
|
|
9
|
+
}),
|
|
10
|
+
}));
|
|
11
|
+
const mockGroupDeviceVar = jest.fn();
|
|
12
|
+
const mockPersistentVarsSave = jest.fn();
|
|
13
|
+
jest.mock("../data/persistent-vars", () => ({
|
|
14
|
+
groupDeviceVar: (...args) => mockGroupDeviceVar(...args),
|
|
15
|
+
PersistentVars: () => ({
|
|
16
|
+
save: mockPersistentVarsSave,
|
|
17
|
+
}),
|
|
18
|
+
}));
|
|
19
|
+
const mockNewKeys = jest.fn();
|
|
20
|
+
jest.mock("../keys", () => {
|
|
21
|
+
const actual = jest.requireActual("../keys");
|
|
22
|
+
return {
|
|
23
|
+
...actual,
|
|
24
|
+
newKeys: (...args) => mockNewKeys(...args),
|
|
25
|
+
};
|
|
26
|
+
});
|
|
27
|
+
// Mock installPackageFromBundles and getPackageInfo from the same package
|
|
28
|
+
const mockInstallPackageFromBundles = jest.fn();
|
|
29
|
+
const mockGetPackageInfo = jest.fn();
|
|
30
|
+
jest.mock("./package-installer", () => ({
|
|
31
|
+
installPackageFromBundles: (...args) => mockInstallPackageFromBundles(...args),
|
|
32
|
+
getPackageInfo: (...args) => mockGetPackageInfo(...args),
|
|
33
|
+
}));
|
|
34
|
+
// --- Test utilities ---
|
|
35
|
+
function makeMockDeps(files, shellExec) {
|
|
36
|
+
return {
|
|
37
|
+
fs: {
|
|
38
|
+
readFile: jest.fn(async (path) => {
|
|
39
|
+
if (files[path] !== undefined)
|
|
40
|
+
return files[path];
|
|
41
|
+
throw new Error(`ENOENT: ${path}`);
|
|
42
|
+
}),
|
|
43
|
+
writeFile: jest.fn(async () => { }),
|
|
44
|
+
exists: jest.fn(async (path) => {
|
|
45
|
+
if (path in files)
|
|
46
|
+
return true;
|
|
47
|
+
const dirPrefix = path.endsWith("/") ? path : `${path}/`;
|
|
48
|
+
return Object.keys(files).some((f) => f.startsWith(dirPrefix));
|
|
49
|
+
}),
|
|
50
|
+
readJson: jest.fn(async (path) => {
|
|
51
|
+
if (files[path] === undefined)
|
|
52
|
+
throw new Error(`ENOENT: ${path}`);
|
|
53
|
+
return JSON.parse(files[path]);
|
|
54
|
+
}),
|
|
55
|
+
},
|
|
56
|
+
shell: {
|
|
57
|
+
exec: shellExec ?? jest.fn(async () => ({ stdout: "", stderr: "" })),
|
|
58
|
+
},
|
|
59
|
+
resolvePath: (...segments) => segments.join("/"),
|
|
60
|
+
packagesRootDir: "/packages",
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
function mockDataContext() {
|
|
64
|
+
return {
|
|
65
|
+
packageLoader: {
|
|
66
|
+
loadPackage: jest.fn().mockResolvedValue(undefined),
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
// --- Tests ---
|
|
71
|
+
beforeEach(() => {
|
|
72
|
+
jest.clearAllMocks();
|
|
73
|
+
});
|
|
74
|
+
describe("nameToFolderSlug", () => {
|
|
75
|
+
it("converts a simple name", () => {
|
|
76
|
+
expect((0, package_creator_1.nameToFolderSlug)("My Widget")).toBe("my-widget");
|
|
77
|
+
});
|
|
78
|
+
it("converts camelCase names", () => {
|
|
79
|
+
expect((0, package_creator_1.nameToFolderSlug)("camelCaseThing")).toBe("camel-case-thing");
|
|
80
|
+
});
|
|
81
|
+
it("strips special characters", () => {
|
|
82
|
+
expect((0, package_creator_1.nameToFolderSlug)("Hello! World?")).toBe("hello-world");
|
|
83
|
+
});
|
|
84
|
+
it("collapses multiple spaces", () => {
|
|
85
|
+
expect((0, package_creator_1.nameToFolderSlug)("too many spaces")).toBe("too-many-spaces");
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
describe("createPackage", () => {
|
|
89
|
+
it("creates a package using local template cache with correct steps", async () => {
|
|
90
|
+
const templatePkgJson = JSON.stringify({
|
|
91
|
+
name: "peers-package-template",
|
|
92
|
+
version: "1.0.0",
|
|
93
|
+
peers: {},
|
|
94
|
+
repository: { url: "https://github.com/peers-app/peers-package-template" },
|
|
95
|
+
});
|
|
96
|
+
const constsContent = 'export const packageId = "<package-id>";\n' +
|
|
97
|
+
'export const packageName = "<package-name>";\n' +
|
|
98
|
+
'export const appScreenId = "<app-screen-id>";\n' +
|
|
99
|
+
'export const contractId = "<contract-id>";\n';
|
|
100
|
+
const deps = makeMockDeps({});
|
|
101
|
+
const dc = mockDataContext();
|
|
102
|
+
// Simulate: location doesn't exist, cache doesn't exist either, consts.ts exists after copy
|
|
103
|
+
deps.fs.exists.mockImplementation(async (path) => {
|
|
104
|
+
if (path === "/packages/my-widget")
|
|
105
|
+
return false;
|
|
106
|
+
if (path === "/packages/.peers-package-template")
|
|
107
|
+
return false;
|
|
108
|
+
if (path.includes("consts.ts"))
|
|
109
|
+
return true;
|
|
110
|
+
return false;
|
|
111
|
+
});
|
|
112
|
+
deps.fs.readJson.mockResolvedValue(JSON.parse(templatePkgJson));
|
|
113
|
+
deps.fs.readFile.mockResolvedValue(constsContent);
|
|
114
|
+
const pvarSetter = jest.fn();
|
|
115
|
+
mockGroupDeviceVar.mockReturnValue(pvarSetter);
|
|
116
|
+
const mockPv = { packageVersionId: "pv001", packageId: "pkg001" };
|
|
117
|
+
mockInstallPackageFromBundles.mockResolvedValue({
|
|
118
|
+
packageVersion: mockPv,
|
|
119
|
+
loaded: undefined,
|
|
120
|
+
unchanged: false,
|
|
121
|
+
});
|
|
122
|
+
const result = await (0, package_creator_1.createPackage)(dc, deps, {
|
|
123
|
+
name: "My Widget",
|
|
124
|
+
author: "Test User",
|
|
125
|
+
});
|
|
126
|
+
const execCalls = deps.shell.exec.mock.calls.map((c) => c[0]);
|
|
127
|
+
// Verify template was cloned to cache (not directly to location)
|
|
128
|
+
expect(execCalls).toContain("git clone https://github.com/peers-app/peers-package-template.git /packages/.peers-package-template");
|
|
129
|
+
// Verify cp -r from cache to location
|
|
130
|
+
expect(deps.shell.exec).toHaveBeenCalledWith("cp -r /packages/.peers-package-template/. /packages/my-widget");
|
|
131
|
+
// Should NOT have cloned directly to location
|
|
132
|
+
expect(execCalls).not.toContain(expect.stringContaining("git clone") && expect.stringContaining("/packages/my-widget"));
|
|
133
|
+
// Verify package.json was patched
|
|
134
|
+
expect(deps.fs.writeFile).toHaveBeenCalledWith("/packages/my-widget/package.json", expect.any(String));
|
|
135
|
+
const writtenPkgJson = JSON.parse(deps.fs.writeFile.mock.calls.find((c) => c[0].includes("package.json"))[1]);
|
|
136
|
+
expect(writtenPkgJson.name).toBe("my-widget");
|
|
137
|
+
expect(writtenPkgJson.author).toBe("Test User");
|
|
138
|
+
expect(writtenPkgJson.peers.packageId).toMatch(/^[a-z0-9]{25}$/);
|
|
139
|
+
expect(writtenPkgJson.repository).toBeUndefined();
|
|
140
|
+
// Verify consts.ts was patched
|
|
141
|
+
const constsWriteCall = deps.fs.writeFile.mock.calls.find((c) => c[0].includes("consts.ts"));
|
|
142
|
+
expect(constsWriteCall).toBeTruthy();
|
|
143
|
+
const writtenConsts = constsWriteCall[1];
|
|
144
|
+
expect(writtenConsts).not.toContain("<package-id>");
|
|
145
|
+
expect(writtenConsts).not.toContain("<package-name>");
|
|
146
|
+
expect(writtenConsts).not.toContain("<app-screen-id>");
|
|
147
|
+
expect(writtenConsts).not.toContain("<contract-id>");
|
|
148
|
+
expect(writtenConsts).toContain("My Widget");
|
|
149
|
+
// Verify initialization commands
|
|
150
|
+
expect(deps.shell.exec).toHaveBeenCalledWith("rm -rf .git", { cwd: "/packages/my-widget" });
|
|
151
|
+
expect(deps.shell.exec).toHaveBeenCalledWith("npm install", { cwd: "/packages/my-widget" });
|
|
152
|
+
expect(deps.shell.exec).toHaveBeenCalledWith("npm run build", { cwd: "/packages/my-widget" });
|
|
153
|
+
expect(deps.shell.exec).toHaveBeenCalledWith("git init", { cwd: "/packages/my-widget" });
|
|
154
|
+
expect(deps.shell.exec).toHaveBeenCalledWith("git add .", { cwd: "/packages/my-widget" });
|
|
155
|
+
expect(deps.shell.exec).toHaveBeenCalledWith('git commit -m "Initial commit"', {
|
|
156
|
+
cwd: "/packages/my-widget",
|
|
157
|
+
});
|
|
158
|
+
// Verify package record was created
|
|
159
|
+
expect(mockSignAndSavePkg).toHaveBeenCalledWith(expect.objectContaining({
|
|
160
|
+
name: "My Widget",
|
|
161
|
+
disabled: false,
|
|
162
|
+
}), { restoreIfDeleted: true, saveAsSnapshot: true });
|
|
163
|
+
// Verify device var was set
|
|
164
|
+
expect(mockGroupDeviceVar).toHaveBeenCalledWith(expect.stringContaining("packageLocalPath_"), expect.any(Object));
|
|
165
|
+
expect(pvarSetter).toHaveBeenCalledWith("/packages/my-widget");
|
|
166
|
+
// Verify installPackageFromBundles was called
|
|
167
|
+
expect(mockInstallPackageFromBundles).toHaveBeenCalledWith(dc, deps, expect.any(String), {
|
|
168
|
+
localPath: "/packages/my-widget",
|
|
169
|
+
});
|
|
170
|
+
expect(result.localPath).toBe("/packages/my-widget");
|
|
171
|
+
expect(result.packageVersion).toBe(mockPv);
|
|
172
|
+
});
|
|
173
|
+
it("delegates to installPackageFromBundles when directory already exists with valid package", async () => {
|
|
174
|
+
const deps = makeMockDeps({});
|
|
175
|
+
const dc = mockDataContext();
|
|
176
|
+
// Location exists
|
|
177
|
+
deps.fs.exists.mockResolvedValue(true);
|
|
178
|
+
// getPackageInfo returns a valid packageId
|
|
179
|
+
mockGetPackageInfo.mockResolvedValue({
|
|
180
|
+
packageId: "00mnkoyo7mizav6tseubjzr3a",
|
|
181
|
+
name: "existing-pkg",
|
|
182
|
+
});
|
|
183
|
+
const mockPv = { packageVersionId: "pv002", packageId: "00mnkoyo7mizav6tseubjzr3a" };
|
|
184
|
+
mockInstallPackageFromBundles.mockResolvedValue({
|
|
185
|
+
packageVersion: mockPv,
|
|
186
|
+
loaded: undefined,
|
|
187
|
+
unchanged: false,
|
|
188
|
+
});
|
|
189
|
+
const result = await (0, package_creator_1.createPackage)(dc, deps, {
|
|
190
|
+
name: "existing-pkg",
|
|
191
|
+
location: "/custom/path",
|
|
192
|
+
});
|
|
193
|
+
// Should NOT clone
|
|
194
|
+
expect(deps.shell.exec).not.toHaveBeenCalled();
|
|
195
|
+
// Should delegate to install
|
|
196
|
+
expect(mockInstallPackageFromBundles).toHaveBeenCalledWith(dc, deps, "00mnkoyo7mizav6tseubjzr3a", { localPath: "/custom/path" });
|
|
197
|
+
expect(result.packageId).toBe("00mnkoyo7mizav6tseubjzr3a");
|
|
198
|
+
expect(result.localPath).toBe("/custom/path");
|
|
199
|
+
});
|
|
200
|
+
it("throws when directory exists but has no valid package", async () => {
|
|
201
|
+
const deps = makeMockDeps({});
|
|
202
|
+
const dc = mockDataContext();
|
|
203
|
+
deps.fs.exists.mockResolvedValue(true);
|
|
204
|
+
mockGetPackageInfo.mockResolvedValue({ name: "no-id-pkg" });
|
|
205
|
+
await expect((0, package_creator_1.createPackage)(dc, deps, { name: "bad-pkg", location: "/some/path" })).rejects.toThrow("does not contain a valid Peers package");
|
|
206
|
+
});
|
|
207
|
+
it("uses direct clone (no caching) when custom templateRepo is provided", async () => {
|
|
208
|
+
const deps = makeMockDeps({});
|
|
209
|
+
const dc = mockDataContext();
|
|
210
|
+
deps.fs.exists.mockImplementation(async (path) => {
|
|
211
|
+
if (path === "/packages/my-pkg")
|
|
212
|
+
return false;
|
|
213
|
+
if (path.includes("consts.ts"))
|
|
214
|
+
return false;
|
|
215
|
+
return false;
|
|
216
|
+
});
|
|
217
|
+
deps.fs.readJson.mockResolvedValue({ name: "template", peers: {} });
|
|
218
|
+
const pvarSetter = jest.fn();
|
|
219
|
+
mockGroupDeviceVar.mockReturnValue(pvarSetter);
|
|
220
|
+
mockInstallPackageFromBundles.mockResolvedValue({
|
|
221
|
+
packageVersion: { packageVersionId: "pv" },
|
|
222
|
+
loaded: undefined,
|
|
223
|
+
unchanged: false,
|
|
224
|
+
});
|
|
225
|
+
await (0, package_creator_1.createPackage)(dc, deps, {
|
|
226
|
+
name: "My Pkg",
|
|
227
|
+
templateRepo: "https://example.com/custom-template.git",
|
|
228
|
+
});
|
|
229
|
+
// Custom template should clone directly to location (no cache, no cp -r)
|
|
230
|
+
expect(deps.shell.exec).toHaveBeenCalledWith("git clone https://example.com/custom-template.git /packages/my-pkg");
|
|
231
|
+
const execCalls = deps.shell.exec.mock.calls.map((c) => c[0]);
|
|
232
|
+
expect(execCalls).not.toContain(expect.stringContaining("cp -r"));
|
|
233
|
+
expect(execCalls).not.toContain(expect.stringContaining(".peers-package-template"));
|
|
234
|
+
});
|
|
235
|
+
it("uses custom location when provided", async () => {
|
|
236
|
+
const deps = makeMockDeps({});
|
|
237
|
+
const dc = mockDataContext();
|
|
238
|
+
deps.fs.exists.mockResolvedValue(false);
|
|
239
|
+
deps.fs.readJson.mockResolvedValue({ name: "template", peers: {} });
|
|
240
|
+
const pvarSetter = jest.fn();
|
|
241
|
+
mockGroupDeviceVar.mockReturnValue(pvarSetter);
|
|
242
|
+
mockInstallPackageFromBundles.mockResolvedValue({
|
|
243
|
+
packageVersion: { packageVersionId: "pv" },
|
|
244
|
+
loaded: undefined,
|
|
245
|
+
unchanged: false,
|
|
246
|
+
});
|
|
247
|
+
await (0, package_creator_1.createPackage)(dc, deps, {
|
|
248
|
+
name: "My Pkg",
|
|
249
|
+
location: "/custom/my-special-location",
|
|
250
|
+
});
|
|
251
|
+
expect(deps.shell.exec).toHaveBeenCalledWith(expect.stringContaining("/custom/my-special-location"));
|
|
252
|
+
expect(pvarSetter).toHaveBeenCalledWith("/custom/my-special-location");
|
|
253
|
+
});
|
|
254
|
+
it("generates a signing keypair and stores it when personalContext is provided", async () => {
|
|
255
|
+
const deps = makeMockDeps({});
|
|
256
|
+
const dc = mockDataContext();
|
|
257
|
+
const personalCtx = mockDataContext();
|
|
258
|
+
deps.fs.exists.mockImplementation(async (path) => {
|
|
259
|
+
if (path.includes("consts.ts"))
|
|
260
|
+
return false;
|
|
261
|
+
return false;
|
|
262
|
+
});
|
|
263
|
+
deps.fs.readJson.mockResolvedValue({ name: "template", peers: {} });
|
|
264
|
+
const pvarSetter = jest.fn();
|
|
265
|
+
mockGroupDeviceVar.mockReturnValue(pvarSetter);
|
|
266
|
+
mockInstallPackageFromBundles.mockResolvedValue({
|
|
267
|
+
packageVersion: { packageVersionId: "pv" },
|
|
268
|
+
loaded: undefined,
|
|
269
|
+
unchanged: false,
|
|
270
|
+
});
|
|
271
|
+
mockNewKeys.mockReturnValue({
|
|
272
|
+
publicKey: "test-public-key-abc",
|
|
273
|
+
secretKey: "test-secret-key-xyz",
|
|
274
|
+
publicBoxKey: "test-box-key",
|
|
275
|
+
});
|
|
276
|
+
await (0, package_creator_1.createPackage)(dc, deps, {
|
|
277
|
+
name: "Signed Pkg",
|
|
278
|
+
personalContext: personalCtx,
|
|
279
|
+
});
|
|
280
|
+
// Verify keypair was generated
|
|
281
|
+
expect(mockNewKeys).toHaveBeenCalled();
|
|
282
|
+
// Verify secret key was saved as a secret pvar in personal context
|
|
283
|
+
expect(mockPersistentVarsSave).toHaveBeenCalledWith(expect.objectContaining({
|
|
284
|
+
name: expect.stringMatching(/^packageSigningKey_[a-z0-9]{25}$/),
|
|
285
|
+
scope: "user",
|
|
286
|
+
value: { value: "test-secret-key-xyz" },
|
|
287
|
+
isSecret: true,
|
|
288
|
+
description: expect.stringContaining("Signed Pkg"),
|
|
289
|
+
}));
|
|
290
|
+
// Verify publishPublicKey was set on the package record
|
|
291
|
+
expect(mockSignAndSavePkg).toHaveBeenCalledWith(expect.objectContaining({
|
|
292
|
+
publishPublicKey: "test-public-key-abc",
|
|
293
|
+
}), expect.any(Object));
|
|
294
|
+
});
|
|
295
|
+
it("skips key generation when personalContext is not provided", async () => {
|
|
296
|
+
const deps = makeMockDeps({});
|
|
297
|
+
const dc = mockDataContext();
|
|
298
|
+
deps.fs.exists.mockImplementation(async (path) => {
|
|
299
|
+
if (path.includes("consts.ts"))
|
|
300
|
+
return false;
|
|
301
|
+
return false;
|
|
302
|
+
});
|
|
303
|
+
deps.fs.readJson.mockResolvedValue({ name: "template", peers: {} });
|
|
304
|
+
const pvarSetter = jest.fn();
|
|
305
|
+
mockGroupDeviceVar.mockReturnValue(pvarSetter);
|
|
306
|
+
mockInstallPackageFromBundles.mockResolvedValue({
|
|
307
|
+
packageVersion: { packageVersionId: "pv" },
|
|
308
|
+
loaded: undefined,
|
|
309
|
+
unchanged: false,
|
|
310
|
+
});
|
|
311
|
+
await (0, package_creator_1.createPackage)(dc, deps, { name: "No Key Pkg" });
|
|
312
|
+
// Should NOT call newKeys
|
|
313
|
+
expect(mockNewKeys).not.toHaveBeenCalled();
|
|
314
|
+
// Should NOT save any pvar
|
|
315
|
+
expect(mockPersistentVarsSave).not.toHaveBeenCalled();
|
|
316
|
+
// publishPublicKey should be empty string
|
|
317
|
+
expect(mockSignAndSavePkg).toHaveBeenCalledWith(expect.objectContaining({
|
|
318
|
+
publishPublicKey: "",
|
|
319
|
+
}), expect.any(Object));
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
describe("ensureTemplateCache", () => {
|
|
323
|
+
it("clones template when cache directory does not exist", async () => {
|
|
324
|
+
const deps = makeMockDeps({});
|
|
325
|
+
deps.fs.exists.mockResolvedValue(false);
|
|
326
|
+
const result = await (0, package_creator_1.ensureTemplateCache)(deps, "https://github.com/peers-app/peers-package-template.git");
|
|
327
|
+
expect(result).toBe("/packages/.peers-package-template");
|
|
328
|
+
expect(deps.shell.exec).toHaveBeenCalledWith("git clone https://github.com/peers-app/peers-package-template.git /packages/.peers-package-template");
|
|
329
|
+
});
|
|
330
|
+
it("pulls latest when cache directory already exists", async () => {
|
|
331
|
+
const deps = makeMockDeps({});
|
|
332
|
+
deps.fs.exists.mockResolvedValue(true);
|
|
333
|
+
const result = await (0, package_creator_1.ensureTemplateCache)(deps, "https://github.com/peers-app/peers-package-template.git");
|
|
334
|
+
expect(result).toBe("/packages/.peers-package-template");
|
|
335
|
+
expect(deps.shell.exec).toHaveBeenCalledWith("git pull", {
|
|
336
|
+
cwd: "/packages/.peers-package-template",
|
|
337
|
+
});
|
|
338
|
+
// Should NOT have cloned
|
|
339
|
+
const execCalls = deps.shell.exec.mock.calls.map((c) => c[0]);
|
|
340
|
+
expect(execCalls).not.toContain(expect.stringContaining("git clone"));
|
|
341
|
+
});
|
|
342
|
+
it("continues silently when pull fails (offline)", async () => {
|
|
343
|
+
const shellExec = jest.fn(async (cmd) => {
|
|
344
|
+
if (cmd === "git pull")
|
|
345
|
+
throw new Error("fatal: unable to access remote");
|
|
346
|
+
return { stdout: "", stderr: "" };
|
|
347
|
+
});
|
|
348
|
+
const deps = makeMockDeps({}, shellExec);
|
|
349
|
+
deps.fs.exists.mockResolvedValue(true);
|
|
350
|
+
const result = await (0, package_creator_1.ensureTemplateCache)(deps, "https://github.com/peers-app/peers-package-template.git");
|
|
351
|
+
// Should not throw, returns cache path
|
|
352
|
+
expect(result).toBe("/packages/.peers-package-template");
|
|
353
|
+
});
|
|
354
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { DataContext } from "../context/data-context";
|
|
2
|
+
import type { IInstallOpts, IInstallResult, IPackageInfo, IPackageInstallerDeps } from "./types";
|
|
3
|
+
/**
|
|
4
|
+
* Install or update a dev PackageVersion from locally-built bundle files.
|
|
5
|
+
*
|
|
6
|
+
* This is the core workhorse of the package-installer module. It reads
|
|
7
|
+
* bundles from `dist/`, hashes them for deduplication, saves File records
|
|
8
|
+
* only when content has changed, upserts the PackageVersion, and optionally
|
|
9
|
+
* triggers the package loader and device version resolution.
|
|
10
|
+
*/
|
|
11
|
+
export declare function installPackageFromBundles(dataContext: DataContext, deps: IPackageInstallerDeps, packageId: string, opts?: IInstallOpts): Promise<IInstallResult>;
|
|
12
|
+
/**
|
|
13
|
+
* Install bundles for all non-disabled packages in a data context.
|
|
14
|
+
* Errors are caught per-package so one failure doesn't block others.
|
|
15
|
+
*/
|
|
16
|
+
export declare function installAllPackageBundles(dataContext: DataContext, deps: IPackageInstallerDeps): Promise<void>;
|
|
17
|
+
/**
|
|
18
|
+
* Save a bundle's content as a File record, deduplicating by hash.
|
|
19
|
+
*
|
|
20
|
+
* If a File with the same hash already exists for this bundle name,
|
|
21
|
+
* the existing record is reused — no new File is created. This eliminates
|
|
22
|
+
* the "new File record on every dev reload" bug.
|
|
23
|
+
*/
|
|
24
|
+
export declare function saveBundleFile(dataContext: DataContext, content: string, name: string, _packageId: string): Promise<{
|
|
25
|
+
fileId: string;
|
|
26
|
+
fileHash: string;
|
|
27
|
+
}>;
|
|
28
|
+
/**
|
|
29
|
+
* Read package metadata from a local package directory.
|
|
30
|
+
* Uses `deps.fs.readJson` instead of `require()` — no cache issues.
|
|
31
|
+
*/
|
|
32
|
+
export declare function getPackageInfo(deps: IPackageInstallerDeps, localPath: string): Promise<IPackageInfo>;
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.installPackageFromBundles = installPackageFromBundles;
|
|
4
|
+
exports.installAllPackageBundles = installAllPackageBundles;
|
|
5
|
+
exports.saveBundleFile = saveBundleFile;
|
|
6
|
+
exports.getPackageInfo = getPackageInfo;
|
|
7
|
+
const file_types_1 = require("../data/files/file.types");
|
|
8
|
+
const files_1 = require("../data/files/files");
|
|
9
|
+
const package_version_resolver_1 = require("../data/package-version-resolver");
|
|
10
|
+
const package_versions_1 = require("../data/package-versions");
|
|
11
|
+
const packages_1 = require("../data/packages");
|
|
12
|
+
const persistent_vars_1 = require("../data/persistent-vars");
|
|
13
|
+
const utils_1 = require("../utils");
|
|
14
|
+
/**
|
|
15
|
+
* Install or update a dev PackageVersion from locally-built bundle files.
|
|
16
|
+
*
|
|
17
|
+
* This is the core workhorse of the package-installer module. It reads
|
|
18
|
+
* bundles from `dist/`, hashes them for deduplication, saves File records
|
|
19
|
+
* only when content has changed, upserts the PackageVersion, and optionally
|
|
20
|
+
* triggers the package loader and device version resolution.
|
|
21
|
+
*/
|
|
22
|
+
async function installPackageFromBundles(dataContext, deps, packageId, opts) {
|
|
23
|
+
const versionTag = opts?.versionTag ?? "dev";
|
|
24
|
+
// 1. Resolve local path
|
|
25
|
+
const localPath = opts?.localPath ?? (await getPackageLocalPath(packageId, dataContext, deps));
|
|
26
|
+
if (!(await deps.fs.exists(localPath))) {
|
|
27
|
+
throw new Error(`Package directory does not exist: ${localPath}`);
|
|
28
|
+
}
|
|
29
|
+
// 2. Read package info
|
|
30
|
+
const info = await getPackageInfo(deps, localPath);
|
|
31
|
+
const version = info.version ?? "0.0.1";
|
|
32
|
+
// 3. Read bundle files
|
|
33
|
+
const distDir = deps.resolvePath(localPath, "dist");
|
|
34
|
+
const packageBundlePath = deps.resolvePath(distDir, "package.bundle.js");
|
|
35
|
+
if (!(await deps.fs.exists(packageBundlePath))) {
|
|
36
|
+
throw new Error(`Required bundle not found: ${packageBundlePath}. Run 'npm run build' first.`);
|
|
37
|
+
}
|
|
38
|
+
const packageBundle = await deps.fs.readFile(packageBundlePath);
|
|
39
|
+
const routesBundlePath = deps.resolvePath(distDir, "routes.bundle.js");
|
|
40
|
+
const routesBundle = (await deps.fs.exists(routesBundlePath))
|
|
41
|
+
? await deps.fs.readFile(routesBundlePath)
|
|
42
|
+
: undefined;
|
|
43
|
+
const uiBundlePath = deps.resolvePath(distDir, "uis.bundle.js");
|
|
44
|
+
const uiBundle = (await deps.fs.exists(uiBundlePath))
|
|
45
|
+
? await deps.fs.readFile(uiBundlePath)
|
|
46
|
+
: undefined;
|
|
47
|
+
// 4. Hash each bundle (must match file-system hashes for signature consistency)
|
|
48
|
+
const packageHash = (0, file_types_1.computeFileHash)(packageBundle);
|
|
49
|
+
const routesHash = routesBundle ? (0, file_types_1.computeFileHash)(routesBundle) : undefined;
|
|
50
|
+
const uiHash = uiBundle ? (0, file_types_1.computeFileHash)(uiBundle) : undefined;
|
|
51
|
+
// 5. Compute PV hash
|
|
52
|
+
const pvHash = (0, package_versions_1.computePackageVersionHash)(version, "", packageHash, routesHash, uiHash);
|
|
53
|
+
// 6. Early exit if unchanged
|
|
54
|
+
const pvTable = (0, package_versions_1.PackageVersions)(dataContext);
|
|
55
|
+
const existingPv = await pvTable.findOne({ packageId, versionTag });
|
|
56
|
+
if (existingPv && existingPv.packageVersionHash === pvHash) {
|
|
57
|
+
return {
|
|
58
|
+
packageVersion: existingPv,
|
|
59
|
+
loaded: undefined,
|
|
60
|
+
unchanged: true,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
// 7. Save bundle files (deduped by hash)
|
|
64
|
+
const packageFile = await saveBundleFile(dataContext, packageBundle, `${packageId}-package.bundle.js`, packageId);
|
|
65
|
+
let routesFile;
|
|
66
|
+
if (routesBundle) {
|
|
67
|
+
routesFile = await saveBundleFile(dataContext, routesBundle, `${packageId}-routes.bundle.js`, packageId);
|
|
68
|
+
}
|
|
69
|
+
let uiFile;
|
|
70
|
+
if (uiBundle) {
|
|
71
|
+
uiFile = await saveBundleFile(dataContext, uiBundle, `${packageId}-uis.bundle.js`, packageId);
|
|
72
|
+
}
|
|
73
|
+
// 8. Upsert PackageVersion
|
|
74
|
+
const now = new Date().toISOString();
|
|
75
|
+
const pv = {
|
|
76
|
+
packageVersionId: existingPv?.packageVersionId ?? (0, utils_1.newid)(),
|
|
77
|
+
packageId,
|
|
78
|
+
version,
|
|
79
|
+
versionTag,
|
|
80
|
+
packageVersionHash: pvHash,
|
|
81
|
+
packageBundleFileId: packageFile.fileId,
|
|
82
|
+
packageBundleFileHash: packageFile.fileHash,
|
|
83
|
+
routesBundleFileId: routesFile?.fileId,
|
|
84
|
+
routesBundleFileHash: routesFile?.fileHash,
|
|
85
|
+
uiBundleFileId: uiFile?.fileId,
|
|
86
|
+
uiBundleFileHash: uiFile?.fileHash,
|
|
87
|
+
appNavs: existingPv?.appNavs,
|
|
88
|
+
history: existingPv?.history,
|
|
89
|
+
signature: existingPv?.signature ?? "",
|
|
90
|
+
createdBy: existingPv?.createdBy ?? packageId,
|
|
91
|
+
createdAt: now,
|
|
92
|
+
};
|
|
93
|
+
const savedPv = await pvTable.signAndSave(pv);
|
|
94
|
+
// 9. Load package (unless skipLoad)
|
|
95
|
+
let loaded;
|
|
96
|
+
if (!opts?.skipLoad) {
|
|
97
|
+
const pkg = await (0, packages_1.Packages)(dataContext).get(packageId);
|
|
98
|
+
if (pkg) {
|
|
99
|
+
loaded = await dataContext.packageLoader.loadPackage(pkg, {
|
|
100
|
+
force: true,
|
|
101
|
+
localPath,
|
|
102
|
+
packageVersionId: savedPv.packageVersionId,
|
|
103
|
+
});
|
|
104
|
+
// Extract appNavs from loaded instance onto the PV
|
|
105
|
+
if (loaded?.appNavs && JSON.stringify(loaded.appNavs) !== JSON.stringify(savedPv.appNavs)) {
|
|
106
|
+
savedPv.appNavs = loaded.appNavs;
|
|
107
|
+
await pvTable.signAndSave(savedPv);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// 10. Update package description if info provides one and it differs
|
|
112
|
+
const pkg = await (0, packages_1.Packages)(dataContext).get(packageId);
|
|
113
|
+
if (pkg && info.description && info.description !== pkg.description) {
|
|
114
|
+
await (0, packages_1.Packages)(dataContext).signAndSave({
|
|
115
|
+
...pkg,
|
|
116
|
+
description: info.description,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
// 11. Activate on device (unless skipResolve)
|
|
120
|
+
if (!opts?.skipResolve && pkg) {
|
|
121
|
+
await (0, package_version_resolver_1.updatePackagePrefs)(packageId, { activePackageVersionId: savedPv.packageVersionId }, dataContext);
|
|
122
|
+
await (0, package_version_resolver_1.resolveDevicePackageVersion)(pkg, dataContext, {
|
|
123
|
+
force: true,
|
|
124
|
+
localPath,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
return { packageVersion: savedPv, loaded, unchanged: false };
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Install bundles for all non-disabled packages in a data context.
|
|
131
|
+
* Errors are caught per-package so one failure doesn't block others.
|
|
132
|
+
*/
|
|
133
|
+
async function installAllPackageBundles(dataContext, deps) {
|
|
134
|
+
const packages = await (0, packages_1.Packages)(dataContext).list();
|
|
135
|
+
for (const pkg of packages) {
|
|
136
|
+
if (pkg.disabled)
|
|
137
|
+
continue;
|
|
138
|
+
try {
|
|
139
|
+
await installPackageFromBundles(dataContext, deps, pkg.packageId);
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
console.error(`[package-installer] Failed to install bundles for ${pkg.name} (${pkg.packageId}):`, err);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Save a bundle's content as a File record, deduplicating by hash.
|
|
148
|
+
*
|
|
149
|
+
* If a File with the same hash already exists for this bundle name,
|
|
150
|
+
* the existing record is reused — no new File is created. This eliminates
|
|
151
|
+
* the "new File record on every dev reload" bug.
|
|
152
|
+
*/
|
|
153
|
+
async function saveBundleFile(dataContext, content, name, _packageId) {
|
|
154
|
+
const fileHash = (0, file_types_1.computeFileHash)(content);
|
|
155
|
+
const filesTable = (0, files_1.Files)(dataContext);
|
|
156
|
+
// Check if a file with this hash already exists for this bundle name
|
|
157
|
+
const existing = await filesTable.findOne({ name, fileHash });
|
|
158
|
+
if (existing) {
|
|
159
|
+
return { fileId: existing.fileId, fileHash: existing.fileHash };
|
|
160
|
+
}
|
|
161
|
+
const fileId = (0, utils_1.newid)();
|
|
162
|
+
const metadata = {
|
|
163
|
+
fileId,
|
|
164
|
+
name,
|
|
165
|
+
fileSize: content.length,
|
|
166
|
+
mimeType: "application/javascript",
|
|
167
|
+
};
|
|
168
|
+
const saved = await filesTable.saveFile(metadata, content);
|
|
169
|
+
return { fileId: saved.fileId, fileHash: saved.fileHash };
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Read package metadata from a local package directory.
|
|
173
|
+
* Uses `deps.fs.readJson` instead of `require()` — no cache issues.
|
|
174
|
+
*/
|
|
175
|
+
async function getPackageInfo(deps, localPath) {
|
|
176
|
+
const pkgJsonPath = deps.resolvePath(localPath, "package.json");
|
|
177
|
+
const pkgJson = await deps.fs.readJson(pkgJsonPath);
|
|
178
|
+
const info = {
|
|
179
|
+
packageId: pkgJson.peers?.packageId,
|
|
180
|
+
name: pkgJson.name ?? "unknown",
|
|
181
|
+
version: pkgJson.version,
|
|
182
|
+
description: pkgJson.description,
|
|
183
|
+
};
|
|
184
|
+
// Try to get a richer description from README
|
|
185
|
+
const readmePath = deps.resolvePath(localPath, "README.md");
|
|
186
|
+
if (await deps.fs.exists(readmePath)) {
|
|
187
|
+
try {
|
|
188
|
+
const readme = await deps.fs.readFile(readmePath);
|
|
189
|
+
const firstParagraph = extractReadmeDescription(readme);
|
|
190
|
+
if (firstParagraph) {
|
|
191
|
+
info.description = firstParagraph;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
// README read failure is non-fatal
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
// Try to detect remoteRepo from git
|
|
199
|
+
try {
|
|
200
|
+
const { stdout } = await deps.shell.exec("git remote get-url origin", {
|
|
201
|
+
cwd: localPath,
|
|
202
|
+
});
|
|
203
|
+
const url = stdout.trim();
|
|
204
|
+
if (url) {
|
|
205
|
+
info.remoteRepo = url;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
// No git remote is fine
|
|
210
|
+
}
|
|
211
|
+
return info;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Resolve the local filesystem path for a package based on device vars.
|
|
215
|
+
* Awaits the loading promise to ensure the DB-stored value is available
|
|
216
|
+
* before reading. Falls back to `{packagesRootDir}`.
|
|
217
|
+
*/
|
|
218
|
+
async function getPackageLocalPath(packageId, dataContext, deps) {
|
|
219
|
+
const pvar = (0, persistent_vars_1.groupDeviceVar)(`packageLocalPath_${packageId}`, {
|
|
220
|
+
defaultValue: deps.packagesRootDir,
|
|
221
|
+
dataContext,
|
|
222
|
+
});
|
|
223
|
+
await pvar.loadingPromise;
|
|
224
|
+
return deps.resolvePath(pvar() ?? deps.packagesRootDir);
|
|
225
|
+
}
|
|
226
|
+
/** Extract the first non-heading paragraph from a README as a description. */
|
|
227
|
+
function extractReadmeDescription(readme) {
|
|
228
|
+
const lines = readme.split("\n");
|
|
229
|
+
let foundContent = false;
|
|
230
|
+
const descLines = [];
|
|
231
|
+
for (const line of lines) {
|
|
232
|
+
// Skip headings and blank lines at the start
|
|
233
|
+
if (!foundContent) {
|
|
234
|
+
if (line.startsWith("#") || line.trim() === "")
|
|
235
|
+
continue;
|
|
236
|
+
foundContent = true;
|
|
237
|
+
}
|
|
238
|
+
// Stop at the next heading or blank line after content
|
|
239
|
+
if (foundContent) {
|
|
240
|
+
if (line.startsWith("#") || (line.trim() === "" && descLines.length > 0))
|
|
241
|
+
break;
|
|
242
|
+
descLines.push(line.trim());
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
const desc = descLines.join(" ").trim();
|
|
246
|
+
return desc || undefined;
|
|
247
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|