@peers-app/peers-sdk 0.18.6 → 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,189 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const keys_1 = require("../keys");
4
+ const utils_1 = require("../utils");
5
+ const package_author_signing_1 = require("./package-author-signing");
6
+ function makePV(overrides) {
7
+ return {
8
+ packageId: (0, utils_1.newid)(),
9
+ packageVersionId: (0, utils_1.newid)(),
10
+ version: "1.2.3",
11
+ versionTag: "stable",
12
+ packageBundleFileHash: "abc123hash",
13
+ routesBundleFileHash: "routes456hash",
14
+ uiBundleFileHash: "ui789hash",
15
+ ...overrides,
16
+ };
17
+ }
18
+ describe("package-author-signing", () => {
19
+ const keys = (0, keys_1.newKeys)();
20
+ describe("PACKAGE_SIGNING_KEY_PREFIX", () => {
21
+ it("has the expected value", () => {
22
+ expect(package_author_signing_1.PACKAGE_SIGNING_KEY_PREFIX).toBe("packageSigningKey_");
23
+ });
24
+ });
25
+ describe("buildAuthorSignedPayload", () => {
26
+ it("extracts the correct fields from a PV", () => {
27
+ const pv = makePV();
28
+ const payload = (0, package_author_signing_1.buildAuthorSignedPayload)(pv, keys.publicKey);
29
+ expect(payload).toEqual({
30
+ packageId: pv.packageId,
31
+ packageVersionId: pv.packageVersionId,
32
+ version: "1.2.3",
33
+ versionTag: "stable",
34
+ packageBundleFileHash: "abc123hash",
35
+ routesBundleFileHash: "routes456hash",
36
+ uiBundleFileHash: "ui789hash",
37
+ publicKey: keys.publicKey,
38
+ });
39
+ });
40
+ it("omits optional hash fields when undefined", () => {
41
+ const pv = makePV({ routesBundleFileHash: undefined, uiBundleFileHash: undefined });
42
+ const payload = (0, package_author_signing_1.buildAuthorSignedPayload)(pv, keys.publicKey);
43
+ expect(payload).not.toHaveProperty("routesBundleFileHash");
44
+ expect(payload).not.toHaveProperty("uiBundleFileHash");
45
+ expect(payload.packageBundleFileHash).toBe("abc123hash");
46
+ });
47
+ it("omits uiBundleFileHash but includes routesBundleFileHash when only ui is undefined", () => {
48
+ const pv = makePV({ uiBundleFileHash: undefined });
49
+ const payload = (0, package_author_signing_1.buildAuthorSignedPayload)(pv, keys.publicKey);
50
+ expect(payload.routesBundleFileHash).toBe("routes456hash");
51
+ expect(payload).not.toHaveProperty("uiBundleFileHash");
52
+ });
53
+ it("treats empty string versionTag the same as undefined", () => {
54
+ const pv = makePV({ versionTag: undefined });
55
+ const payload = (0, package_author_signing_1.buildAuthorSignedPayload)(pv, keys.publicKey);
56
+ expect(payload.versionTag).toBe("");
57
+ });
58
+ });
59
+ describe("signPackageAuthor + verifyPackageAuthorSignature round-trip", () => {
60
+ it("produces a valid signature that verifies", () => {
61
+ const pv = makePV();
62
+ const signature = (0, package_author_signing_1.signPackageAuthor)(pv, keys.secretKey);
63
+ expect(typeof signature).toBe("string");
64
+ expect(signature.length).toBeGreaterThan(0);
65
+ const result = (0, package_author_signing_1.verifyPackageAuthorSignature)({ ...pv, packageAuthorSignature: signature }, keys.publicKey);
66
+ expect(result.valid).toBe(true);
67
+ expect(result.publicKey).toBe(keys.publicKey);
68
+ });
69
+ it("works with only the required bundle hash (no routes or ui)", () => {
70
+ const pv = makePV({ routesBundleFileHash: undefined, uiBundleFileHash: undefined });
71
+ const signature = (0, package_author_signing_1.signPackageAuthor)(pv, keys.secretKey);
72
+ const result = (0, package_author_signing_1.verifyPackageAuthorSignature)({ ...pv, packageAuthorSignature: signature }, keys.publicKey);
73
+ expect(result.valid).toBe(true);
74
+ });
75
+ it("works with undefined versionTag", () => {
76
+ const pv = makePV({ versionTag: undefined });
77
+ const signature = (0, package_author_signing_1.signPackageAuthor)(pv, keys.secretKey);
78
+ const result = (0, package_author_signing_1.verifyPackageAuthorSignature)({ ...pv, packageAuthorSignature: signature }, keys.publicKey);
79
+ expect(result.valid).toBe(true);
80
+ });
81
+ });
82
+ describe("verification fails on tampered fields", () => {
83
+ const pv = makePV();
84
+ let signature;
85
+ beforeAll(() => {
86
+ signature = (0, package_author_signing_1.signPackageAuthor)(pv, keys.secretKey);
87
+ });
88
+ it("fails when packageBundleFileHash is changed", () => {
89
+ const tampered = {
90
+ ...pv,
91
+ packageBundleFileHash: "tampered",
92
+ packageAuthorSignature: signature,
93
+ };
94
+ const result = (0, package_author_signing_1.verifyPackageAuthorSignature)(tampered, keys.publicKey);
95
+ expect(result.valid).toBe(false);
96
+ });
97
+ it("fails when version is changed", () => {
98
+ const tampered = { ...pv, version: "9.9.9", packageAuthorSignature: signature };
99
+ const result = (0, package_author_signing_1.verifyPackageAuthorSignature)(tampered, keys.publicKey);
100
+ expect(result.valid).toBe(false);
101
+ });
102
+ it("fails when versionTag is changed", () => {
103
+ const tampered = { ...pv, versionTag: "beta", packageAuthorSignature: signature };
104
+ const result = (0, package_author_signing_1.verifyPackageAuthorSignature)(tampered, keys.publicKey);
105
+ expect(result.valid).toBe(false);
106
+ });
107
+ it("fails when packageId is changed", () => {
108
+ const tampered = { ...pv, packageId: (0, utils_1.newid)(), packageAuthorSignature: signature };
109
+ const result = (0, package_author_signing_1.verifyPackageAuthorSignature)(tampered, keys.publicKey);
110
+ expect(result.valid).toBe(false);
111
+ });
112
+ it("fails when packageVersionId is changed", () => {
113
+ const tampered = { ...pv, packageVersionId: (0, utils_1.newid)(), packageAuthorSignature: signature };
114
+ const result = (0, package_author_signing_1.verifyPackageAuthorSignature)(tampered, keys.publicKey);
115
+ expect(result.valid).toBe(false);
116
+ });
117
+ it("fails when routesBundleFileHash is changed", () => {
118
+ const tampered = {
119
+ ...pv,
120
+ routesBundleFileHash: "tampered",
121
+ packageAuthorSignature: signature,
122
+ };
123
+ const result = (0, package_author_signing_1.verifyPackageAuthorSignature)(tampered, keys.publicKey);
124
+ expect(result.valid).toBe(false);
125
+ });
126
+ it("fails when uiBundleFileHash is changed", () => {
127
+ const tampered = { ...pv, uiBundleFileHash: "tampered", packageAuthorSignature: signature };
128
+ const result = (0, package_author_signing_1.verifyPackageAuthorSignature)(tampered, keys.publicKey);
129
+ expect(result.valid).toBe(false);
130
+ });
131
+ });
132
+ describe("verification fails with wrong key", () => {
133
+ it("rejects when verified against a different key", () => {
134
+ const pv = makePV();
135
+ const signature = (0, package_author_signing_1.signPackageAuthor)(pv, keys.secretKey);
136
+ const otherKeys = (0, keys_1.newKeys)();
137
+ const result = (0, package_author_signing_1.verifyPackageAuthorSignature)({ ...pv, packageAuthorSignature: signature }, otherKeys.publicKey);
138
+ expect(result.valid).toBe(false);
139
+ });
140
+ });
141
+ describe("TOFU enforcement via expectedPublicKey", () => {
142
+ it("succeeds when expectedPublicKey matches the signer", () => {
143
+ const pv = makePV();
144
+ const signature = (0, package_author_signing_1.signPackageAuthor)(pv, keys.secretKey);
145
+ const result = (0, package_author_signing_1.verifyPackageAuthorSignature)({ ...pv, packageAuthorSignature: signature }, keys.publicKey);
146
+ expect(result.valid).toBe(true);
147
+ expect(result.publicKey).toBe(keys.publicKey);
148
+ });
149
+ it("fails when expectedPublicKey does not match the signer", () => {
150
+ const pv = makePV();
151
+ const signature = (0, package_author_signing_1.signPackageAuthor)(pv, keys.secretKey);
152
+ const differentKey = (0, keys_1.newKeys)().publicKey;
153
+ const result = (0, package_author_signing_1.verifyPackageAuthorSignature)({ ...pv, packageAuthorSignature: signature }, differentKey);
154
+ expect(result.valid).toBe(false);
155
+ });
156
+ });
157
+ describe("edge cases", () => {
158
+ it("returns invalid when packageAuthorSignature is undefined", () => {
159
+ const pv = makePV();
160
+ const result = (0, package_author_signing_1.verifyPackageAuthorSignature)({ ...pv, packageAuthorSignature: undefined }, keys.publicKey);
161
+ expect(result.valid).toBe(false);
162
+ });
163
+ it("returns invalid when packageAuthorSignature is malformed", () => {
164
+ const pv = makePV();
165
+ const result = (0, package_author_signing_1.verifyPackageAuthorSignature)({ ...pv, packageAuthorSignature: "not-a-valid-signature!!!" }, keys.publicKey);
166
+ expect(result.valid).toBe(false);
167
+ });
168
+ it("returns invalid when expectedPublicKey is malformed", () => {
169
+ const pv = makePV();
170
+ const signature = (0, package_author_signing_1.signPackageAuthor)(pv, keys.secretKey);
171
+ const result = (0, package_author_signing_1.verifyPackageAuthorSignature)({ ...pv, packageAuthorSignature: signature }, "not-a-valid-key");
172
+ expect(result.valid).toBe(false);
173
+ });
174
+ it("signature is deterministic for same inputs", () => {
175
+ const pv = makePV();
176
+ const sig1 = (0, package_author_signing_1.signPackageAuthor)(pv, keys.secretKey);
177
+ const sig2 = (0, package_author_signing_1.signPackageAuthor)(pv, keys.secretKey);
178
+ expect(sig1).toBe(sig2);
179
+ });
180
+ it("derives the correct public key from the secret key", () => {
181
+ const pv = makePV();
182
+ const signature = (0, package_author_signing_1.signPackageAuthor)(pv, keys.secretKey);
183
+ const derived = (0, keys_1.hydrateKeys)(keys.secretKey);
184
+ expect(derived.publicKey).toBe(keys.publicKey);
185
+ const result = (0, package_author_signing_1.verifyPackageAuthorSignature)({ ...pv, packageAuthorSignature: signature }, derived.publicKey);
186
+ expect(result.valid).toBe(true);
187
+ });
188
+ });
189
+ });
@@ -0,0 +1,16 @@
1
+ import type { DataContext } from "../context/data-context";
2
+ import type { IClonePackageOpts, IClonePackageResult, IPackageInstallerDeps } from "./types";
3
+ /**
4
+ * Derive a filesystem-safe slug from a git remote URL.
5
+ * "https://github.com/peers-app/groceries.git" → "groceries"
6
+ */
7
+ export declare function deriveRepoSlug(url: string): string;
8
+ /**
9
+ * Clone a remote git repository containing a Peers package, install dependencies,
10
+ * build it, and register the dev bundle in the database.
11
+ *
12
+ * Prerequisites (git, npm) are validated up front with clear error messages.
13
+ * If the target directory already exists and contains a valid package, delegates
14
+ * to {@link installPackageFromBundles} instead of re-cloning.
15
+ */
16
+ export declare function clonePackage(dataContext: DataContext, deps: IPackageInstallerDeps, opts: IClonePackageOpts): Promise<IClonePackageResult>;
@@ -0,0 +1,115 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.deriveRepoSlug = deriveRepoSlug;
4
+ exports.clonePackage = clonePackage;
5
+ const packages_1 = require("../data/packages");
6
+ const persistent_vars_1 = require("../data/persistent-vars");
7
+ const system_ids_1 = require("../system-ids");
8
+ const utils_1 = require("../utils");
9
+ const package_installer_1 = require("./package-installer");
10
+ /**
11
+ * Derive a filesystem-safe slug from a git remote URL.
12
+ * "https://github.com/peers-app/groceries.git" → "groceries"
13
+ */
14
+ function deriveRepoSlug(url) {
15
+ const lastSegment = url.split("/").pop() ?? "";
16
+ return lastSegment.replace(/\.git$/, "");
17
+ }
18
+ /**
19
+ * Determine whether a package from this remote should start disabled.
20
+ * Packages from the peers-app org are trusted and enabled by default.
21
+ */
22
+ function autoDetectDisabled(remoteRepo) {
23
+ return !remoteRepo.toLowerCase().startsWith("https://github.com/peers-app/");
24
+ }
25
+ /**
26
+ * Clone a remote git repository containing a Peers package, install dependencies,
27
+ * build it, and register the dev bundle in the database.
28
+ *
29
+ * Prerequisites (git, npm) are validated up front with clear error messages.
30
+ * If the target directory already exists and contains a valid package, delegates
31
+ * to {@link installPackageFromBundles} instead of re-cloning.
32
+ */
33
+ async function clonePackage(dataContext, deps, opts) {
34
+ // 1. Validate prerequisites
35
+ try {
36
+ await deps.shell.exec("git --version");
37
+ }
38
+ catch {
39
+ throw new Error("git is not installed. Please install git to add remote packages.");
40
+ }
41
+ try {
42
+ await deps.shell.exec("npm --version");
43
+ }
44
+ catch {
45
+ throw new Error("npm is not installed. Please install Node.js (which includes npm) to add remote packages.");
46
+ }
47
+ // 2. Resolve location
48
+ const location = opts.location ?? deps.resolvePath(deps.packagesRootDir, deriveRepoSlug(opts.remoteRepo));
49
+ // 3. Check if directory already exists
50
+ if (await deps.fs.exists(location)) {
51
+ const info = await (0, package_installer_1.getPackageInfo)(deps, location);
52
+ if (info.packageId && (0, utils_1.isid)(info.packageId)) {
53
+ const result = await (0, package_installer_1.installPackageFromBundles)(dataContext, deps, info.packageId, {
54
+ localPath: location,
55
+ });
56
+ return {
57
+ packageId: info.packageId,
58
+ localPath: location,
59
+ remoteRepo: opts.remoteRepo,
60
+ packageVersion: result.packageVersion,
61
+ };
62
+ }
63
+ throw new Error(`Directory already exists at ${location} but does not contain a valid Peers package (missing peers.packageId)`);
64
+ }
65
+ // 4. Clone
66
+ await deps.shell.exec(`git clone ${opts.remoteRepo} ${location}`);
67
+ // 5. Install + build (unless skipBuild)
68
+ if (!opts.skipBuild) {
69
+ await deps.shell.exec("npm install", { cwd: location });
70
+ await deps.shell.exec("npm run build", { cwd: location });
71
+ }
72
+ // 6. Read package info and resolve packageId
73
+ const info = await (0, package_installer_1.getPackageInfo)(deps, location);
74
+ let packageId;
75
+ if (info.packageId && (0, utils_1.isid)(info.packageId)) {
76
+ packageId = info.packageId;
77
+ }
78
+ else {
79
+ // No valid packageId in package.json — generate one and patch it in
80
+ packageId = (0, utils_1.newid)();
81
+ const packageJsonPath = deps.resolvePath(location, "package.json");
82
+ const packageObj = await deps.fs.readJson(packageJsonPath);
83
+ packageObj.peers = packageObj.peers || {};
84
+ packageObj.peers.packageId = packageId;
85
+ await deps.fs.writeFile(packageJsonPath, JSON.stringify(packageObj, null, 2));
86
+ }
87
+ // 7. Register package record
88
+ const disabled = opts.disabled ?? autoDetectDisabled(opts.remoteRepo);
89
+ await (0, packages_1.Packages)(dataContext).signAndSave({
90
+ packageId,
91
+ name: info.name,
92
+ description: info.description ?? "",
93
+ createdBy: system_ids_1.peersRootUserId,
94
+ disabled,
95
+ remoteRepo: opts.remoteRepo,
96
+ publishPublicKey: "",
97
+ signature: "",
98
+ }, { restoreIfDeleted: true, saveAsSnapshot: true });
99
+ // Set the packageLocalPath device var
100
+ const pvar = (0, persistent_vars_1.groupDeviceVar)(`packageLocalPath_${packageId}`, {
101
+ defaultValue: deps.packagesRootDir,
102
+ dataContext,
103
+ });
104
+ pvar(location);
105
+ // 8. Install dev bundle
106
+ const result = await (0, package_installer_1.installPackageFromBundles)(dataContext, deps, packageId, {
107
+ localPath: location,
108
+ });
109
+ return {
110
+ packageId,
111
+ localPath: location,
112
+ remoteRepo: opts.remoteRepo,
113
+ packageVersion: result.packageVersion,
114
+ };
115
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,276 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const package_cloner_1 = require("./package-cloner");
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
+ jest.mock("../data/persistent-vars", () => ({
13
+ groupDeviceVar: (...args) => mockGroupDeviceVar(...args),
14
+ }));
15
+ const mockInstallPackageFromBundles = jest.fn();
16
+ const mockGetPackageInfo = jest.fn();
17
+ jest.mock("./package-installer", () => ({
18
+ installPackageFromBundles: (...args) => mockInstallPackageFromBundles(...args),
19
+ getPackageInfo: (...args) => mockGetPackageInfo(...args),
20
+ }));
21
+ // --- Test utilities ---
22
+ function makeMockDeps(shellExec) {
23
+ return {
24
+ fs: {
25
+ readFile: jest.fn(async () => ""),
26
+ writeFile: jest.fn(async () => { }),
27
+ exists: jest.fn(async () => false),
28
+ readJson: jest.fn(async () => ({})),
29
+ },
30
+ shell: {
31
+ exec: shellExec ?? jest.fn(async () => ({ stdout: "", stderr: "" })),
32
+ },
33
+ resolvePath: (...segments) => segments.join("/"),
34
+ packagesRootDir: "/packages",
35
+ };
36
+ }
37
+ function mockDataContext() {
38
+ return {
39
+ packageLoader: {
40
+ loadPackage: jest.fn().mockResolvedValue(undefined),
41
+ },
42
+ };
43
+ }
44
+ // --- Tests ---
45
+ beforeEach(() => {
46
+ jest.clearAllMocks();
47
+ });
48
+ describe("deriveRepoSlug", () => {
49
+ it("extracts slug from https URL with .git suffix", () => {
50
+ expect((0, package_cloner_1.deriveRepoSlug)("https://github.com/peers-app/groceries.git")).toBe("groceries");
51
+ });
52
+ it("extracts slug from https URL without .git suffix", () => {
53
+ expect((0, package_cloner_1.deriveRepoSlug)("https://github.com/user/my-pkg")).toBe("my-pkg");
54
+ });
55
+ it("handles SSH-style URLs", () => {
56
+ expect((0, package_cloner_1.deriveRepoSlug)("git@github.com:user/some-repo.git")).toBe("some-repo");
57
+ });
58
+ it("returns empty string for empty URL", () => {
59
+ expect((0, package_cloner_1.deriveRepoSlug)("")).toBe("");
60
+ });
61
+ });
62
+ describe("clonePackage", () => {
63
+ it("throws when git is not installed", async () => {
64
+ const shellExec = jest.fn(async (cmd) => {
65
+ if (cmd === "git --version")
66
+ throw new Error("not found");
67
+ return { stdout: "", stderr: "" };
68
+ });
69
+ const deps = makeMockDeps(shellExec);
70
+ const dc = mockDataContext();
71
+ await expect((0, package_cloner_1.clonePackage)(dc, deps, { remoteRepo: "https://github.com/peers-app/test.git" })).rejects.toThrow("git is not installed");
72
+ });
73
+ it("throws when npm is not installed", async () => {
74
+ const shellExec = jest.fn(async (cmd) => {
75
+ if (cmd === "npm --version")
76
+ throw new Error("not found");
77
+ return { stdout: "", stderr: "" };
78
+ });
79
+ const deps = makeMockDeps(shellExec);
80
+ const dc = mockDataContext();
81
+ await expect((0, package_cloner_1.clonePackage)(dc, deps, { remoteRepo: "https://github.com/peers-app/test.git" })).rejects.toThrow("npm is not installed");
82
+ });
83
+ it("clones, installs, builds, and registers a package with valid packageId", async () => {
84
+ const deps = makeMockDeps();
85
+ const dc = mockDataContext();
86
+ // Directory doesn't exist
87
+ deps.fs.exists.mockResolvedValue(false);
88
+ // After clone, getPackageInfo returns a valid package
89
+ mockGetPackageInfo.mockResolvedValue({
90
+ packageId: "00mnkoyo7mizav6tseubjzr3a",
91
+ name: "groceries",
92
+ description: "Shopping list app",
93
+ });
94
+ const pvarSetter = jest.fn();
95
+ mockGroupDeviceVar.mockReturnValue(pvarSetter);
96
+ const mockPv = { packageVersionId: "pv001" };
97
+ mockInstallPackageFromBundles.mockResolvedValue({
98
+ packageVersion: mockPv,
99
+ loaded: undefined,
100
+ unchanged: false,
101
+ });
102
+ const result = await (0, package_cloner_1.clonePackage)(dc, deps, {
103
+ remoteRepo: "https://github.com/peers-app/groceries.git",
104
+ });
105
+ // Verify clone
106
+ expect(deps.shell.exec).toHaveBeenCalledWith("git clone https://github.com/peers-app/groceries.git /packages/groceries");
107
+ // Verify install + build
108
+ expect(deps.shell.exec).toHaveBeenCalledWith("npm install", { cwd: "/packages/groceries" });
109
+ expect(deps.shell.exec).toHaveBeenCalledWith("npm run build", { cwd: "/packages/groceries" });
110
+ // Verify package record was created (peers-app org = not disabled)
111
+ expect(mockSignAndSavePkg).toHaveBeenCalledWith(expect.objectContaining({
112
+ packageId: "00mnkoyo7mizav6tseubjzr3a",
113
+ name: "groceries",
114
+ description: "Shopping list app",
115
+ disabled: false,
116
+ remoteRepo: "https://github.com/peers-app/groceries.git",
117
+ }), { restoreIfDeleted: true, saveAsSnapshot: true });
118
+ // Verify device var
119
+ expect(pvarSetter).toHaveBeenCalledWith("/packages/groceries");
120
+ // Verify install
121
+ expect(mockInstallPackageFromBundles).toHaveBeenCalledWith(dc, deps, "00mnkoyo7mizav6tseubjzr3a", { localPath: "/packages/groceries" });
122
+ expect(result.packageId).toBe("00mnkoyo7mizav6tseubjzr3a");
123
+ expect(result.localPath).toBe("/packages/groceries");
124
+ expect(result.remoteRepo).toBe("https://github.com/peers-app/groceries.git");
125
+ expect(result.packageVersion).toBe(mockPv);
126
+ });
127
+ it("auto-disables packages from non-peers-app orgs", async () => {
128
+ const deps = makeMockDeps();
129
+ const dc = mockDataContext();
130
+ deps.fs.exists.mockResolvedValue(false);
131
+ mockGetPackageInfo.mockResolvedValue({
132
+ packageId: "00mnkoyo7mizav6tseubjzr3b",
133
+ name: "external-pkg",
134
+ });
135
+ mockGroupDeviceVar.mockReturnValue(jest.fn());
136
+ mockInstallPackageFromBundles.mockResolvedValue({
137
+ packageVersion: { packageVersionId: "pv" },
138
+ loaded: undefined,
139
+ unchanged: false,
140
+ });
141
+ await (0, package_cloner_1.clonePackage)(dc, deps, {
142
+ remoteRepo: "https://github.com/some-user/external-pkg.git",
143
+ });
144
+ expect(mockSignAndSavePkg).toHaveBeenCalledWith(expect.objectContaining({ disabled: true }), expect.any(Object));
145
+ });
146
+ it("respects explicit disabled override", async () => {
147
+ const deps = makeMockDeps();
148
+ const dc = mockDataContext();
149
+ deps.fs.exists.mockResolvedValue(false);
150
+ mockGetPackageInfo.mockResolvedValue({
151
+ packageId: "00mnkoyo7mizav6tseubjzr3c",
152
+ name: "forced-enabled",
153
+ });
154
+ mockGroupDeviceVar.mockReturnValue(jest.fn());
155
+ mockInstallPackageFromBundles.mockResolvedValue({
156
+ packageVersion: { packageVersionId: "pv" },
157
+ loaded: undefined,
158
+ unchanged: false,
159
+ });
160
+ // External repo but explicitly enabled
161
+ await (0, package_cloner_1.clonePackage)(dc, deps, {
162
+ remoteRepo: "https://github.com/external/pkg.git",
163
+ disabled: false,
164
+ });
165
+ expect(mockSignAndSavePkg).toHaveBeenCalledWith(expect.objectContaining({ disabled: false }), expect.any(Object));
166
+ });
167
+ it("skips build when skipBuild is true", async () => {
168
+ const deps = makeMockDeps();
169
+ const dc = mockDataContext();
170
+ deps.fs.exists.mockResolvedValue(false);
171
+ mockGetPackageInfo.mockResolvedValue({
172
+ packageId: "00mnkoyo7mizav6tseubjzr3d",
173
+ name: "prebuilt",
174
+ });
175
+ mockGroupDeviceVar.mockReturnValue(jest.fn());
176
+ mockInstallPackageFromBundles.mockResolvedValue({
177
+ packageVersion: { packageVersionId: "pv" },
178
+ loaded: undefined,
179
+ unchanged: false,
180
+ });
181
+ await (0, package_cloner_1.clonePackage)(dc, deps, {
182
+ remoteRepo: "https://github.com/peers-app/prebuilt.git",
183
+ skipBuild: true,
184
+ });
185
+ const execCalls = deps.shell.exec.mock.calls.map((c) => c[0]);
186
+ expect(execCalls).toContain("git --version");
187
+ expect(execCalls).toContain("npm --version");
188
+ expect(execCalls).toContain("git clone https://github.com/peers-app/prebuilt.git /packages/prebuilt");
189
+ expect(execCalls).not.toContain("npm install");
190
+ expect(execCalls).not.toContain("npm run build");
191
+ });
192
+ it("generates packageId and patches package.json when repo has no valid ID", async () => {
193
+ const deps = makeMockDeps();
194
+ const dc = mockDataContext();
195
+ deps.fs.exists.mockResolvedValue(false);
196
+ // No valid packageId
197
+ mockGetPackageInfo.mockResolvedValue({
198
+ name: "no-id-package",
199
+ description: "A package without peers config",
200
+ });
201
+ // readJson returns a package.json without peers.packageId
202
+ deps.fs.readJson.mockResolvedValue({
203
+ name: "no-id-package",
204
+ version: "1.0.0",
205
+ });
206
+ mockGroupDeviceVar.mockReturnValue(jest.fn());
207
+ mockInstallPackageFromBundles.mockResolvedValue({
208
+ packageVersion: { packageVersionId: "pv" },
209
+ loaded: undefined,
210
+ unchanged: false,
211
+ });
212
+ const result = await (0, package_cloner_1.clonePackage)(dc, deps, {
213
+ remoteRepo: "https://github.com/peers-app/no-id-package.git",
214
+ });
215
+ // Should have written a patched package.json
216
+ expect(deps.fs.writeFile).toHaveBeenCalledWith("/packages/no-id-package/package.json", expect.any(String));
217
+ const patchedJson = JSON.parse(deps.fs.writeFile.mock.calls[0][1]);
218
+ expect(patchedJson.peers.packageId).toMatch(/^[a-z0-9]{25}$/);
219
+ // Result should use the generated ID
220
+ expect(result.packageId).toMatch(/^[a-z0-9]{25}$/);
221
+ });
222
+ it("delegates to installPackageFromBundles when directory already exists with valid package", async () => {
223
+ const deps = makeMockDeps();
224
+ const dc = mockDataContext();
225
+ // Location exists
226
+ deps.fs.exists.mockResolvedValue(true);
227
+ mockGetPackageInfo.mockResolvedValue({
228
+ packageId: "00mnkoyo7mizav6tseubjzr3e",
229
+ name: "existing",
230
+ });
231
+ const mockPv = { packageVersionId: "pvexist" };
232
+ mockInstallPackageFromBundles.mockResolvedValue({
233
+ packageVersion: mockPv,
234
+ loaded: undefined,
235
+ unchanged: true,
236
+ });
237
+ const result = await (0, package_cloner_1.clonePackage)(dc, deps, {
238
+ remoteRepo: "https://github.com/peers-app/existing.git",
239
+ });
240
+ // Should NOT clone or build
241
+ const execCalls = deps.shell.exec.mock.calls.map((c) => c[0]);
242
+ expect(execCalls).not.toContain(expect.stringContaining("git clone"));
243
+ expect(execCalls).not.toContain("npm install");
244
+ // Should delegate
245
+ expect(mockInstallPackageFromBundles).toHaveBeenCalled();
246
+ expect(result.packageId).toBe("00mnkoyo7mizav6tseubjzr3e");
247
+ });
248
+ it("throws when directory exists but has no valid packageId", async () => {
249
+ const deps = makeMockDeps();
250
+ const dc = mockDataContext();
251
+ deps.fs.exists.mockResolvedValue(true);
252
+ mockGetPackageInfo.mockResolvedValue({ name: "invalid-pkg" });
253
+ await expect((0, package_cloner_1.clonePackage)(dc, deps, { remoteRepo: "https://github.com/user/invalid-pkg.git" })).rejects.toThrow("does not contain a valid Peers package");
254
+ });
255
+ it("uses custom location when provided", async () => {
256
+ const deps = makeMockDeps();
257
+ const dc = mockDataContext();
258
+ deps.fs.exists.mockResolvedValue(false);
259
+ mockGetPackageInfo.mockResolvedValue({
260
+ packageId: "00mnkoyo7mizav6tseubjzr3f",
261
+ name: "custom-loc",
262
+ });
263
+ mockGroupDeviceVar.mockReturnValue(jest.fn());
264
+ mockInstallPackageFromBundles.mockResolvedValue({
265
+ packageVersion: { packageVersionId: "pv" },
266
+ loaded: undefined,
267
+ unchanged: false,
268
+ });
269
+ await (0, package_cloner_1.clonePackage)(dc, deps, {
270
+ remoteRepo: "https://github.com/peers-app/test.git",
271
+ location: "/custom/install/path",
272
+ });
273
+ expect(deps.shell.exec).toHaveBeenCalledWith("git clone https://github.com/peers-app/test.git /custom/install/path");
274
+ expect(deps.shell.exec).toHaveBeenCalledWith("npm install", { cwd: "/custom/install/path" });
275
+ });
276
+ });
@@ -0,0 +1,22 @@
1
+ import type { DataContext } from "../context/data-context";
2
+ import type { ICreatePackageOpts, ICreatePackageResult, IPackageInstallerDeps } from "./types";
3
+ /**
4
+ * Convert a human-readable package name to a filesystem-safe folder slug.
5
+ * "My Widget" → "my-widget", "camelCase Thing" → "camel-case-thing"
6
+ */
7
+ export declare function nameToFolderSlug(name: string): string;
8
+ /**
9
+ * Ensure the package template is available locally.
10
+ * On first call, clones the template repo into a dot-prefixed cache directory.
11
+ * On subsequent calls, attempts a `git pull` to refresh (continues silently if offline).
12
+ * @returns The path to the cached template directory.
13
+ */
14
+ export declare function ensureTemplateCache(deps: IPackageInstallerDeps, templateRepo: string): Promise<string>;
15
+ /**
16
+ * Create a new Peers package from the package template.
17
+ *
18
+ * Clones the template repo, patches IDs and metadata, runs npm install + build,
19
+ * initializes a fresh git repo, then delegates to {@link installPackageFromBundles}
20
+ * to register the dev bundle in the database.
21
+ */
22
+ export declare function createPackage(dataContext: DataContext, deps: IPackageInstallerDeps, opts: ICreatePackageOpts): Promise<ICreatePackageResult>;