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.
@@ -21,8 +21,11 @@ android {
21
21
 
22
22
  signingConfigs {
23
23
  create("release") {
24
- if (releaseRequested) {
25
- val keystorePath = requireReleaseSigningValue("PAKSTR_ANDROID_KEYSTORE_PATH")
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
- signingConfig = signingConfigs.getByName("release")
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
- console.error("❌ CLI crashed:", err);
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 subcommand = argv[1];
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
- if (subcommand === "apk") {
28
- console.log("🚀 Running: build apk");
29
- return await (0, build_1.buildCommand)(args);
30
- }
31
- console.error("❌ Missing subcommand. Use: build apk");
32
- process.exitCode = 1;
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(`❌ Unknown command: ${command}`);
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
- 🚀 Pakstr CLI
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 build apk --manifest <path> --web <path> --out <path>
80
+ pakstr --version
81
+ pakstr --help
48
82
 
49
- Example:
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
- pakstr build apk \
52
- --manifest app.manifest.json \
53
- --web ./dist \
54
- --out ./build/app.apk
89
+ Example:
90
+ pakstr init
91
+ PAKSTR_NSEC=nsec1... pakstr run
55
92
  `);
56
93
  }
@@ -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 buildContext_1 = require("../core/buildContext");
19
- const config_1 = require("../core/config");
20
- const releaseSigning_1 = require("../core/releaseSigning");
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
- const runtimeConfig_1 = require("../runtime/runtimeConfig");
24
- async function buildCommand(args) {
25
- const ctx = (0, buildContext_1.createBuildContext)(args);
26
- const config = (0, config_1.loadConfig)(process.cwd());
27
- const manifest = ctx.manifest;
28
- const mode = ctx.mode;
29
- const appName = manifest.appName;
30
- const finalPath = path_1.default.resolve(ctx.out);
31
- console.log("\n🚀 Pakstr CLI");
32
- console.log("📦 App:", appName);
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("🏗️ Builder:", config.builder);
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
- if (mode === "release") {
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
- let workspace;
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 = mode === "release"
51
- ? (workspace = (0, releaseWorkspace_1.createReleaseWorkspace)(templateRoot)).androidRoot
52
- : templateRoot;
33
+ const androidRoot = workspace.androidRoot;
53
34
  console.log("📱 Android:", androidRoot);
54
- await prepareAndroidProject(androidRoot, ctx.dist, manifest, appName, ctx.manifestDir);
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
- mode,
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: manifest.appId,
65
- versionCode: manifest.versionCode,
66
- versionName: manifest.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 (mode === "release" && !result.verification) {
72
- console.warn("⚠️ Release verification evidence unavailable locally.");
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
- signing?.cleanup();
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("Cleanup also failed:", safeErrorMessage(cleanupError));
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🚀 BUILD COMPLETE");
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
- openFolder(finalPath);
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
- throw new Error(`Output directory must already exist: ${parent}`);
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 verified artifact");
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
+ }