pakstr 0.8.7 → 0.10.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/android-template/app/build.gradle.kts +11 -3
- package/bin/cli.js +11 -6
- package/dist/cli.js +53 -16
- package/dist/commands/build.js +42 -114
- package/dist/commands/init.js +238 -0
- package/dist/commands/publish.js +137 -0
- package/dist/commands/run.js +95 -0
- package/dist/commands/sign.js +41 -0
- package/dist/core/androidProject.js +70 -0
- package/dist/core/appIdentity.js +7 -0
- package/dist/core/blossom.js +122 -0
- package/dist/core/dotenv.js +51 -0
- package/dist/core/nostr.js +153 -0
- package/dist/core/pakstrConfig.js +206 -0
- package/dist/core/zapStore.js +221 -0
- package/dist/runners/DockerGradleRunner.js +38 -128
- package/dist/runners/DockerSignRunner.js +182 -0
- package/dist/runners/LocalGradleRunner.js +26 -129
- package/dist/runners/createRunner.js +4 -4
- package/package.json +7 -1
- package/dist/android/releaseVerification.js +0 -164
- package/dist/config.js +0 -4
- package/dist/core/buildContext.js +0 -102
- package/dist/core/config.js +0 -24
- package/dist/core/copyFile.js +0 -17
- package/dist/core/manifest.js +0 -47
- package/dist/core/releaseSigning.js +0 -134
- package/dist/core/resolveProjectInputs.js +0 -20
- package/dist/core/validateProject.js +0 -21
- package/dist/core/zeroConfig.js +0 -97
- package/dist/runtime/runtimeConfig.js +0 -22
|
@@ -21,8 +21,11 @@ android {
|
|
|
21
21
|
|
|
22
22
|
signingConfigs {
|
|
23
23
|
create("release") {
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
// Signing is optional: pakstr build produces an UNSIGNED release APK and
|
|
25
|
+
// pakstr sign signs it with apksigner afterwards. Only wire up the
|
|
26
|
+
// Gradle signingConfig when PAKSTR_ANDROID_KEYSTORE_PATH is present.
|
|
27
|
+
val keystorePath = System.getenv("PAKSTR_ANDROID_KEYSTORE_PATH")
|
|
28
|
+
if (releaseRequested && keystorePath != null && keystorePath.isNotEmpty()) {
|
|
26
29
|
val keystoreFile = File(keystorePath)
|
|
27
30
|
if (!keystoreFile.isFile) {
|
|
28
31
|
throw GradleException("Release signing keystore path is not a file")
|
|
@@ -53,7 +56,12 @@ android {
|
|
|
53
56
|
}
|
|
54
57
|
|
|
55
58
|
release {
|
|
56
|
-
|
|
59
|
+
// Only attach the signing config when a keystore was provided;
|
|
60
|
+
// otherwise emit an unsigned release APK for pakstr sign to sign.
|
|
61
|
+
val keystorePath = System.getenv("PAKSTR_ANDROID_KEYSTORE_PATH")
|
|
62
|
+
if (keystorePath != null && keystorePath.isNotEmpty()) {
|
|
63
|
+
signingConfig = signingConfigs.getByName("release")
|
|
64
|
+
}
|
|
57
65
|
isMinifyEnabled = true
|
|
58
66
|
isShrinkResources = true
|
|
59
67
|
proguardFiles(
|
package/bin/cli.js
CHANGED
|
@@ -3,11 +3,16 @@
|
|
|
3
3
|
const { runCLI } = require("../dist/cli.js");
|
|
4
4
|
|
|
5
5
|
runCLI(process.argv.slice(2))
|
|
6
|
-
|
|
6
|
+
.then(() => {
|
|
7
|
+
// runCLI sets process.exitCode on user-facing errors; nothing to do here.
|
|
8
|
+
})
|
|
7
9
|
.catch((err) => {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
10
|
+
// Known, human-readable errors: print just the message.
|
|
11
|
+
const name = err && err.name;
|
|
12
|
+
if (name === "ConfigError" || name === "NsecError" || err && err.envVar !== undefined) {
|
|
13
|
+
console.error("Error:", err.message);
|
|
14
|
+
} else {
|
|
15
|
+
console.error("Error:", err && err.message ? err.message : err);
|
|
16
|
+
}
|
|
11
17
|
process.exit(1);
|
|
12
|
-
|
|
13
|
-
});
|
|
18
|
+
});
|
package/dist/cli.js
CHANGED
|
@@ -5,8 +5,16 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.runCLI = runCLI;
|
|
7
7
|
const build_1 = require("./commands/build");
|
|
8
|
+
const sign_1 = require("./commands/sign");
|
|
9
|
+
const publish_1 = require("./commands/publish");
|
|
10
|
+
const run_1 = require("./commands/run");
|
|
11
|
+
const init_1 = require("./commands/init");
|
|
12
|
+
const dotenv_1 = require("./core/dotenv");
|
|
8
13
|
const package_json_1 = __importDefault(require("../package.json"));
|
|
9
14
|
async function runCLI(argv) {
|
|
15
|
+
// Auto-load a local `.env` (gitignored) so `pakstr run` works out of the box
|
|
16
|
+
// after `pakstr init`. Existing env vars (e.g. CI secrets) always win.
|
|
17
|
+
(0, dotenv_1.loadDotEnv)();
|
|
10
18
|
if (argv.includes("--help") || argv.includes("-h")) {
|
|
11
19
|
printHelp();
|
|
12
20
|
return;
|
|
@@ -16,41 +24,70 @@ async function runCLI(argv) {
|
|
|
16
24
|
return;
|
|
17
25
|
}
|
|
18
26
|
const command = argv[0];
|
|
19
|
-
const
|
|
20
|
-
const args = argv.slice(2);
|
|
27
|
+
const args = argv.slice(1);
|
|
21
28
|
if (!command) {
|
|
22
29
|
printHelp();
|
|
23
30
|
return;
|
|
24
31
|
}
|
|
32
|
+
const configFlag = extractFlag(args, "--config");
|
|
25
33
|
switch (command) {
|
|
34
|
+
case "init":
|
|
35
|
+
(0, init_1.initCommand)({ force: args.includes("--force"), out: configFlag });
|
|
36
|
+
return;
|
|
26
37
|
case "build":
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
38
|
+
await (0, build_1.buildCommand)(configFlag ?? undefined);
|
|
39
|
+
return;
|
|
40
|
+
case "sign":
|
|
41
|
+
await (0, sign_1.signCommand)(configFlag ?? undefined);
|
|
42
|
+
return;
|
|
43
|
+
case "publish":
|
|
44
|
+
await (0, publish_1.publishCommand)(configFlag ?? undefined, {
|
|
45
|
+
dryRun: args.includes("--dry-run"),
|
|
46
|
+
verify: args.includes("--verify"),
|
|
47
|
+
outJson: extractFlag(args, "--out-json"),
|
|
48
|
+
});
|
|
49
|
+
return;
|
|
50
|
+
case "run":
|
|
51
|
+
await (0, run_1.runCommand)(configFlag ?? undefined, {
|
|
52
|
+
verifyPublish: args.includes("--verify-publish"),
|
|
53
|
+
publishOutJson: extractFlag(args, "--publish-out-json"),
|
|
54
|
+
});
|
|
33
55
|
return;
|
|
34
56
|
default:
|
|
35
|
-
console.error(
|
|
57
|
+
console.error(`Unknown command: ${command}`);
|
|
36
58
|
printHelp();
|
|
37
59
|
process.exitCode = 1;
|
|
38
60
|
return;
|
|
39
61
|
}
|
|
40
62
|
}
|
|
63
|
+
function extractFlag(args, flag) {
|
|
64
|
+
const i = args.indexOf(flag);
|
|
65
|
+
if (i === -1)
|
|
66
|
+
return undefined;
|
|
67
|
+
return args[i + 1];
|
|
68
|
+
}
|
|
41
69
|
function printHelp() {
|
|
42
70
|
console.log(`
|
|
43
|
-
|
|
71
|
+
Pakstr — one-step nsec -> build -> sign -> publish.
|
|
44
72
|
|
|
45
73
|
Usage:
|
|
74
|
+
pakstr init [--force] [--config <path>]
|
|
75
|
+
pakstr build [--config <path>]
|
|
76
|
+
pakstr sign [--config <path>]
|
|
77
|
+
pakstr publish [--config <path>] [--dry-run] [--verify] [--out-json <path>]
|
|
78
|
+
pakstr run [--config <path>] [--verify-publish] [--publish-out-json <path>]
|
|
46
79
|
|
|
47
|
-
pakstr
|
|
80
|
+
pakstr --version
|
|
81
|
+
pakstr --help
|
|
48
82
|
|
|
49
|
-
|
|
83
|
+
Config:
|
|
84
|
+
pakstr.yaml is the single source of truth (see docs/SPEC.md §3).
|
|
85
|
+
Secrets live in env vars, never in the config file:
|
|
86
|
+
PAKSTR_NSEC nsec that derives the APK signing key (and publish auth by default)
|
|
87
|
+
PAKSTR_PUBLISH_NSEC optional separate publish nsec (see publish.publishKey)
|
|
50
88
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
--out ./build/app.apk
|
|
89
|
+
Example:
|
|
90
|
+
pakstr init
|
|
91
|
+
PAKSTR_NSEC=nsec1... pakstr run
|
|
55
92
|
`);
|
|
56
93
|
}
|
package/dist/commands/build.js
CHANGED
|
@@ -6,95 +6,70 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
6
6
|
exports.buildCommand = buildCommand;
|
|
7
7
|
const fs_1 = __importDefault(require("fs"));
|
|
8
8
|
const path_1 = __importDefault(require("path"));
|
|
9
|
-
const child_process_1 = require("child_process");
|
|
10
|
-
const generatedProjectVerification_1 = require("../android/generatedProjectVerification");
|
|
11
|
-
const gradle_1 = require("../android/gradle");
|
|
12
|
-
const branding_1 = require("../android/branding");
|
|
13
|
-
const icon_1 = require("../android/icon");
|
|
14
|
-
const permissions_1 = require("../android/permissions");
|
|
15
|
-
const releaseWorkspace_1 = require("../android/releaseWorkspace");
|
|
16
|
-
const splash_1 = require("../android/splash");
|
|
17
9
|
const template_1 = require("../android/template");
|
|
18
|
-
const
|
|
19
|
-
const
|
|
20
|
-
const
|
|
21
|
-
const validateProject_1 = require("../core/validateProject");
|
|
10
|
+
const releaseWorkspace_1 = require("../android/releaseWorkspace");
|
|
11
|
+
const pakstrConfig_1 = require("../core/pakstrConfig");
|
|
12
|
+
const androidProject_1 = require("../core/androidProject");
|
|
22
13
|
const createRunner_1 = require("../runners/createRunner");
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
console.log("
|
|
32
|
-
console.log("
|
|
33
|
-
console.log("🌐 Web:", ctx.dist);
|
|
14
|
+
/**
|
|
15
|
+
* `pakstr build` — produce an UNSIGNED release APK at `build.out`.
|
|
16
|
+
* Spec §5.2. No nsec required.
|
|
17
|
+
*/
|
|
18
|
+
async function buildCommand(configPath) {
|
|
19
|
+
const config = (0, pakstrConfig_1.loadPakstrConfig)(configPath);
|
|
20
|
+
const finalPath = path_1.default.resolve(config.build.out);
|
|
21
|
+
console.log("\n🚀 pakstr build");
|
|
22
|
+
console.log("📦 App:", config.app.appName);
|
|
23
|
+
console.log("🌐 Web:", config.build.web);
|
|
34
24
|
console.log("📱 Out:", finalPath);
|
|
35
|
-
console.log("🏗️
|
|
36
|
-
console.log("🚀 BUILD MODE:", mode.toUpperCase());
|
|
37
|
-
(0, validateProject_1.validateProject)(ctx.dist, manifest);
|
|
25
|
+
console.log("🏗️ Builder:", config.build.builder);
|
|
38
26
|
validateOutputParent(finalPath);
|
|
39
|
-
|
|
40
|
-
removeExistingOutput(finalPath);
|
|
41
|
-
}
|
|
42
|
-
const signingInput = mode === "release" ? (0, releaseSigning_1.readReleaseSigningInput)() : undefined;
|
|
27
|
+
removeExistingOutput(finalPath);
|
|
43
28
|
const templateRoot = await (0, template_1.ensureTemplate)();
|
|
44
|
-
|
|
45
|
-
let signing;
|
|
29
|
+
const workspace = (0, releaseWorkspace_1.createReleaseWorkspace)(templateRoot);
|
|
46
30
|
let stagedPath;
|
|
47
31
|
let primaryError;
|
|
48
|
-
let cleanupError;
|
|
49
32
|
try {
|
|
50
|
-
const androidRoot =
|
|
51
|
-
? (workspace = (0, releaseWorkspace_1.createReleaseWorkspace)(templateRoot)).androidRoot
|
|
52
|
-
: templateRoot;
|
|
33
|
+
const androidRoot = workspace.androidRoot;
|
|
53
34
|
console.log("📱 Android:", androidRoot);
|
|
54
|
-
await
|
|
55
|
-
if (signingInput) {
|
|
56
|
-
signing = (0, releaseSigning_1.createReleaseSigningContext)(signingInput);
|
|
57
|
-
signingInput.keystoreBase64 = "";
|
|
58
|
-
}
|
|
59
|
-
console.log("\n⚙️ Running build runner...");
|
|
60
|
-
const result = await (0, createRunner_1.createRunner)(config).build({
|
|
35
|
+
await (0, androidProject_1.prepareAndroidProject)({
|
|
61
36
|
androidRoot,
|
|
62
|
-
|
|
37
|
+
webDir: config.build.web,
|
|
38
|
+
app: config.app,
|
|
39
|
+
configDir: config.configDir,
|
|
40
|
+
});
|
|
41
|
+
console.log("\n⚙️ Running build runner...");
|
|
42
|
+
const result = await (0, createRunner_1.createRunner)(config.build.builder).build({
|
|
43
|
+
androidRoot,
|
|
44
|
+
mode: "release",
|
|
63
45
|
expectedMetadata: {
|
|
64
|
-
applicationId:
|
|
65
|
-
versionCode:
|
|
66
|
-
versionName:
|
|
67
|
-
appLabel: appName,
|
|
46
|
+
applicationId: config.app.appId,
|
|
47
|
+
versionCode: config.app.versionCode,
|
|
48
|
+
versionName: config.app.versionName,
|
|
49
|
+
appLabel: config.app.appName,
|
|
68
50
|
},
|
|
69
|
-
signing,
|
|
70
51
|
});
|
|
71
|
-
if (
|
|
72
|
-
|
|
73
|
-
console.warn("ℹ️ CI/Docker release verification will be used.");
|
|
52
|
+
if (!result.unsigned) {
|
|
53
|
+
throw new Error("Build runner did not produce an unsigned release APK");
|
|
74
54
|
}
|
|
75
55
|
stagedPath = stageArtifact(result.artifactPath, finalPath);
|
|
76
56
|
}
|
|
77
57
|
catch (error) {
|
|
78
58
|
primaryError = error;
|
|
79
59
|
}
|
|
60
|
+
let cleanupError;
|
|
80
61
|
try {
|
|
81
|
-
|
|
62
|
+
workspace.cleanup();
|
|
82
63
|
}
|
|
83
64
|
catch (error) {
|
|
84
65
|
cleanupError = error;
|
|
85
66
|
}
|
|
86
|
-
try {
|
|
87
|
-
workspace?.cleanup();
|
|
88
|
-
}
|
|
89
|
-
catch (error) {
|
|
90
|
-
cleanupError ??= error;
|
|
91
|
-
}
|
|
92
67
|
if (primaryError || cleanupError) {
|
|
93
68
|
removeStagedArtifact(stagedPath);
|
|
94
69
|
removeExistingOutput(finalPath);
|
|
95
70
|
if (primaryError) {
|
|
96
71
|
if (cleanupError) {
|
|
97
|
-
console.error("
|
|
72
|
+
console.error("Workspace cleanup also failed:", safeErrorMessage(cleanupError));
|
|
98
73
|
}
|
|
99
74
|
throw primaryError;
|
|
100
75
|
}
|
|
@@ -104,41 +79,17 @@ async function buildCommand(args) {
|
|
|
104
79
|
throw new Error("Build completed without a staged APK");
|
|
105
80
|
}
|
|
106
81
|
publishArtifact(stagedPath, finalPath);
|
|
107
|
-
console.log("\n
|
|
108
|
-
console.log("App:", appName);
|
|
109
|
-
console.log("Mode:", mode.toUpperCase());
|
|
82
|
+
console.log("\n✅ BUILD COMPLETE (unsigned)");
|
|
110
83
|
console.log("📦 Output:", finalPath);
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
async function prepareAndroidProject(androidRoot, distPath, manifest, appName, manifestDir) {
|
|
114
|
-
const assetsTarget = path_1.default.join(androidRoot, "app/src/main/assets/www");
|
|
115
|
-
if (!fs_1.default.existsSync(distPath)) {
|
|
116
|
-
throw new Error("Web assets not found");
|
|
117
|
-
}
|
|
118
|
-
fs_1.default.rmSync(assetsTarget, { recursive: true, force: true });
|
|
119
|
-
fs_1.default.mkdirSync(assetsTarget, { recursive: true });
|
|
120
|
-
copyFolder(distPath, assetsTarget);
|
|
121
|
-
(0, runtimeConfig_1.generateRuntimeConfig)(assetsTarget, manifest);
|
|
122
|
-
(0, gradle_1.patchGradle)(androidRoot, manifest);
|
|
123
|
-
(0, branding_1.patchAppName)(androidRoot, appName);
|
|
124
|
-
if (manifest.ui?.icon) {
|
|
125
|
-
await (0, icon_1.patchIcon)(androidRoot, manifest.ui.icon, manifest.backgroundColor ?? "#FFFFFF", manifestDir);
|
|
126
|
-
}
|
|
127
|
-
if (manifest.ui?.splash) {
|
|
128
|
-
await (0, splash_1.patchSplash)(androidRoot, manifest.ui.splash.image, manifest.ui.splash.background ?? "#FFFFFF", manifestDir);
|
|
129
|
-
}
|
|
130
|
-
(0, permissions_1.patchPermissions)(androidRoot, manifest);
|
|
131
|
-
(0, generatedProjectVerification_1.verifyGeneratedAndroidProject)(androidRoot, {
|
|
132
|
-
applicationId: manifest.appId,
|
|
133
|
-
versionCode: manifest.versionCode,
|
|
134
|
-
versionName: manifest.versionName,
|
|
135
|
-
appLabel: appName,
|
|
136
|
-
});
|
|
84
|
+
console.log("➡️ Next: pakstr sign");
|
|
85
|
+
return finalPath;
|
|
137
86
|
}
|
|
138
87
|
function validateOutputParent(finalPath) {
|
|
139
88
|
const parent = path_1.default.dirname(finalPath);
|
|
140
89
|
if (!fs_1.default.existsSync(parent) || !fs_1.default.lstatSync(parent).isDirectory()) {
|
|
141
|
-
|
|
90
|
+
// Create it per spec §5.2 ("Create the parent directory of build.out if missing").
|
|
91
|
+
fs_1.default.mkdirSync(parent, { recursive: true });
|
|
92
|
+
return;
|
|
142
93
|
}
|
|
143
94
|
if (fs_1.default.lstatSync(parent).isSymbolicLink()) {
|
|
144
95
|
throw new Error(`Output directory must not be a symbolic link: ${parent}`);
|
|
@@ -157,7 +108,7 @@ function stageArtifact(artifactPath, finalPath) {
|
|
|
157
108
|
fs_1.default.copyFileSync(artifactPath, stagedPath, fs_1.default.constants.COPYFILE_EXCL);
|
|
158
109
|
if (fs_1.default.statSync(stagedPath).size !== fs_1.default.statSync(artifactPath).size) {
|
|
159
110
|
fs_1.default.rmSync(stageDir, { recursive: true, force: true });
|
|
160
|
-
throw new Error("Staged APK size does not match
|
|
111
|
+
throw new Error("Staged APK size does not match built artifact");
|
|
161
112
|
}
|
|
162
113
|
return stagedPath;
|
|
163
114
|
}
|
|
@@ -183,29 +134,6 @@ function removeStagedArtifact(stagedPath) {
|
|
|
183
134
|
return;
|
|
184
135
|
fs_1.default.rmSync(path_1.default.dirname(stagedPath), { recursive: true, force: true });
|
|
185
136
|
}
|
|
186
|
-
function copyFolder(src, dest) {
|
|
187
|
-
for (const entry of fs_1.default.readdirSync(src, { withFileTypes: true })) {
|
|
188
|
-
const srcPath = path_1.default.join(src, entry.name);
|
|
189
|
-
const destPath = path_1.default.join(dest, entry.name);
|
|
190
|
-
if (entry.isDirectory()) {
|
|
191
|
-
fs_1.default.mkdirSync(destPath, { recursive: true });
|
|
192
|
-
copyFolder(srcPath, destPath);
|
|
193
|
-
}
|
|
194
|
-
else {
|
|
195
|
-
fs_1.default.copyFileSync(srcPath, destPath);
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
function openFolder(filePath) {
|
|
200
|
-
if (process.platform !== "darwin" || process.env.CI)
|
|
201
|
-
return;
|
|
202
|
-
try {
|
|
203
|
-
(0, child_process_1.execFileSync)("open", [path_1.default.dirname(filePath)], { stdio: "ignore" });
|
|
204
|
-
}
|
|
205
|
-
catch {
|
|
206
|
-
// Opening the output folder is optional.
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
137
|
function safeErrorMessage(error) {
|
|
210
138
|
return error instanceof Error ? error.message : "unknown cleanup error";
|
|
211
139
|
}
|
|
@@ -0,0 +1,238 @@
|
|
|
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.initCommand = initCommand;
|
|
7
|
+
const fs_1 = __importDefault(require("fs"));
|
|
8
|
+
const path_1 = __importDefault(require("path"));
|
|
9
|
+
const crypto_1 = require("crypto");
|
|
10
|
+
const pakstrConfig_1 = require("../core/pakstrConfig");
|
|
11
|
+
const nostr_1 = require("../core/nostr");
|
|
12
|
+
/**
|
|
13
|
+
* `pakstr init` — generate `pakstr.yaml` in the current directory.
|
|
14
|
+
* Spec §5.1. Infers defaults from `package.json`. Never silently overwrites.
|
|
15
|
+
*/
|
|
16
|
+
const HEADER = `# pakstr.yaml — one file, one command, one APK.
|
|
17
|
+
# Generated by \`pakstr init\`. Edit freely.
|
|
18
|
+
#
|
|
19
|
+
# ⚠️ Keep your nsec OUT of this file. \`pakstr init\` wrote a local dev nsec
|
|
20
|
+
# to .env (gitignored). For real releases, put ${nostr_1.PAKSTR_NSEC_ENV} in your CI
|
|
21
|
+
# secret manager — reuse the same nsec so updates install over prior APKs.
|
|
22
|
+
# WARNING: ${nostr_1.PAKSTR_NSEC_ENV} is a production release-signing root secret. Anyone who
|
|
23
|
+
# has it can derive every app identity for every known appId. Losing it breaks
|
|
24
|
+
# update continuity. It cannot rotate the signer of an already-published app.
|
|
25
|
+
`;
|
|
26
|
+
async function initCommand(options = {}) {
|
|
27
|
+
const configPath = path_1.default.resolve(options.out ?? path_1.default.join(process.cwd(), pakstrConfig_1.PAKSTR_CONFIG_FILENAME));
|
|
28
|
+
if (fs_1.default.existsSync(configPath) && !options.force) {
|
|
29
|
+
throw new Error(`${configPath} already exists. Use --force to overwrite, or remove it first.`);
|
|
30
|
+
}
|
|
31
|
+
const pkg = readPackageJson();
|
|
32
|
+
const { appId, sanitized: appIdSanitized } = inferAppId(pkg);
|
|
33
|
+
const appName = inferAppName(pkg) ?? appNameFromAppId(appId);
|
|
34
|
+
const versionName = (pkg && typeof pkg.version === "string" ? pkg.version : "1.0.0");
|
|
35
|
+
const versionCode = 1;
|
|
36
|
+
const web = inferWebDir();
|
|
37
|
+
const out = `./build/${appId}.apk`;
|
|
38
|
+
const yaml = renderConfig({
|
|
39
|
+
appId,
|
|
40
|
+
appName,
|
|
41
|
+
versionName,
|
|
42
|
+
versionCode,
|
|
43
|
+
description: pkg && typeof pkg.description === "string" ? pkg.description : "",
|
|
44
|
+
web,
|
|
45
|
+
out,
|
|
46
|
+
});
|
|
47
|
+
fs_1.default.writeFileSync(configPath, yaml, "utf8");
|
|
48
|
+
console.log(`✅ Wrote ${configPath}`);
|
|
49
|
+
if (appIdSanitized) {
|
|
50
|
+
console.log(`⚠️ Your package.json name contains characters that aren't valid in an Android package`);
|
|
51
|
+
console.log(` name (e.g. hyphens). pakstr sanitized it to appId "${appId}". Edit`);
|
|
52
|
+
console.log(` ${pakstrConfig_1.PAKSTR_CONFIG_FILENAME} if you'd prefer a different value.`);
|
|
53
|
+
}
|
|
54
|
+
// Generate a fresh dev nsec into a gitignored `.env` so `pakstr run` works
|
|
55
|
+
// locally with no manual setup. Never overwrites an existing PAKSTR_NSEC.
|
|
56
|
+
const { nsec: devNsec, status: nsecStatus } = ensureDevNsec();
|
|
57
|
+
ensureGitIgnoresEnv();
|
|
58
|
+
// Write zapstore.yaml (relay-side publisher whitelist) with the publisher
|
|
59
|
+
// npub derived from the dev nsec. Never overwrites an existing one.
|
|
60
|
+
const zapstoreStatus = await ensureZapstoreYaml(devNsec);
|
|
61
|
+
console.log("");
|
|
62
|
+
console.log("➡️ Next: run pakstr run");
|
|
63
|
+
if (nsecStatus === "generated") {
|
|
64
|
+
console.log(` A fresh ${nostr_1.PAKSTR_NSEC_ENV} was written to .env (gitignored) for local dev.`);
|
|
65
|
+
}
|
|
66
|
+
else if (nsecStatus === "present") {
|
|
67
|
+
console.log(` ${nostr_1.PAKSTR_NSEC_ENV} already in .env — left untouched.`);
|
|
68
|
+
}
|
|
69
|
+
if (zapstoreStatus === "generated") {
|
|
70
|
+
console.log(" zapstore.yaml written (relay-side publisher whitelist). Commit it.");
|
|
71
|
+
}
|
|
72
|
+
else if (zapstoreStatus === "present") {
|
|
73
|
+
console.log(" zapstore.yaml already exists — left untouched.");
|
|
74
|
+
}
|
|
75
|
+
console.log("");
|
|
76
|
+
console.log(` For real releases, put the SAME ${nostr_1.PAKSTR_NSEC_ENV} in your CI secret manager`);
|
|
77
|
+
console.log(" (reusing it is what lets an update install over the previous APK).");
|
|
78
|
+
console.log(` ${nostr_1.PAKSTR_NSEC_ENV} is a release-signing root secret: anyone who has it can`);
|
|
79
|
+
console.log(" sign updates for every app whose appId they know. Keep it safe.");
|
|
80
|
+
}
|
|
81
|
+
/** Generate a dev nsec into .env if none is present there. Returns the nsec string. */
|
|
82
|
+
function ensureDevNsec() {
|
|
83
|
+
const envPath = path_1.default.join(process.cwd(), ".env");
|
|
84
|
+
const existing = fs_1.default.existsSync(envPath)
|
|
85
|
+
? fs_1.default.readFileSync(envPath, "utf8")
|
|
86
|
+
: "";
|
|
87
|
+
if (containsPakstrNsec(existing)) {
|
|
88
|
+
const m = existing.match(new RegExp(`^${nostr_1.PAKSTR_NSEC_ENV}=(\\S+)`, "m"));
|
|
89
|
+
return { nsec: m?.[1] ?? "", status: "present" };
|
|
90
|
+
}
|
|
91
|
+
const nsec = (0, nostr_1.encodeNsec)((0, crypto_1.randomBytes)(32));
|
|
92
|
+
const line = `${nostr_1.PAKSTR_NSEC_ENV}=${nsec}\n`;
|
|
93
|
+
const header = existing.length === 0 ? "# Local development secrets — never commit this file.\n" : "";
|
|
94
|
+
fs_1.default.writeFileSync(envPath, header + (existing.length ? "\n" + line : line), {
|
|
95
|
+
flag: existing.length === 0 ? "wx" : "a",
|
|
96
|
+
});
|
|
97
|
+
return { nsec, status: "generated" };
|
|
98
|
+
}
|
|
99
|
+
function containsPakstrNsec(envText) {
|
|
100
|
+
return new RegExp(`^${nostr_1.PAKSTR_NSEC_ENV}=`, "m").test(envText);
|
|
101
|
+
}
|
|
102
|
+
/** Ensure `.env` is listed in `.gitignore` so the nsec is never committed. */
|
|
103
|
+
function ensureGitIgnoresEnv() {
|
|
104
|
+
const gitignorePath = path_1.default.join(process.cwd(), ".gitignore");
|
|
105
|
+
const existing = fs_1.default.existsSync(gitignorePath)
|
|
106
|
+
? fs_1.default.readFileSync(gitignorePath, "utf8")
|
|
107
|
+
: "";
|
|
108
|
+
if (containsEnvEntry(existing))
|
|
109
|
+
return;
|
|
110
|
+
const addition = existing.length === 0 || existing.endsWith("\n")
|
|
111
|
+
? ".env\n"
|
|
112
|
+
: "\n.env\n";
|
|
113
|
+
fs_1.default.writeFileSync(gitignorePath, existing + addition, {
|
|
114
|
+
flag: existing.length === 0 ? "wx" : "a",
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
function containsEnvEntry(gitignoreText) {
|
|
118
|
+
return gitignoreText
|
|
119
|
+
.split(/\r?\n/)
|
|
120
|
+
.some(line => line.trim() === ".env" || line.trim().startsWith(".env"));
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Write `zapstore.yaml` (relay-side publisher whitelist) with the publisher
|
|
124
|
+
* npub derived from the dev nsec. Never overwrites an existing file.
|
|
125
|
+
* Returns what happened.
|
|
126
|
+
*/
|
|
127
|
+
async function ensureZapstoreYaml(devNsec) {
|
|
128
|
+
const zapstorePath = path_1.default.join(process.cwd(), "zapstore.yaml");
|
|
129
|
+
if (fs_1.default.existsSync(zapstorePath))
|
|
130
|
+
return "present";
|
|
131
|
+
let npub = "";
|
|
132
|
+
if (devNsec) {
|
|
133
|
+
try {
|
|
134
|
+
const secret = (0, nostr_1.decodeNsec)(devNsec);
|
|
135
|
+
const hex = await (0, nostr_1.getNpubHex)(secret);
|
|
136
|
+
npub = (0, nostr_1.pubkeyHexToNpub)(hex);
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
// If we can't derive the npub, write the file with a placeholder so the
|
|
140
|
+
// user knows to fill it in.
|
|
141
|
+
npub = "";
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const content = `# zapstore.yaml — relay-side publisher whitelist (NIP-82 / Zap Store).
|
|
145
|
+
# The relay fetches this file from your repo to verify the publisher's pubkey
|
|
146
|
+
# is authorized for this repository. Generated by \`pakstr init\`.
|
|
147
|
+
#
|
|
148
|
+
# \`pubkey\` is the npub matching the nsec used to publish (PAKSTR_NSEC, or
|
|
149
|
+
# PAKSTR_PUBLISH_NSEC if you set publish.publishKey). Replace it with your
|
|
150
|
+
# real publisher npub for production releases if different from your dev nsec.
|
|
151
|
+
|
|
152
|
+
repository: # TODO: set to your app's source code repository URL
|
|
153
|
+
pubkey: ${npub || "# TODO: set to your publisher npub (npub1...)"}
|
|
154
|
+
`;
|
|
155
|
+
fs_1.default.writeFileSync(zapstorePath, content, "utf8");
|
|
156
|
+
return "generated";
|
|
157
|
+
}
|
|
158
|
+
function renderConfig(c) {
|
|
159
|
+
const perms = ` [${pakstrConfig_1.PERMISSION_ALIASES.map(p => `"${p}"`).join(", ")}]`;
|
|
160
|
+
return `${HEADER}
|
|
161
|
+
app:
|
|
162
|
+
appId: ${c.appId} # REQUIRED. Java package id; also feeds key derivation.
|
|
163
|
+
appName: ${escapeYamlScalar(c.appName)} # REQUIRED. Launcher label.
|
|
164
|
+
versionName: "${c.versionName}" # REQUIRED. Human-readable version.
|
|
165
|
+
versionCode: ${c.versionCode} # REQUIRED. Monotonic integer.
|
|
166
|
+
description: ${c.description === "" ? '""' : escapeYamlScalar(c.description)} # OPTIONAL.
|
|
167
|
+
# icon: ./icon.png # OPTIONAL. Resolved relative to this file.
|
|
168
|
+
# splash: # OPTIONAL.
|
|
169
|
+
# image: ./splash.png
|
|
170
|
+
# background: "#0f0f17"
|
|
171
|
+
# backgroundColor: "#0f0f17" # OPTIONAL. Launcher icon background.
|
|
172
|
+
# permissions: ${perms} # OPTIONAL. Aliases (case-insensitive): ${pakstrConfig_1.PERMISSION_ALIASES.join(", ")}.
|
|
173
|
+
|
|
174
|
+
build:
|
|
175
|
+
web: ${c.web} # REQUIRED. Built web assets (must contain index.html).
|
|
176
|
+
out: ${c.out} # OPTIONAL. Default ./build/<appId>.apk
|
|
177
|
+
builder: docker # Only "docker" is specified.
|
|
178
|
+
|
|
179
|
+
publish:
|
|
180
|
+
zapstore:
|
|
181
|
+
enabled: true # Default true when publish is present.
|
|
182
|
+
# publishKey: PAKSTR_PUBLISH_NSEC # OPTIONAL. Omit to reuse ${nostr_1.PAKSTR_NSEC_ENV} for publishing.
|
|
183
|
+
`;
|
|
184
|
+
}
|
|
185
|
+
function escapeYamlScalar(value) {
|
|
186
|
+
// Keep it simple: quote if it contains anything that looks YAML-special.
|
|
187
|
+
if (value === "" || /[:#\-?*&!|>'"%@\`\[\]{}]/.test(value) || /^\s|\s$/.test(value)) {
|
|
188
|
+
return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
|
|
189
|
+
}
|
|
190
|
+
return value;
|
|
191
|
+
}
|
|
192
|
+
function readPackageJson() {
|
|
193
|
+
const p = path_1.default.join(process.cwd(), "package.json");
|
|
194
|
+
if (!fs_1.default.existsSync(p))
|
|
195
|
+
return null;
|
|
196
|
+
try {
|
|
197
|
+
return JSON.parse(fs_1.default.readFileSync(p, "utf8"));
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
function inferAppId(pkg) {
|
|
204
|
+
const name = pkg && typeof pkg.name === "string" ? pkg.name : null;
|
|
205
|
+
if (!name)
|
|
206
|
+
return { appId: "dev.pakstr.myapp", sanitized: false };
|
|
207
|
+
// Split the npm name into segments (handling @scope/name), sanitize each,
|
|
208
|
+
// and track whether any segment changed so we can warn the user.
|
|
209
|
+
const raw = name.startsWith("@") ? name.slice(1).split("/") : [name];
|
|
210
|
+
const segments = raw.map(s => s.toLowerCase());
|
|
211
|
+
const sanitized = segments.map(s => (0, pakstrConfig_1.sanitizeAppIdSegment)(s) ?? "");
|
|
212
|
+
const changed = segments.some((orig, i) => sanitized[i] !== orig);
|
|
213
|
+
const valid = sanitized.filter(s => s.length > 0);
|
|
214
|
+
const appId = valid.length > 0 ? `dev.pakstr.${valid.join(".")}` : "dev.pakstr.myapp";
|
|
215
|
+
// Final guard: if it still doesn't match the pattern, fall back.
|
|
216
|
+
return pakstrConfig_1.APP_ID_PATTERN.test(appId)
|
|
217
|
+
? { appId, sanitized: changed }
|
|
218
|
+
: { appId: "dev.pakstr.myapp", sanitized: true };
|
|
219
|
+
}
|
|
220
|
+
function inferAppName(pkg) {
|
|
221
|
+
const name = pkg && typeof pkg.name === "string" ? pkg.name : null;
|
|
222
|
+
if (!name)
|
|
223
|
+
return null;
|
|
224
|
+
const unscoped = name.replace(/^@[^/]+\//, "");
|
|
225
|
+
return unscoped || null;
|
|
226
|
+
}
|
|
227
|
+
function appNameFromAppId(appId) {
|
|
228
|
+
const last = appId.split(".").pop() ?? appId;
|
|
229
|
+
return last.charAt(0).toUpperCase() + last.slice(1);
|
|
230
|
+
}
|
|
231
|
+
function inferWebDir() {
|
|
232
|
+
const cwd = process.cwd();
|
|
233
|
+
if (fs_1.default.existsSync(path_1.default.join(cwd, "dist", "index.html")))
|
|
234
|
+
return "./dist";
|
|
235
|
+
if (fs_1.default.existsSync(path_1.default.join(cwd, "build", "index.html")))
|
|
236
|
+
return "./build";
|
|
237
|
+
return "./dist";
|
|
238
|
+
}
|