@peers-app/peers-sdk 0.18.8 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +74 -1
  2. package/dist/data/files/file-read-stream.js +7 -0
  3. package/dist/data/files/file.types.d.ts +6 -0
  4. package/dist/data/files/file.types.js +18 -0
  5. package/dist/data/files/files.test.js +50 -7
  6. package/dist/data/package-version-resolver.test.js +1 -0
  7. package/dist/data/package-versions.d.ts +3 -0
  8. package/dist/data/package-versions.js +5 -0
  9. package/dist/data/packages.d.ts +6 -0
  10. package/dist/data/packages.js +8 -0
  11. package/dist/index.d.ts +1 -0
  12. package/dist/index.js +1 -0
  13. package/dist/package-installer/index.d.ts +10 -0
  14. package/dist/package-installer/index.js +26 -0
  15. package/dist/package-installer/package-author-signing.d.ts +48 -0
  16. package/dist/package-installer/package-author-signing.js +73 -0
  17. package/dist/package-installer/package-author-signing.test.d.ts +1 -0
  18. package/dist/package-installer/package-author-signing.test.js +189 -0
  19. package/dist/package-installer/package-cloner.d.ts +16 -0
  20. package/dist/package-installer/package-cloner.js +115 -0
  21. package/dist/package-installer/package-cloner.test.d.ts +1 -0
  22. package/dist/package-installer/package-cloner.test.js +276 -0
  23. package/dist/package-installer/package-creator.d.ts +22 -0
  24. package/dist/package-installer/package-creator.js +154 -0
  25. package/dist/package-installer/package-creator.test.d.ts +1 -0
  26. package/dist/package-installer/package-creator.test.js +354 -0
  27. package/dist/package-installer/package-installer.d.ts +32 -0
  28. package/dist/package-installer/package-installer.js +247 -0
  29. package/dist/package-installer/package-installer.test.d.ts +1 -0
  30. package/dist/package-installer/package-installer.test.js +666 -0
  31. package/dist/package-installer/package-propagation.d.ts +29 -0
  32. package/dist/package-installer/package-propagation.js +363 -0
  33. package/dist/package-installer/package-propagation.test.d.ts +1 -0
  34. package/dist/package-installer/package-propagation.test.js +1145 -0
  35. package/dist/package-installer/package-publisher.d.ts +50 -0
  36. package/dist/package-installer/package-publisher.js +67 -0
  37. package/dist/package-installer/package-publisher.test.d.ts +1 -0
  38. package/dist/package-installer/package-publisher.test.js +142 -0
  39. package/dist/package-installer/package-remote-checker.d.ts +54 -0
  40. package/dist/package-installer/package-remote-checker.js +186 -0
  41. package/dist/package-installer/package-remote-checker.test.d.ts +1 -0
  42. package/dist/package-installer/package-remote-checker.test.js +263 -0
  43. package/dist/package-installer/package-seed-installer.d.ts +45 -0
  44. package/dist/package-installer/package-seed-installer.js +108 -0
  45. package/dist/package-installer/package-seed-installer.test.d.ts +1 -0
  46. package/dist/package-installer/package-seed-installer.test.js +123 -0
  47. package/dist/package-installer/package-tarball.d.ts +35 -0
  48. package/dist/package-installer/package-tarball.js +57 -0
  49. package/dist/package-installer/package-tarball.test.d.ts +1 -0
  50. package/dist/package-installer/package-tarball.test.js +75 -0
  51. package/dist/package-installer/types.d.ts +110 -0
  52. package/dist/package-installer/types.js +2 -0
  53. package/dist/rpc-types.d.ts +14 -0
  54. package/dist/rpc-types.js +6 -0
  55. package/dist/system-ids.d.ts +1 -0
  56. package/dist/system-ids.js +2 -1
  57. package/package.json +3 -2
@@ -0,0 +1,154 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.nameToFolderSlug = nameToFolderSlug;
4
+ exports.ensureTemplateCache = ensureTemplateCache;
5
+ exports.createPackage = createPackage;
6
+ const packages_1 = require("../data/packages");
7
+ const persistent_vars_1 = require("../data/persistent-vars");
8
+ const keys_1 = require("../keys");
9
+ const system_ids_1 = require("../system-ids");
10
+ const utils_1 = require("../utils");
11
+ const package_author_signing_1 = require("./package-author-signing");
12
+ const package_installer_1 = require("./package-installer");
13
+ const DEFAULT_TEMPLATE_REPO = "https://github.com/peers-app/peers-package-template.git";
14
+ const TEMPLATE_CACHE_DIR = ".peers-package-template";
15
+ /**
16
+ * Convert a human-readable package name to a filesystem-safe folder slug.
17
+ * "My Widget" → "my-widget", "camelCase Thing" → "camel-case-thing"
18
+ */
19
+ function nameToFolderSlug(name) {
20
+ let slug = name.replace(/[^a-zA-Z0-9]/g, " ");
21
+ slug = slug.replace(/\s+/g, " ").trim();
22
+ return (0, utils_1.camelCaseToHyphens)(slug);
23
+ }
24
+ /**
25
+ * Ensure the package template is available locally.
26
+ * On first call, clones the template repo into a dot-prefixed cache directory.
27
+ * On subsequent calls, attempts a `git pull` to refresh (continues silently if offline).
28
+ * @returns The path to the cached template directory.
29
+ */
30
+ async function ensureTemplateCache(deps, templateRepo) {
31
+ const cachePath = deps.resolvePath(deps.packagesRootDir, TEMPLATE_CACHE_DIR);
32
+ if (await deps.fs.exists(cachePath)) {
33
+ try {
34
+ await deps.shell.exec("git pull", { cwd: cachePath });
35
+ }
36
+ catch {
37
+ // Offline or remote unreachable — continue with stale cache
38
+ }
39
+ }
40
+ else {
41
+ await deps.shell.exec(`git clone ${templateRepo} ${cachePath}`);
42
+ }
43
+ return cachePath;
44
+ }
45
+ /**
46
+ * Create a new Peers package from the package template.
47
+ *
48
+ * Clones the template repo, patches IDs and metadata, runs npm install + build,
49
+ * initializes a fresh git repo, then delegates to {@link installPackageFromBundles}
50
+ * to register the dev bundle in the database.
51
+ */
52
+ async function createPackage(dataContext, deps, opts) {
53
+ const location = opts.location ?? deps.resolvePath(deps.packagesRootDir, nameToFolderSlug(opts.name));
54
+ // If directory already exists, treat as an import rather than a fresh create
55
+ if (await deps.fs.exists(location)) {
56
+ const info = await (0, package_installer_1.getPackageInfo)(deps, location);
57
+ if (info.packageId) {
58
+ const result = await (0, package_installer_1.installPackageFromBundles)(dataContext, deps, info.packageId, {
59
+ localPath: location,
60
+ });
61
+ return {
62
+ packageId: info.packageId,
63
+ localPath: location,
64
+ packageVersion: result.packageVersion,
65
+ };
66
+ }
67
+ throw new Error(`Directory already exists at ${location} but does not contain a valid Peers package`);
68
+ }
69
+ // Generate fresh IDs
70
+ const packageId = (0, utils_1.newid)();
71
+ const contractId = (0, utils_1.newid)();
72
+ const appScreenId = (0, utils_1.newid)();
73
+ const templateRepo = opts.templateRepo ?? DEFAULT_TEMPLATE_REPO;
74
+ // Get template contents into location
75
+ if (opts.templateRepo && opts.templateRepo !== DEFAULT_TEMPLATE_REPO) {
76
+ // Custom template — clone directly (no caching)
77
+ await deps.shell.exec(`git clone ${templateRepo} ${location}`);
78
+ }
79
+ else {
80
+ // Default template — use local cache for speed and offline support
81
+ const cachePath = await ensureTemplateCache(deps, templateRepo);
82
+ await deps.shell.exec(`cp -r ${cachePath}/. ${location}`);
83
+ }
84
+ // Patch package.json
85
+ const packageJsonPath = deps.resolvePath(location, "package.json");
86
+ const packageObj = await deps.fs.readJson(packageJsonPath);
87
+ packageObj.peers = packageObj.peers || {};
88
+ packageObj.peers.packageId = packageId;
89
+ packageObj.name = (0, utils_1.camelCaseToHyphens)(opts.name);
90
+ if (opts.author) {
91
+ packageObj.author = opts.author;
92
+ }
93
+ delete packageObj.repository;
94
+ await deps.fs.writeFile(packageJsonPath, JSON.stringify(packageObj, null, 2));
95
+ // Patch src/consts.ts placeholders
96
+ const constsPath = deps.resolvePath(location, "src", "consts.ts");
97
+ if (await deps.fs.exists(constsPath)) {
98
+ let consts = await deps.fs.readFile(constsPath);
99
+ consts = consts
100
+ .replace("<package-name>", opts.name)
101
+ .replace("<package-id>", packageId)
102
+ .replace("<app-screen-id>", appScreenId)
103
+ .replace("<contract-id>", contractId);
104
+ await deps.fs.writeFile(constsPath, consts);
105
+ }
106
+ // Initialize as a new project: remove template git history, install, build, fresh git
107
+ await deps.shell.exec("rm -rf .git", { cwd: location });
108
+ await deps.shell.exec("npm install", { cwd: location });
109
+ await deps.shell.exec("npm run build", { cwd: location });
110
+ await deps.shell.exec("git init", { cwd: location });
111
+ await deps.shell.exec("git add .", { cwd: location });
112
+ await deps.shell.exec('git commit -m "Initial commit"', { cwd: location });
113
+ // Generate signing keypair and store secret in user's personal group
114
+ let publishPublicKey = "";
115
+ if (opts.personalContext) {
116
+ const keys = (0, keys_1.newKeys)();
117
+ publishPublicKey = keys.publicKey;
118
+ const keyName = `${package_author_signing_1.PACKAGE_SIGNING_KEY_PREFIX}${packageId}`;
119
+ await (0, persistent_vars_1.PersistentVars)(opts.personalContext).save({
120
+ persistentVarId: (0, utils_1.deterministicPvarId)(keyName),
121
+ name: keyName,
122
+ scope: "user",
123
+ value: { value: keys.secretKey },
124
+ isSecret: true,
125
+ description: `Signing key for package "${opts.name}"`,
126
+ modifiedAt: Date.now(),
127
+ });
128
+ }
129
+ // Register the package record
130
+ await (0, packages_1.Packages)(dataContext).signAndSave({
131
+ packageId,
132
+ name: opts.name,
133
+ description: "",
134
+ createdBy: system_ids_1.peersRootUserId,
135
+ disabled: false,
136
+ publishPublicKey,
137
+ signature: "",
138
+ }, { restoreIfDeleted: true, saveAsSnapshot: true });
139
+ // Set the packageLocalPath device var
140
+ const pvar = (0, persistent_vars_1.groupDeviceVar)(`packageLocalPath_${packageId}`, {
141
+ defaultValue: deps.packagesRootDir,
142
+ dataContext,
143
+ });
144
+ pvar(location);
145
+ // Install the dev bundle
146
+ const result = await (0, package_installer_1.installPackageFromBundles)(dataContext, deps, packageId, {
147
+ localPath: location,
148
+ });
149
+ return {
150
+ packageId,
151
+ localPath: location,
152
+ packageVersion: result.packageVersion,
153
+ };
154
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -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>;