pakstr 0.17.0 → 0.18.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.
- package/dist/commands/init.js +21 -1
- package/dist/commands/publish.js +215 -174
- package/dist/commands/run.js +8 -7
- package/dist/core/androidProject.js +8 -1
- package/dist/core/artifactUpload.js +57 -0
- package/dist/core/blossom.js +18 -23
- package/dist/core/giteaRelease.js +134 -0
- package/dist/core/iconResolver.js +74 -0
- package/dist/core/pakstrConfig.js +112 -3
- package/dist/core/zapStore.js +6 -3
- package/package.json +1 -1
package/dist/commands/init.js
CHANGED
|
@@ -184,9 +184,29 @@ build:
|
|
|
184
184
|
builder: docker # Only "docker" is specified.
|
|
185
185
|
|
|
186
186
|
publish:
|
|
187
|
+
upload:
|
|
188
|
+
provider: blossom
|
|
189
|
+
server: https://cdn.zapstore.dev
|
|
190
|
+
|
|
187
191
|
zapstore:
|
|
188
|
-
enabled: true #
|
|
192
|
+
enabled: true # Set false to upload without Zapstore events.
|
|
193
|
+
source: upload
|
|
194
|
+
# source: https://downloads.example.com/my-app.apk # Or an absolute public APK URL.
|
|
195
|
+
|
|
196
|
+
relay: wss://relay.zapstore.dev
|
|
189
197
|
# publishKey: PAKSTR_PUBLISH_NSEC # OPTIONAL. Omit to reuse ${nostr_1.PAKSTR_NSEC_ENV} for publishing.
|
|
198
|
+
|
|
199
|
+
# Gitea Release alternative: replace the active Blossom upload block above
|
|
200
|
+
# with this block. Keep the token itself in the environment, never this file.
|
|
201
|
+
# upload:
|
|
202
|
+
# provider: gitea-release
|
|
203
|
+
# baseUrl: https://git.example.com
|
|
204
|
+
# owner: your-org
|
|
205
|
+
# repo: your-app
|
|
206
|
+
# tokenEnv: PAKSTR_GITEA_TOKEN
|
|
207
|
+
# tag: "v{versionName}"
|
|
208
|
+
# assetName: "{appId}-{versionName}.apk"
|
|
209
|
+
# prerelease: false
|
|
190
210
|
`;
|
|
191
211
|
}
|
|
192
212
|
function escapeYamlScalar(value) {
|
package/dist/commands/publish.js
CHANGED
|
@@ -6,224 +6,265 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
6
6
|
exports.publishCommand = publishCommand;
|
|
7
7
|
const fs_1 = __importDefault(require("fs"));
|
|
8
8
|
const path_1 = __importDefault(require("path"));
|
|
9
|
-
const
|
|
10
|
-
const zapStore_1 = require("../core/zapStore");
|
|
9
|
+
const artifactUpload_1 = require("../core/artifactUpload");
|
|
11
10
|
const blossom_1 = require("../core/blossom");
|
|
12
|
-
const
|
|
11
|
+
const giteaRelease_1 = require("../core/giteaRelease");
|
|
13
12
|
const identityProof_1 = require("../core/identityProof");
|
|
13
|
+
const nostr_1 = require("../core/nostr");
|
|
14
|
+
const pakstrConfig_1 = require("../core/pakstrConfig");
|
|
15
|
+
const zapStore_1 = require("../core/zapStore");
|
|
14
16
|
const zapstoreConfig_1 = require("../core/zapstoreConfig");
|
|
15
|
-
/**
|
|
16
|
-
* `pakstr publish` — publish the NIP-82 application event, upload the signed APK
|
|
17
|
-
* to Blossom, then publish the APK-dependent events. Spec §5.4 / §8.
|
|
18
|
-
*
|
|
19
|
-
* Requires that `pakstr build` + `pakstr sign` have already run (the signed
|
|
20
|
-
* APK at build.out, plus its `.signer-sha256` sidecar).
|
|
21
|
-
*
|
|
22
|
-
* `--dry-run` skips network + disk: it builds the events with a stub blossom
|
|
23
|
-
* descriptor and a mock relay transport, so you can preview exactly what would
|
|
24
|
-
* be published without an APK or network.
|
|
25
|
-
*/
|
|
17
|
+
/** `pakstr publish` — optionally upload the signed APK and publish its Zapstore events. */
|
|
26
18
|
async function publishCommand(configPath, options = {}) {
|
|
19
|
+
if (options.blossomFetch && options.fetchImpl) {
|
|
20
|
+
throw new Error("Specify only one of blossomFetch or fetchImpl");
|
|
21
|
+
}
|
|
27
22
|
const config = (0, pakstrConfig_1.loadPakstrConfig)(configPath);
|
|
28
23
|
const apkPath = path_1.default.resolve(config.build.out);
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const npub = (0, nostr_1.pubkeyHexToNpub)(pubkeyHex);
|
|
35
|
-
if (zapstoreConfig && zapstoreConfig.pubkeyHex !== pubkeyHex) {
|
|
36
|
-
throw new Error(`Publisher npub mismatch: ${zapstoreConfig_1.ZAPSTORE_CONFIG_FILENAME} configures ${zapstoreConfig.pubkey}, but the active Pakstr signer is ${npub}`);
|
|
24
|
+
const uploadEnabled = config.publish.upload.enabled;
|
|
25
|
+
const zapstoreEnabled = config.publish.zapstoreEnabled;
|
|
26
|
+
const fetchImpl = options.fetchImpl ?? options.blossomFetch;
|
|
27
|
+
if (!uploadEnabled && !zapstoreEnabled) {
|
|
28
|
+
throw new Error("Nothing to publish: both publish.upload and publish.zapstore are disabled");
|
|
37
29
|
}
|
|
38
30
|
console.log("\n📤 pakstr publish" + (options.dryRun ? " (dry-run)" : ""));
|
|
39
31
|
console.log("App:", config.app.appName, `(${config.app.appId})`);
|
|
40
|
-
console.log("
|
|
41
|
-
console.log("
|
|
42
|
-
|
|
43
|
-
console.log("Blossom:", config.publish.blossom);
|
|
44
|
-
let blossomUrl;
|
|
45
|
-
let apkSha256;
|
|
46
|
-
let apkSize;
|
|
32
|
+
console.log("Upload:", uploadEnabled ? config.publish.upload.provider : "disabled");
|
|
33
|
+
console.log("Zapstore:", zapstoreEnabled ? `enabled (${config.publish.zapstoreSource})` : "disabled");
|
|
34
|
+
let artifact;
|
|
47
35
|
let signerCertificateSha256 = "";
|
|
48
|
-
let filename = "";
|
|
49
36
|
let identityProof;
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
releaseNotes: zapstoreConfig?.releaseNotes,
|
|
63
|
-
repository,
|
|
64
|
-
};
|
|
65
|
-
const target = { relayUrl: config.publish.relay };
|
|
66
|
-
if (!options.dryRun) {
|
|
67
|
-
if (!fs_1.default.existsSync(apkPath) || !fs_1.default.lstatSync(apkPath).isFile()) {
|
|
68
|
-
throw new Error(`Signed APK not found at ${apkPath}. Run \`pakstr build\` and \`pakstr sign\` first.`);
|
|
69
|
-
}
|
|
37
|
+
if (options.dryRun) {
|
|
38
|
+
artifact = {
|
|
39
|
+
filePath: apkPath,
|
|
40
|
+
filename: path_1.default.basename(apkPath),
|
|
41
|
+
contentType: "application/vnd.android.package-archive",
|
|
42
|
+
sha256: "0".repeat(64),
|
|
43
|
+
size: 0,
|
|
44
|
+
};
|
|
45
|
+
signerCertificateSha256 = "0".repeat(64);
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
artifact = (0, artifactUpload_1.inspectArtifact)(apkPath);
|
|
70
49
|
const certShaPath = `${apkPath}.signer-sha256`;
|
|
71
50
|
if (!fs_1.default.existsSync(certShaPath)) {
|
|
72
51
|
throw new Error(`Signer certificate sidecar not found: ${certShaPath}. Run \`pakstr sign\` first.`);
|
|
73
52
|
}
|
|
74
53
|
signerCertificateSha256 = fs_1.default.readFileSync(certShaPath, "utf8").trim();
|
|
75
|
-
filename = path_1.default.basename(apkPath);
|
|
76
|
-
// Read the NIP-C1 identity proof sidecar (optional; generated by
|
|
77
|
-
// `pakstr sign` when a publish nsec is available).
|
|
78
54
|
const proofPath = `${apkPath}.identity-proof`;
|
|
79
|
-
if (fs_1.default.existsSync(proofPath)) {
|
|
80
|
-
|
|
81
|
-
try {
|
|
82
|
-
raw = fs_1.default.readFileSync(proofPath, "utf8").trim();
|
|
83
|
-
}
|
|
84
|
-
catch (e) {
|
|
85
|
-
throw new Error(`Failed to read identity proof sidecar ${proofPath}: ${e instanceof Error ? e.message : String(e)}`);
|
|
86
|
-
}
|
|
87
|
-
identityProof = (0, identityProof_1.parseIdentityProofSidecar)(raw, proofPath, signerCertificateSha256);
|
|
55
|
+
if (zapstoreEnabled && fs_1.default.existsSync(proofPath)) {
|
|
56
|
+
identityProof = (0, identityProof_1.parseIdentityProofSidecar)(fs_1.default.readFileSync(proofPath, "utf8").trim(), proofPath, signerCertificateSha256);
|
|
88
57
|
}
|
|
89
|
-
else {
|
|
90
|
-
|
|
91
|
-
// in CI output (the sign step may have run without a publish nsec).
|
|
92
|
-
console.log("⚠️ No .identity-proof sidecar found — the kind 30509 identity proof will not be published.");
|
|
93
|
-
console.log(" Re-run `pakstr sign` with a publish nsec available to generate it.");
|
|
58
|
+
else if (zapstoreEnabled) {
|
|
59
|
+
console.log("⚠️ No .identity-proof sidecar found — kind 30509 will not be published.");
|
|
94
60
|
}
|
|
95
61
|
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
62
|
+
const transport = options.transport ?? (options.dryRun ? new zapStore_1.MockRelayTransport() : new zapStore_1.WebsocketRelayTransport());
|
|
63
|
+
let zapstoreConfig;
|
|
64
|
+
let publishNsec;
|
|
65
|
+
let npub;
|
|
66
|
+
let pubkeyHex;
|
|
67
|
+
let appMetadata;
|
|
68
|
+
let app;
|
|
69
|
+
if (zapstoreEnabled) {
|
|
70
|
+
zapstoreConfig = options.dryRun ? undefined : (0, zapstoreConfig_1.loadZapstorePublisherConfig)(config.configDir);
|
|
71
|
+
publishNsec = (0, zapStore_1.resolvePublishSecret)(config.publish.publishKey, process.env);
|
|
72
|
+
pubkeyHex = await (0, nostr_1.getNpubHex)(publishNsec.bytes);
|
|
73
|
+
npub = (0, nostr_1.pubkeyHexToNpub)(pubkeyHex);
|
|
74
|
+
if (zapstoreConfig && zapstoreConfig.pubkeyHex !== pubkeyHex) {
|
|
75
|
+
throw new Error(`Publisher npub mismatch: ${zapstoreConfig_1.ZAPSTORE_CONFIG_FILENAME} configures ${zapstoreConfig.pubkey}, but the active Pakstr signer is ${npub}`);
|
|
76
|
+
}
|
|
77
|
+
console.log("Publishing as:", publishNsec.envVar);
|
|
78
|
+
console.log("Publisher npub:", npub);
|
|
79
|
+
console.log("Relay:", config.publish.relay);
|
|
80
|
+
app = {
|
|
81
|
+
appId: config.app.appId,
|
|
82
|
+
appName: zapstoreConfig?.name ?? config.app.appName,
|
|
83
|
+
versionName: config.app.versionName,
|
|
84
|
+
versionCode: config.app.versionCode,
|
|
85
|
+
description: zapstoreConfig?.description ?? config.app.description,
|
|
86
|
+
summary: zapstoreConfig?.summary,
|
|
87
|
+
tags: zapstoreConfig?.tags,
|
|
88
|
+
license: zapstoreConfig?.license,
|
|
89
|
+
website: zapstoreConfig?.website,
|
|
90
|
+
releaseNotes: zapstoreConfig?.releaseNotes,
|
|
91
|
+
repository: zapstoreConfig?.repository ?? (0, zapstoreConfig_1.loadZapstoreRepository)(config.configDir),
|
|
92
|
+
};
|
|
93
|
+
const expected = await (0, zapStore_1.buildAppMetadataEvent)({ app, publishNsec, target: { relayUrl: config.publish.relay } });
|
|
94
|
+
const existing = options.dryRun ? undefined : await (0, zapStore_1.findAppMetadataEvent)(transport, {
|
|
102
95
|
appId: app.appId,
|
|
103
96
|
pubkeyHex,
|
|
104
97
|
relayUrl: config.publish.relay,
|
|
105
98
|
});
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
99
|
+
console.log(`📋 Existing kind-32267: ${existing?.id ?? "none"}`);
|
|
100
|
+
appMetadata = existing && (0, zapStore_1.isCurrentAppMetadataEvent)(existing, expected.appMetadata)
|
|
101
|
+
? existing
|
|
102
|
+
: expected.appMetadata;
|
|
103
|
+
if (appMetadata === expected.appMetadata) {
|
|
104
|
+
console.log("📤 Publishing kind-32267...");
|
|
105
|
+
await transport.publish(appMetadata, config.publish.relay);
|
|
106
|
+
console.log(`✅ Published kind-32267: ${appMetadata.id}`);
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
console.log("✅ kind-32267 already up to date, skipping publish");
|
|
110
|
+
}
|
|
114
111
|
}
|
|
115
|
-
|
|
116
|
-
|
|
112
|
+
let artifactUrl;
|
|
113
|
+
let uploadProvider = null;
|
|
114
|
+
if (config.publish.zapstoreSource !== "upload") {
|
|
115
|
+
artifactUrl = config.publish.zapstoreSource;
|
|
117
116
|
}
|
|
118
|
-
if (options.dryRun) {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
blossomUrl = `${config.publish.blossom.replace(/\/$/, "")}/${apkSha256}`;
|
|
124
|
-
signerCertificateSha256 = "0".repeat(64);
|
|
125
|
-
filename = path_1.default.basename(apkPath);
|
|
126
|
-
// Stub identity proof so the 30509 event path is exercised end-to-end.
|
|
127
|
-
identityProof = {
|
|
128
|
-
certHash: signerCertificateSha256,
|
|
129
|
-
signature: "dry-run-stub==",
|
|
130
|
-
createdAt: Math.floor(Date.now() / 1000),
|
|
131
|
-
expiry: Math.floor(Date.now() / 1000) + 365 * 24 * 3600,
|
|
132
|
-
pubkeyHex,
|
|
133
|
-
};
|
|
134
|
-
console.log("📦 (dry-run) Blossom URL:", blossomUrl);
|
|
117
|
+
else if (options.dryRun) {
|
|
118
|
+
uploadProvider = config.publish.upload.provider;
|
|
119
|
+
artifactUrl = config.publish.upload.provider === "blossom"
|
|
120
|
+
? `${config.publish.upload.server.replace(/\/$/, "")}/${artifact.sha256}`
|
|
121
|
+
: `${config.publish.upload.baseUrl}/dry-run/${encodeURIComponent(artifact.filename)}`;
|
|
135
122
|
}
|
|
136
123
|
else {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
pubkeyHex,
|
|
170
|
-
appMetadataEventId: appMetadata.id,
|
|
171
|
-
releaseEventId: releasePublish.release.id,
|
|
172
|
-
assetEventId: releasePublish.asset.id,
|
|
173
|
-
identityProofEventId: releasePublish.identityProof?.id,
|
|
174
|
-
events: {
|
|
175
|
-
appMetadata,
|
|
176
|
-
release: releasePublish.release,
|
|
177
|
-
asset: releasePublish.asset,
|
|
178
|
-
identityProof: releasePublish.identityProof,
|
|
179
|
-
},
|
|
180
|
-
relayUrl: config.publish.relay,
|
|
181
|
-
};
|
|
182
|
-
console.log(options.dryRun ? "✅ PUBLISHED (dry-run — no network)" : "✅ PUBLISHED");
|
|
183
|
-
console.log("🪪 Publisher npub:", result.npub);
|
|
184
|
-
console.log("🧾 Asset event:", result.assetEventId);
|
|
185
|
-
console.log("🧾 Release event:", result.releaseEventId);
|
|
186
|
-
console.log("🧾 App metadata event:", result.appMetadataEventId);
|
|
187
|
-
if (result.identityProofEventId) {
|
|
188
|
-
console.log("🧾 Identity proof event:", result.identityProofEventId);
|
|
124
|
+
if (!uploadEnabled)
|
|
125
|
+
throw new Error("Zapstore source is upload, but artifact upload is disabled");
|
|
126
|
+
let uploaded;
|
|
127
|
+
if (config.publish.upload.provider === "blossom") {
|
|
128
|
+
const blossomSecret = publishNsec ?? (0, zapStore_1.resolvePublishSecret)(config.publish.publishKey, process.env);
|
|
129
|
+
const result = await (0, blossom_1.uploadToBlossom)({
|
|
130
|
+
serverUrl: config.publish.upload.server,
|
|
131
|
+
artifact,
|
|
132
|
+
secret: blossomSecret.bytes,
|
|
133
|
+
fetchImpl,
|
|
134
|
+
});
|
|
135
|
+
uploaded = { ...artifact, provider: "blossom", downloadUrl: result.url };
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
const token = process.env[config.publish.upload.tokenEnv];
|
|
139
|
+
if (!token)
|
|
140
|
+
throw new Error(`${config.publish.upload.tokenEnv} is required for Gitea Release upload`);
|
|
141
|
+
uploaded = await new giteaRelease_1.GiteaReleaseUploader({
|
|
142
|
+
...config.publish.upload,
|
|
143
|
+
token,
|
|
144
|
+
fetchImpl,
|
|
145
|
+
}).upload(artifact, {
|
|
146
|
+
appId: config.app.appId,
|
|
147
|
+
appName: config.app.appName,
|
|
148
|
+
versionName: config.app.versionName,
|
|
149
|
+
versionCode: config.app.versionCode,
|
|
150
|
+
releaseNotes: zapstoreConfig?.releaseNotes,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
uploadProvider = uploaded.provider;
|
|
154
|
+
artifactUrl = uploaded.downloadUrl;
|
|
155
|
+
console.log(`📦 Uploaded via ${uploaded.provider}:`, artifactUrl);
|
|
189
156
|
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
157
|
+
// An explicitly enabled uploader still runs when Zapstore uses an external URL or is disabled.
|
|
158
|
+
if (uploadEnabled && config.publish.zapstoreSource !== "upload" && !options.dryRun) {
|
|
159
|
+
if (config.publish.upload.provider === "blossom") {
|
|
160
|
+
const secret = (0, zapStore_1.resolvePublishSecret)(config.publish.publishKey, process.env);
|
|
161
|
+
const uploaded = await (0, blossom_1.uploadToBlossom)({ serverUrl: config.publish.upload.server, artifact, secret: secret.bytes, fetchImpl });
|
|
162
|
+
uploadProvider = "blossom";
|
|
163
|
+
console.log("📦 Uploaded via blossom:", uploaded.url);
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
const token = process.env[config.publish.upload.tokenEnv];
|
|
167
|
+
if (!token)
|
|
168
|
+
throw new Error(`${config.publish.upload.tokenEnv} is required for Gitea Release upload`);
|
|
169
|
+
const uploaded = await new giteaRelease_1.GiteaReleaseUploader({ ...config.publish.upload, token, fetchImpl }).upload(artifact, {
|
|
195
170
|
appId: config.app.appId,
|
|
196
171
|
appName: config.app.appName,
|
|
197
172
|
versionName: config.app.versionName,
|
|
198
173
|
versionCode: config.app.versionCode,
|
|
174
|
+
releaseNotes: zapstoreConfig?.releaseNotes,
|
|
175
|
+
});
|
|
176
|
+
uploadProvider = uploaded.provider;
|
|
177
|
+
console.log("📦 Uploaded via gitea-release:", uploaded.downloadUrl);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
let zapstoreResult;
|
|
181
|
+
if (zapstoreEnabled) {
|
|
182
|
+
if (!app || !publishNsec || !npub || !pubkeyHex || !appMetadata)
|
|
183
|
+
throw new Error("Zapstore publish context was not initialized");
|
|
184
|
+
if (options.dryRun) {
|
|
185
|
+
identityProof = {
|
|
186
|
+
certHash: signerCertificateSha256,
|
|
187
|
+
signature: "dry-run-stub==",
|
|
188
|
+
createdAt: Math.floor(Date.now() / 1000),
|
|
189
|
+
expiry: Math.floor(Date.now() / 1000) + 365 * 24 * 3600,
|
|
190
|
+
pubkeyHex,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
const built = await (0, zapStore_1.buildReleaseEvents)({
|
|
194
|
+
app,
|
|
195
|
+
apk: {
|
|
196
|
+
apkSha256: artifact.sha256,
|
|
197
|
+
apkSize: artifact.size,
|
|
198
|
+
downloadUrl: artifactUrl,
|
|
199
|
+
signerCertificateSha256,
|
|
200
|
+
filename: artifact.filename,
|
|
199
201
|
},
|
|
200
|
-
|
|
201
|
-
relayUrl: config.publish.relay,
|
|
202
|
-
|
|
202
|
+
publishNsec,
|
|
203
|
+
target: { relayUrl: config.publish.relay },
|
|
204
|
+
identityProof,
|
|
203
205
|
});
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
206
|
+
await transport.publish(built.asset, config.publish.relay);
|
|
207
|
+
await transport.publish(built.release, config.publish.relay);
|
|
208
|
+
if (built.identityProof)
|
|
209
|
+
await transport.publish(built.identityProof, config.publish.relay);
|
|
210
|
+
zapstoreResult = {
|
|
211
|
+
npub,
|
|
212
|
+
pubkeyHex,
|
|
213
|
+
appMetadataEventId: appMetadata.id,
|
|
214
|
+
releaseEventId: built.release.id,
|
|
215
|
+
assetEventId: built.asset.id,
|
|
216
|
+
identityProofEventId: built.identityProof?.id,
|
|
217
|
+
events: { appMetadata, release: built.release, asset: built.asset, identityProof: built.identityProof },
|
|
218
|
+
relayUrl: config.publish.relay,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
if (options.verify && !options.dryRun) {
|
|
222
|
+
if (zapstoreResult) {
|
|
223
|
+
const verified = await (0, zapStore_1.verifyPublish)({
|
|
224
|
+
app: app,
|
|
225
|
+
assetEventId: zapstoreResult.assetEventId,
|
|
226
|
+
relayUrl: config.publish.relay,
|
|
227
|
+
downloadUrl: artifactUrl,
|
|
228
|
+
fetchImpl,
|
|
229
|
+
});
|
|
230
|
+
if (!verified.eventFound || !verified.apkDownloadable)
|
|
231
|
+
throw new Error("Publish verification failed");
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
const response = await (fetchImpl ?? fetch)(artifactUrl, { method: "HEAD" });
|
|
235
|
+
if (!response.ok)
|
|
236
|
+
throw new Error(`Artifact verification failed: HTTP ${response.status}`);
|
|
207
237
|
}
|
|
208
238
|
}
|
|
239
|
+
const result = {
|
|
240
|
+
artifactUrl,
|
|
241
|
+
uploadProvider,
|
|
242
|
+
zapstore: zapstoreResult,
|
|
243
|
+
...(zapstoreResult ?? {}),
|
|
244
|
+
};
|
|
245
|
+
console.log("🔗 Artifact URL:", artifactUrl);
|
|
209
246
|
if (options.outJson) {
|
|
247
|
+
const blossomServer = config.publish.upload.provider === "blossom" ? config.publish.upload.server : null;
|
|
210
248
|
const summary = {
|
|
211
249
|
appId: config.app.appId,
|
|
212
250
|
appName: config.app.appName,
|
|
213
251
|
versionName: config.app.versionName,
|
|
214
252
|
versionCode: config.app.versionCode,
|
|
215
|
-
npub
|
|
216
|
-
pubkeyHex
|
|
217
|
-
relayUrl: config.publish.relay,
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
253
|
+
npub,
|
|
254
|
+
pubkeyHex,
|
|
255
|
+
relayUrl: zapstoreEnabled ? config.publish.relay : null,
|
|
256
|
+
artifactUrl,
|
|
257
|
+
source: config.publish.zapstoreSource === "upload" ? "upload" : "url",
|
|
258
|
+
uploadProvider,
|
|
259
|
+
blossomServer,
|
|
260
|
+
blossomUrl: artifactUrl,
|
|
261
|
+
apkSha256: artifact.sha256,
|
|
262
|
+
apkSize: artifact.size,
|
|
222
263
|
signerCertificateSha256,
|
|
223
|
-
assetEventId:
|
|
224
|
-
releaseEventId:
|
|
225
|
-
appMetadataEventId:
|
|
226
|
-
identityProofEventId:
|
|
264
|
+
assetEventId: zapstoreResult?.assetEventId,
|
|
265
|
+
releaseEventId: zapstoreResult?.releaseEventId,
|
|
266
|
+
appMetadataEventId: zapstoreResult?.appMetadataEventId,
|
|
267
|
+
identityProofEventId: zapstoreResult?.identityProofEventId,
|
|
227
268
|
publishedAt: new Date().toISOString(),
|
|
228
269
|
dryRun: !!options.dryRun,
|
|
229
270
|
};
|
package/dist/commands/run.js
CHANGED
|
@@ -27,8 +27,8 @@ async function runCommand(configPath, options = {}) {
|
|
|
27
27
|
const config = (0, pakstrConfig_1.loadPakstrConfig)(configPath);
|
|
28
28
|
// Verify PAKSTR_NSEC present (fail fast before any work).
|
|
29
29
|
(0, nostr_1.requireSigningNsec)(process.env);
|
|
30
|
-
// Pre-validate the publish nsec
|
|
31
|
-
if (config.publish.zapstoreEnabled) {
|
|
30
|
+
// Pre-validate the Nostr publish nsec when Zapstore or Blossom needs it.
|
|
31
|
+
if (config.publish.zapstoreEnabled || (config.publish.upload.enabled && config.publish.upload.provider === "blossom")) {
|
|
32
32
|
if (!(0, nostr_1.isPublishNsecPresent)(config.publish.publishKey, process.env)) {
|
|
33
33
|
const envVar = config.publish.publishKey === undefined
|
|
34
34
|
? "PAKSTR_NSEC"
|
|
@@ -44,11 +44,12 @@ async function runCommand(configPath, options = {}) {
|
|
|
44
44
|
console.log("Version:", config.app.versionName, `(${config.app.versionCode})`);
|
|
45
45
|
console.log("Web:", config.build.web);
|
|
46
46
|
console.log("Out:", path_1.default.resolve(config.build.out));
|
|
47
|
-
|
|
47
|
+
const publishingEnabled = config.publish.zapstoreEnabled || config.publish.upload.enabled;
|
|
48
|
+
console.log("Publish:", publishingEnabled ? "enabled" : "disabled");
|
|
48
49
|
if (options.dryRun) {
|
|
49
50
|
// Skip build + sign — no Docker, no APK. Only exercise the publish
|
|
50
51
|
// event-signing path with stub values and a mock relay.
|
|
51
|
-
if (
|
|
52
|
+
if (publishingEnabled) {
|
|
52
53
|
console.log("\n— Dry run: skipping build + sign, publish only —");
|
|
53
54
|
await (0, publish_1.publishCommand)(config.configPath || undefined, {
|
|
54
55
|
dryRun: true,
|
|
@@ -67,8 +68,8 @@ async function runCommand(configPath, options = {}) {
|
|
|
67
68
|
// Step 4: sign the APK.
|
|
68
69
|
console.log("\n— Step 2/3: sign —");
|
|
69
70
|
await (0, sign_1.signCommand)(config.configPath || undefined);
|
|
70
|
-
// Step 5: publish to
|
|
71
|
-
if (
|
|
71
|
+
// Step 5: upload and/or publish to Zapstore (unless both are disabled).
|
|
72
|
+
if (publishingEnabled) {
|
|
72
73
|
console.log("\n— Step 3/3: publish —");
|
|
73
74
|
await (0, publish_1.publishCommand)(config.configPath || undefined, {
|
|
74
75
|
verify: options.verifyPublish,
|
|
@@ -76,7 +77,7 @@ async function runCommand(configPath, options = {}) {
|
|
|
76
77
|
});
|
|
77
78
|
}
|
|
78
79
|
else {
|
|
79
|
-
console.log("\
|
|
80
|
+
console.log("\nArtifact upload and Zapstore publishing are disabled. Stopping after sign.");
|
|
80
81
|
}
|
|
81
82
|
// Step 6: cleanup. The signed APK at build.out is the artifact and is kept.
|
|
82
83
|
// Derived signing material is cleaned up inside the sign runner (tmpfs).
|
|
@@ -13,6 +13,7 @@ const branding_1 = require("../android/branding");
|
|
|
13
13
|
const icon_1 = require("../android/icon");
|
|
14
14
|
const permissions_1 = require("../android/permissions");
|
|
15
15
|
const splash_1 = require("../android/splash");
|
|
16
|
+
const iconResolver_1 = require("./iconResolver");
|
|
16
17
|
const DEFAULT_ICON_PATH = path_1.default.resolve(__dirname, "../../assets/logo.png");
|
|
17
18
|
/**
|
|
18
19
|
* Copy built web assets into the Android template and patch identity/branding.
|
|
@@ -31,7 +32,13 @@ async function prepareAndroidProject(opts) {
|
|
|
31
32
|
generateRuntimeConfig(assetsTarget);
|
|
32
33
|
(0, gradle_1.patchGradle)(androidRoot, app);
|
|
33
34
|
(0, branding_1.patchAppName)(androidRoot, app.appName);
|
|
34
|
-
await (0,
|
|
35
|
+
const icon = await (0, iconResolver_1.resolveAppIcon)({
|
|
36
|
+
configDir,
|
|
37
|
+
configuredIcon: app.icon,
|
|
38
|
+
defaultIcon: DEFAULT_ICON_PATH,
|
|
39
|
+
});
|
|
40
|
+
console.log(`🖼️ Launcher icon source: ${icon.source} (${icon.path})`);
|
|
41
|
+
await (0, icon_1.patchIcon)(androidRoot, icon.path, app.backgroundColor ?? "#FFFFFF", configDir);
|
|
35
42
|
if (app.splash?.image) {
|
|
36
43
|
await (0, splash_1.patchSplash)(androidRoot, app.splash.image, app.splash.background ?? "#FFFFFF", configDir);
|
|
37
44
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.APK_CONTENT_TYPE = void 0;
|
|
7
|
+
exports.inspectArtifact = inspectArtifact;
|
|
8
|
+
exports.expandArtifactTemplate = expandArtifactTemplate;
|
|
9
|
+
exports.validateDownloadUrl = validateDownloadUrl;
|
|
10
|
+
const crypto_1 = require("crypto");
|
|
11
|
+
const fs_1 = __importDefault(require("fs"));
|
|
12
|
+
const path_1 = __importDefault(require("path"));
|
|
13
|
+
exports.APK_CONTENT_TYPE = "application/vnd.android.package-archive";
|
|
14
|
+
function inspectArtifact(filePath) {
|
|
15
|
+
if (!fs_1.default.existsSync(filePath) || !fs_1.default.lstatSync(filePath).isFile()) {
|
|
16
|
+
throw new Error(`Signed APK not found at ${filePath}. Run \`pakstr build\` and \`pakstr sign\` first.`);
|
|
17
|
+
}
|
|
18
|
+
const hash = (0, crypto_1.createHash)("sha256");
|
|
19
|
+
const fd = fs_1.default.openSync(filePath, "r");
|
|
20
|
+
try {
|
|
21
|
+
const buffer = Buffer.alloc(64 * 1024);
|
|
22
|
+
let bytesRead = 0;
|
|
23
|
+
while ((bytesRead = fs_1.default.readSync(fd, buffer, 0, buffer.length, null)) !== 0) {
|
|
24
|
+
hash.update(buffer.subarray(0, bytesRead));
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
finally {
|
|
28
|
+
fs_1.default.closeSync(fd);
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
filePath,
|
|
32
|
+
filename: path_1.default.basename(filePath),
|
|
33
|
+
contentType: exports.APK_CONTENT_TYPE,
|
|
34
|
+
sha256: hash.digest("hex"),
|
|
35
|
+
size: fs_1.default.statSync(filePath).size,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function expandArtifactTemplate(template, artifact, release) {
|
|
39
|
+
return template
|
|
40
|
+
.replaceAll("{appId}", release.appId)
|
|
41
|
+
.replaceAll("{versionName}", release.versionName)
|
|
42
|
+
.replaceAll("{versionCode}", String(release.versionCode))
|
|
43
|
+
.replaceAll("{filename}", artifact.filename);
|
|
44
|
+
}
|
|
45
|
+
function validateDownloadUrl(value, provider) {
|
|
46
|
+
let url;
|
|
47
|
+
try {
|
|
48
|
+
url = new URL(value);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
throw new Error(`${provider} did not return a valid artifact download URL`);
|
|
52
|
+
}
|
|
53
|
+
if ((url.protocol !== "https:" && url.protocol !== "http:") || !url.hostname) {
|
|
54
|
+
throw new Error(`${provider} did not return an HTTP(S) artifact download URL`);
|
|
55
|
+
}
|
|
56
|
+
return url.toString();
|
|
57
|
+
}
|
package/dist/core/blossom.js
CHANGED
|
@@ -7,8 +7,8 @@ exports.BlossomError = void 0;
|
|
|
7
7
|
exports.hashFile = hashFile;
|
|
8
8
|
exports.uploadToBlossom = uploadToBlossom;
|
|
9
9
|
exports.npubHexToBech32 = npubHexToBech32;
|
|
10
|
-
const crypto_1 = require("crypto");
|
|
11
10
|
const fs_1 = __importDefault(require("fs"));
|
|
11
|
+
const artifactUpload_1 = require("./artifactUpload");
|
|
12
12
|
const nostr_1 = require("./nostr");
|
|
13
13
|
/**
|
|
14
14
|
* Blossom (BUD-02 / BUD-11) blob upload.
|
|
@@ -19,7 +19,7 @@ const nostr_1 = require("./nostr");
|
|
|
19
19
|
*
|
|
20
20
|
* Reference: https://github.com/hzrd149/blossom (BUD-02 upload, BUD-11 auth).
|
|
21
21
|
*/
|
|
22
|
-
const DEFAULT_CONTENT_TYPE =
|
|
22
|
+
const DEFAULT_CONTENT_TYPE = artifactUpload_1.APK_CONTENT_TYPE;
|
|
23
23
|
const AUTH_TTL_SECONDS = 5 * 60;
|
|
24
24
|
class BlossomError extends Error {
|
|
25
25
|
status;
|
|
@@ -32,26 +32,17 @@ class BlossomError extends Error {
|
|
|
32
32
|
exports.BlossomError = BlossomError;
|
|
33
33
|
/** Compute the SHA-256 of a file (hex). */
|
|
34
34
|
function hashFile(filePath) {
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
const fd = fs_1.default.openSync(filePath, "r");
|
|
38
|
-
try {
|
|
39
|
-
const buf = Buffer.alloc(64 * 1024);
|
|
40
|
-
let bytes = 0;
|
|
41
|
-
while ((bytes = fs_1.default.readSync(fd, buf, 0, buf.length, null)) !== 0) {
|
|
42
|
-
hash.update(buf.subarray(0, bytes));
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
finally {
|
|
46
|
-
fs_1.default.closeSync(fd);
|
|
47
|
-
}
|
|
48
|
-
return { sha256: hash.digest("hex"), size };
|
|
35
|
+
const artifact = (0, artifactUpload_1.inspectArtifact)(filePath);
|
|
36
|
+
return { sha256: artifact.sha256, size: artifact.size };
|
|
49
37
|
}
|
|
50
38
|
async function uploadToBlossom(opts) {
|
|
51
39
|
const fetchFn = opts.fetchImpl ?? fetch;
|
|
52
|
-
const
|
|
40
|
+
const artifact = opts.artifact ?? (opts.filePath ? (0, artifactUpload_1.inspectArtifact)(opts.filePath) : undefined);
|
|
41
|
+
if (!artifact)
|
|
42
|
+
throw new BlossomError("Blossom upload requires a local artifact");
|
|
43
|
+
const contentType = opts.contentType ?? artifact.contentType ?? DEFAULT_CONTENT_TYPE;
|
|
53
44
|
const serverUrl = opts.serverUrl.replace(/\/$/, "");
|
|
54
|
-
const { sha256, size } =
|
|
45
|
+
const { sha256, size } = artifact;
|
|
55
46
|
// 1. HEAD /<sha256> — skip upload if the blob already exists.
|
|
56
47
|
const head = await fetchFn(`${serverUrl}/${sha256}`, { method: "HEAD" });
|
|
57
48
|
if (head.status === 200) {
|
|
@@ -77,7 +68,7 @@ async function uploadToBlossom(opts) {
|
|
|
77
68
|
}, opts.secret);
|
|
78
69
|
// 3. PUT /upload with the signed auth event in the Authorization header.
|
|
79
70
|
const authHeader = "Nostr " + Buffer.from(JSON.stringify(authEvent)).toString("base64url");
|
|
80
|
-
const body = fs_1.default.readFileSync(
|
|
71
|
+
const body = fs_1.default.readFileSync(artifact.filePath);
|
|
81
72
|
const res = await fetchFn(`${serverUrl}/upload`, {
|
|
82
73
|
method: "PUT",
|
|
83
74
|
headers: {
|
|
@@ -103,12 +94,16 @@ async function uploadToBlossom(opts) {
|
|
|
103
94
|
const text = await res.text();
|
|
104
95
|
try {
|
|
105
96
|
const descriptor = JSON.parse(text);
|
|
97
|
+
if (descriptor.sha256 && descriptor.sha256 !== sha256) {
|
|
98
|
+
throw new BlossomError("Blossom response SHA-256 does not match the uploaded APK");
|
|
99
|
+
}
|
|
100
|
+
if (descriptor.size !== undefined && descriptor.size !== size) {
|
|
101
|
+
throw new BlossomError("Blossom response size does not match the uploaded APK");
|
|
102
|
+
}
|
|
106
103
|
if (!descriptor.url)
|
|
107
104
|
descriptor.url = `${serverUrl}/${sha256}`;
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
if (!descriptor.size)
|
|
111
|
-
descriptor.size = size;
|
|
105
|
+
descriptor.sha256 = sha256;
|
|
106
|
+
descriptor.size = size;
|
|
112
107
|
if (!descriptor.type)
|
|
113
108
|
descriptor.type = contentType;
|
|
114
109
|
return descriptor;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.GiteaReleaseUploader = exports.GiteaReleaseError = void 0;
|
|
7
|
+
const fs_1 = __importDefault(require("fs"));
|
|
8
|
+
const artifactUpload_1 = require("./artifactUpload");
|
|
9
|
+
class GiteaReleaseError extends Error {
|
|
10
|
+
status;
|
|
11
|
+
constructor(message, status) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.status = status;
|
|
14
|
+
this.name = "GiteaReleaseError";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
exports.GiteaReleaseError = GiteaReleaseError;
|
|
18
|
+
class GiteaReleaseUploader {
|
|
19
|
+
options;
|
|
20
|
+
provider = "gitea-release";
|
|
21
|
+
constructor(options) {
|
|
22
|
+
this.options = options;
|
|
23
|
+
}
|
|
24
|
+
async upload(artifact, context) {
|
|
25
|
+
const fetchFn = this.options.fetchImpl ?? fetch;
|
|
26
|
+
const repositoryUrl = `${this.options.baseUrl}/api/v1/repos/${encodeURIComponent(this.options.owner)}/${encodeURIComponent(this.options.repo)}`;
|
|
27
|
+
const tag = (0, artifactUpload_1.expandArtifactTemplate)(this.options.tag, artifact, context);
|
|
28
|
+
const assetName = (0, artifactUpload_1.expandArtifactTemplate)(this.options.assetName, artifact, context);
|
|
29
|
+
if (!tag || /[\\\x00-\x1f\x7f]/.test(tag) || tag.includes("..")) {
|
|
30
|
+
throw new GiteaReleaseError("Expanded Gitea release tag is invalid");
|
|
31
|
+
}
|
|
32
|
+
if (!assetName || /[\\/\x00-\x1f\x7f]/.test(assetName) || assetName.includes("..")) {
|
|
33
|
+
throw new GiteaReleaseError("Expanded Gitea asset name must be a single safe filename");
|
|
34
|
+
}
|
|
35
|
+
let release = await this.fetchRelease(fetchFn, repositoryUrl, tag);
|
|
36
|
+
if (!release) {
|
|
37
|
+
const response = await fetchFn(`${repositoryUrl}/releases`, {
|
|
38
|
+
method: "POST",
|
|
39
|
+
headers: this.headers({ "Content-Type": "application/json" }),
|
|
40
|
+
body: JSON.stringify({
|
|
41
|
+
tag_name: tag,
|
|
42
|
+
name: `${context.appName} ${context.versionName}`,
|
|
43
|
+
body: context.releaseNotes ?? "",
|
|
44
|
+
draft: false,
|
|
45
|
+
prerelease: this.options.prerelease,
|
|
46
|
+
}),
|
|
47
|
+
});
|
|
48
|
+
if (response.status === 409 || response.status === 422) {
|
|
49
|
+
release = await this.fetchRelease(fetchFn, repositoryUrl, tag);
|
|
50
|
+
}
|
|
51
|
+
else if (response.ok) {
|
|
52
|
+
release = await this.readRelease(response, "create release");
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
throw await this.httpError(response, "create release");
|
|
56
|
+
}
|
|
57
|
+
if (!release)
|
|
58
|
+
throw new GiteaReleaseError("Gitea release creation raced but the release could not be found");
|
|
59
|
+
}
|
|
60
|
+
const assets = release.assets ?? await this.fetchAssets(fetchFn, repositoryUrl, release.id);
|
|
61
|
+
for (const existing of assets.filter(asset => asset.name === assetName)) {
|
|
62
|
+
const response = await fetchFn(`${repositoryUrl}/releases/assets/${existing.id}`, {
|
|
63
|
+
method: "DELETE",
|
|
64
|
+
headers: this.headers(),
|
|
65
|
+
});
|
|
66
|
+
if (!response.ok && response.status !== 404)
|
|
67
|
+
throw await this.httpError(response, "delete existing asset");
|
|
68
|
+
}
|
|
69
|
+
const form = new FormData();
|
|
70
|
+
form.append("attachment", new Blob([fs_1.default.readFileSync(artifact.filePath)], { type: artifact.contentType }), assetName);
|
|
71
|
+
const response = await fetchFn(`${repositoryUrl}/releases/${release.id}/assets?name=${encodeURIComponent(assetName)}`, {
|
|
72
|
+
method: "POST",
|
|
73
|
+
headers: this.headers(),
|
|
74
|
+
body: form,
|
|
75
|
+
});
|
|
76
|
+
if (!response.ok)
|
|
77
|
+
throw await this.httpError(response, "upload release asset");
|
|
78
|
+
const uploaded = await this.readJson(response, "upload release asset");
|
|
79
|
+
if (!uploaded.id || uploaded.name !== assetName || !uploaded.browser_download_url) {
|
|
80
|
+
throw new GiteaReleaseError("Gitea upload response is missing the expected asset fields");
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
...artifact,
|
|
84
|
+
provider: this.provider,
|
|
85
|
+
downloadUrl: (0, artifactUpload_1.validateDownloadUrl)(uploaded.browser_download_url, "Gitea"),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
async fetchRelease(fetchFn, repositoryUrl, tag) {
|
|
89
|
+
const response = await fetchFn(`${repositoryUrl}/releases/tags/${encodeURIComponent(tag)}`, { headers: this.headers() });
|
|
90
|
+
if (response.status === 404)
|
|
91
|
+
return undefined;
|
|
92
|
+
if (!response.ok)
|
|
93
|
+
throw await this.httpError(response, "find release");
|
|
94
|
+
return await this.readRelease(response, "find release");
|
|
95
|
+
}
|
|
96
|
+
async fetchAssets(fetchFn, repositoryUrl, releaseId) {
|
|
97
|
+
const response = await fetchFn(`${repositoryUrl}/releases/${releaseId}/assets`, { headers: this.headers() });
|
|
98
|
+
if (!response.ok)
|
|
99
|
+
throw await this.httpError(response, "list release assets");
|
|
100
|
+
const assets = await this.readJson(response, "list release assets");
|
|
101
|
+
if (!Array.isArray(assets))
|
|
102
|
+
throw new GiteaReleaseError("Gitea list-assets response is not an array");
|
|
103
|
+
return assets;
|
|
104
|
+
}
|
|
105
|
+
async readRelease(response, operation) {
|
|
106
|
+
const release = await this.readJson(response, operation);
|
|
107
|
+
if (!Number.isInteger(release.id) || release.id <= 0) {
|
|
108
|
+
throw new GiteaReleaseError(`Gitea ${operation} response is missing a release id`);
|
|
109
|
+
}
|
|
110
|
+
return release;
|
|
111
|
+
}
|
|
112
|
+
async readJson(response, operation) {
|
|
113
|
+
try {
|
|
114
|
+
return await response.json();
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
throw new GiteaReleaseError(`Gitea ${operation} returned invalid JSON`, response.status);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
headers(extra = {}) {
|
|
121
|
+
return { Authorization: `token ${this.options.token}`, Accept: "application/json", ...extra };
|
|
122
|
+
}
|
|
123
|
+
async httpError(response, operation) {
|
|
124
|
+
let detail = "";
|
|
125
|
+
try {
|
|
126
|
+
detail = (await response.text()).slice(0, 200);
|
|
127
|
+
}
|
|
128
|
+
catch { /* ignore */ }
|
|
129
|
+
if (this.options.token)
|
|
130
|
+
detail = detail.replaceAll(this.options.token, "[REDACTED]");
|
|
131
|
+
return new GiteaReleaseError(`Gitea ${operation} failed for ${this.options.owner}/${this.options.repo} using ${this.options.tokenEnv}: HTTP ${response.status}${detail ? ` — ${detail}` : ""}`, response.status);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
exports.GiteaReleaseUploader = GiteaReleaseUploader;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.resolveAppIcon = resolveAppIcon;
|
|
7
|
+
const child_process_1 = require("child_process");
|
|
8
|
+
const fs_1 = __importDefault(require("fs"));
|
|
9
|
+
const path_1 = __importDefault(require("path"));
|
|
10
|
+
const sharp_1 = __importDefault(require("sharp"));
|
|
11
|
+
const assetPath_1 = require("./assetPath");
|
|
12
|
+
async function resolveAppIcon(input) {
|
|
13
|
+
const configDir = fs_1.default.realpathSync(input.configDir);
|
|
14
|
+
const repositoryRoot = findRepositoryRoot(configDir);
|
|
15
|
+
const candidates = [
|
|
16
|
+
{ path: path_1.default.join(configDir, "logo.png"), source: "app-logo", root: configDir },
|
|
17
|
+
];
|
|
18
|
+
if (repositoryRoot && repositoryRoot !== configDir) {
|
|
19
|
+
candidates.push({ path: path_1.default.join(repositoryRoot, "logo.png"), source: "repository-logo", root: repositoryRoot });
|
|
20
|
+
}
|
|
21
|
+
for (const candidate of candidates) {
|
|
22
|
+
if (!fs_1.default.existsSync(candidate.path))
|
|
23
|
+
continue;
|
|
24
|
+
try {
|
|
25
|
+
const resolved = fs_1.default.realpathSync(candidate.path);
|
|
26
|
+
if (!isContained(candidate.root, resolved) || !fs_1.default.statSync(resolved).isFile()) {
|
|
27
|
+
throw new Error("not a regular file inside the application repository");
|
|
28
|
+
}
|
|
29
|
+
const metadata = await (0, sharp_1.default)(resolved).metadata();
|
|
30
|
+
if (metadata.format !== "png" || !metadata.width || !metadata.height) {
|
|
31
|
+
throw new Error("not a non-empty PNG image");
|
|
32
|
+
}
|
|
33
|
+
return { path: resolved, source: candidate.source };
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
console.warn(`⚠️ Ignoring unsuitable automatic logo ${candidate.path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (input.configuredIcon !== undefined) {
|
|
40
|
+
const configured = (0, assetPath_1.resolveAssetPath)(input.configuredIcon, configDir);
|
|
41
|
+
if (!fs_1.default.existsSync(configured) || !fs_1.default.lstatSync(configured).isFile()) {
|
|
42
|
+
throw new Error(`❌ Icon not found or not a regular file: ${configured}`);
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
const metadata = await (0, sharp_1.default)(configured).metadata();
|
|
46
|
+
if (!metadata.width || !metadata.height)
|
|
47
|
+
throw new Error("image has no dimensions");
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
throw new Error(`❌ Invalid configured icon ${configured}: ${error instanceof Error ? error.message : String(error)}`);
|
|
51
|
+
}
|
|
52
|
+
return { path: configured, source: "configured" };
|
|
53
|
+
}
|
|
54
|
+
return { path: input.defaultIcon, source: "default" };
|
|
55
|
+
}
|
|
56
|
+
function findRepositoryRoot(configDir) {
|
|
57
|
+
try {
|
|
58
|
+
const output = (0, child_process_1.execFileSync)("git", ["-C", configDir, "rev-parse", "--show-toplevel"], {
|
|
59
|
+
encoding: "utf8",
|
|
60
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
61
|
+
}).trim();
|
|
62
|
+
if (!output)
|
|
63
|
+
return undefined;
|
|
64
|
+
const root = fs_1.default.realpathSync(output);
|
|
65
|
+
return isContained(root, configDir) ? root : undefined;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function isContained(root, candidate) {
|
|
72
|
+
const relative = path_1.default.relative(root, candidate);
|
|
73
|
+
return relative === "" || (!relative.startsWith(".." + path_1.default.sep) && relative !== "..");
|
|
74
|
+
}
|
|
@@ -130,6 +130,9 @@ function resolveAndValidate(config, configPath) {
|
|
|
130
130
|
if (app.description !== undefined && typeof app.description !== "string") {
|
|
131
131
|
fail(`${where("app.description")} must be a string`, configPath);
|
|
132
132
|
}
|
|
133
|
+
if (app.icon !== undefined && (typeof app.icon !== "string" || app.icon.trim().length === 0)) {
|
|
134
|
+
fail(`${where("app.icon")} must be a non-empty string`, configPath);
|
|
135
|
+
}
|
|
133
136
|
// Optional color fields
|
|
134
137
|
if (app.backgroundColor !== undefined) {
|
|
135
138
|
assertHexColor(app.backgroundColor, "app.backgroundColor", configPath);
|
|
@@ -178,20 +181,29 @@ function resolveAndValidate(config, configPath) {
|
|
|
178
181
|
// publish section
|
|
179
182
|
const publish = config.publish;
|
|
180
183
|
let zapstoreEnabled = true;
|
|
184
|
+
let zapstoreSource = "upload";
|
|
181
185
|
let publishKey = undefined;
|
|
182
186
|
if (publish !== undefined) {
|
|
183
187
|
if (publish.zapstore !== undefined) {
|
|
188
|
+
if (typeof publish.zapstore !== "object" || Array.isArray(publish.zapstore)) {
|
|
189
|
+
fail(`${where("publish.zapstore")} must be a mapping`, configPath);
|
|
190
|
+
}
|
|
184
191
|
if (publish.zapstore.enabled !== undefined && typeof publish.zapstore.enabled !== "boolean") {
|
|
185
192
|
fail(`${where("publish.zapstore.enabled")} must be a boolean`, configPath);
|
|
186
193
|
}
|
|
187
194
|
zapstoreEnabled = publish.zapstore.enabled ?? true;
|
|
195
|
+
if (publish.zapstore.source !== undefined) {
|
|
196
|
+
requireString(publish.zapstore.source, "publish.zapstore.source", configPath);
|
|
197
|
+
zapstoreSource = publish.zapstore.source === "upload"
|
|
198
|
+
? "upload"
|
|
199
|
+
: validatePublicUrl(publish.zapstore.source, "publish.zapstore.source", configPath);
|
|
200
|
+
}
|
|
188
201
|
}
|
|
189
202
|
if (publish.publishKey !== undefined) {
|
|
190
203
|
if (publish.publishKey !== null && typeof publish.publishKey !== "string") {
|
|
191
204
|
fail(`${where("publish.publishKey")} must be a string or empty (bare key)`, configPath);
|
|
192
205
|
}
|
|
193
206
|
if (typeof publish.publishKey === "string" && publish.publishKey.trim().length === 0) {
|
|
194
|
-
// Treat empty string like a bare key.
|
|
195
207
|
publishKey = null;
|
|
196
208
|
}
|
|
197
209
|
else {
|
|
@@ -200,13 +212,72 @@ function resolveAndValidate(config, configPath) {
|
|
|
200
212
|
}
|
|
201
213
|
}
|
|
202
214
|
const relay = publish?.relay ?? "wss://relay.zapstore.dev";
|
|
203
|
-
|
|
215
|
+
if (publish?.blossom !== undefined && publish.upload !== undefined) {
|
|
216
|
+
fail(`${where("publish.blossom")} cannot be combined with publish.upload`, configPath);
|
|
217
|
+
}
|
|
218
|
+
const defaultBlossom = publish?.blossom ?? "https://cdn.zapstore.dev";
|
|
219
|
+
let upload;
|
|
220
|
+
if (publish?.upload === undefined) {
|
|
221
|
+
upload = {
|
|
222
|
+
enabled: publish?.blossom !== undefined || (zapstoreEnabled && zapstoreSource === "upload"),
|
|
223
|
+
provider: "blossom",
|
|
224
|
+
server: validatePublicUrl(defaultBlossom, "publish.blossom", configPath),
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
const rawUpload = publish.upload;
|
|
229
|
+
if (typeof rawUpload !== "object" || Array.isArray(rawUpload)) {
|
|
230
|
+
fail(`${where("publish.upload")} must be a mapping`, configPath);
|
|
231
|
+
}
|
|
232
|
+
if (rawUpload.enabled !== undefined && typeof rawUpload.enabled !== "boolean") {
|
|
233
|
+
fail(`${where("publish.upload.enabled")} must be a boolean`, configPath);
|
|
234
|
+
}
|
|
235
|
+
if (rawUpload.provider === "blossom") {
|
|
236
|
+
upload = {
|
|
237
|
+
enabled: rawUpload.enabled ?? true,
|
|
238
|
+
provider: "blossom",
|
|
239
|
+
server: validatePublicUrl(rawUpload.server ?? "https://cdn.zapstore.dev", "publish.upload.server", configPath),
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
else if (rawUpload.provider === "gitea-release") {
|
|
243
|
+
requireString(rawUpload.baseUrl, "publish.upload.baseUrl", configPath);
|
|
244
|
+
requireSafeSegment(rawUpload.owner, "publish.upload.owner", configPath);
|
|
245
|
+
requireSafeSegment(rawUpload.repo, "publish.upload.repo", configPath);
|
|
246
|
+
const tokenEnv = rawUpload.tokenEnv ?? "PAKSTR_GITEA_TOKEN";
|
|
247
|
+
requireEnvName(tokenEnv, "publish.upload.tokenEnv", configPath);
|
|
248
|
+
const tag = rawUpload.tag ?? "v{versionName}";
|
|
249
|
+
const assetName = rawUpload.assetName ?? "{appId}-{versionName}.apk";
|
|
250
|
+
validateTemplate(tag, "publish.upload.tag", configPath);
|
|
251
|
+
validateTemplate(assetName, "publish.upload.assetName", configPath);
|
|
252
|
+
if (rawUpload.prerelease !== undefined && typeof rawUpload.prerelease !== "boolean") {
|
|
253
|
+
fail(`${where("publish.upload.prerelease")} must be a boolean`, configPath);
|
|
254
|
+
}
|
|
255
|
+
upload = {
|
|
256
|
+
enabled: rawUpload.enabled ?? true,
|
|
257
|
+
provider: "gitea-release",
|
|
258
|
+
baseUrl: validatePublicUrl(rawUpload.baseUrl, "publish.upload.baseUrl", configPath).replace(/\/$/, ""),
|
|
259
|
+
owner: rawUpload.owner,
|
|
260
|
+
repo: rawUpload.repo,
|
|
261
|
+
tokenEnv,
|
|
262
|
+
tag,
|
|
263
|
+
assetName,
|
|
264
|
+
prerelease: rawUpload.prerelease ?? false,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
else {
|
|
268
|
+
fail(`${where("publish.upload.provider")} must be "blossom" or "gitea-release"`, configPath);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
if (zapstoreEnabled && zapstoreSource === "upload" && !upload.enabled) {
|
|
272
|
+
fail(`${where("publish.zapstore.source")} is "upload" but publish.upload is disabled`, configPath);
|
|
273
|
+
}
|
|
274
|
+
const blossom = upload.provider === "blossom" ? upload.server : defaultBlossom;
|
|
204
275
|
return {
|
|
205
276
|
configDir,
|
|
206
277
|
configPath,
|
|
207
278
|
app: resolvedApp,
|
|
208
279
|
build: { web: webAbs, out, builder },
|
|
209
|
-
publish: { zapstoreEnabled, publishKey, relay, blossom },
|
|
280
|
+
publish: { zapstoreEnabled, zapstoreSource, upload, publishKey, relay, blossom },
|
|
210
281
|
};
|
|
211
282
|
}
|
|
212
283
|
function requireString(value, field, configPath) {
|
|
@@ -224,3 +295,41 @@ function assertHexColor(value, field, configPath) {
|
|
|
224
295
|
fail(`${field} must be a hex color of the form #RRGGBB`, configPath);
|
|
225
296
|
}
|
|
226
297
|
}
|
|
298
|
+
function validatePublicUrl(value, field, configPath) {
|
|
299
|
+
let parsed;
|
|
300
|
+
try {
|
|
301
|
+
parsed = new URL(value);
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
fail(`${field} must be an absolute HTTP(S) URL`, configPath);
|
|
305
|
+
}
|
|
306
|
+
if ((parsed.protocol !== "https:" && parsed.protocol !== "http:") || !parsed.hostname) {
|
|
307
|
+
fail(`${field} must be an absolute HTTP(S) URL`, configPath);
|
|
308
|
+
}
|
|
309
|
+
if (parsed.username || parsed.password) {
|
|
310
|
+
fail(`${field} must not contain credentials`, configPath);
|
|
311
|
+
}
|
|
312
|
+
return parsed.toString().replace(/\/$/, value.endsWith("/") ? "/" : "");
|
|
313
|
+
}
|
|
314
|
+
function requireSafeSegment(value, field, configPath) {
|
|
315
|
+
requireString(value, field, configPath);
|
|
316
|
+
if (value === "." || value === ".." || /[\\/\x00-\x1f\x7f]/.test(value)) {
|
|
317
|
+
fail(`${field} must be a single non-empty path segment`, configPath);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
function requireEnvName(value, field, configPath) {
|
|
321
|
+
requireString(value, field, configPath);
|
|
322
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) {
|
|
323
|
+
fail(`${field} must be a valid environment variable name`, configPath);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
function validateTemplate(value, field, configPath) {
|
|
327
|
+
requireString(value, field, configPath);
|
|
328
|
+
const stripped = value.replace(/\{(appId|versionName|versionCode|filename)\}/g, "");
|
|
329
|
+
if (/[{}]/.test(stripped)) {
|
|
330
|
+
fail(`${field} contains an unknown or malformed placeholder`, configPath);
|
|
331
|
+
}
|
|
332
|
+
if (/[\\\x00-\x1f\x7f]/.test(value) || value.includes("..")) {
|
|
333
|
+
fail(`${field} must not contain traversal, backslashes, or control characters`, configPath);
|
|
334
|
+
}
|
|
335
|
+
}
|
package/dist/core/zapStore.js
CHANGED
|
@@ -112,7 +112,7 @@ async function buildReleaseEvents(input) {
|
|
|
112
112
|
["i", input.app.appId],
|
|
113
113
|
["x", input.apk.apkSha256],
|
|
114
114
|
["version", input.app.versionName],
|
|
115
|
-
["url", input.apk.blossomUrl],
|
|
115
|
+
["url", input.apk.downloadUrl ?? input.apk.blossomUrl ?? ""],
|
|
116
116
|
["m", "application/vnd.android.package-archive"],
|
|
117
117
|
["size", String(input.apk.apkSize)],
|
|
118
118
|
["f", PLATFORM],
|
|
@@ -334,13 +334,16 @@ function queryRelayForFilter(WebSocketCtor, relayUrl, filter, timeoutMs = 30000)
|
|
|
334
334
|
}
|
|
335
335
|
/**
|
|
336
336
|
* Verify a publish by querying the relay for the asset event and confirming
|
|
337
|
-
* the APK is retrievable from
|
|
337
|
+
* the APK is retrievable from its artifact URL. Returns true if both check out.
|
|
338
338
|
*/
|
|
339
339
|
async function verifyPublish(input) {
|
|
340
340
|
const WebSocketCtor = input.WebSocketCtor ?? WebSocket;
|
|
341
341
|
const fetchFn = input.fetchImpl ?? fetch;
|
|
342
342
|
const eventFound = await queryRelayForEvent(WebSocketCtor, input.relayUrl, input.assetEventId);
|
|
343
|
-
const
|
|
343
|
+
const artifactUrl = input.downloadUrl ?? input.blossomUrl;
|
|
344
|
+
if (!artifactUrl)
|
|
345
|
+
throw new Error("Publish verification requires an artifact URL");
|
|
346
|
+
const head = await fetchFn(artifactUrl, { method: "HEAD" });
|
|
344
347
|
return { eventFound, apkDownloadable: head.status === 200 };
|
|
345
348
|
}
|
|
346
349
|
/** Open a relay, send a REQ for a single event id, wait for it. */
|