pake-cli 3.14.0 โ†’ 3.15.1

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/README.md CHANGED
@@ -192,6 +192,8 @@ pake https://weekly.tw93.fun --name Weekly --icon https://cdn.tw93.fun/pake/week
192
192
 
193
193
  First-time packaging requires environment setup and may be slower, subsequent builds are fast. For complete parameter documentation, see [CLI Usage Guide](docs/cli-usage.md). Don't want to use CLI? Try [GitHub Actions Online Building](docs/github-actions-usage.md).
194
194
 
195
+ Using Pake from a script or AI agent? Pass `--json` for machine-readable results, describe apps declaratively with `--config app.json` ([schema](schema/pake.schema.json)), and package local build output directly with `pake ./dist --name MyTool`. See [llms.txt](llms.txt) for the full agent contract. Claude Code users can install the official skill with `/plugin marketplace add tw93/Pake` and `/plugin install pake@pake`.
196
+
195
197
  ## Development
196
198
 
197
199
  Requires Rust `>=1.85` and Node `>=22` (recommended LTS; `>=18` also works). For detailed installation guide, see [Tauri documentation](https://v2.tauri.app/start/prerequisites/). If unfamiliar with development environment, use the CLI tool instead.
package/dist/cli.js CHANGED
@@ -20,7 +20,7 @@ import * as psl from 'psl';
20
20
  import { InvalidArgumentError, program as program$1, Option } from 'commander';
21
21
 
22
22
  var name = "pake-cli";
23
- var version = "3.14.0";
23
+ var version = "3.15.1";
24
24
  var description = "๐Ÿคฑ๐Ÿป Turn any webpage into a desktop app with one command. ๐Ÿคฑ๐Ÿป ไธ€้”ฎๆ‰“ๅŒ…็ฝ‘้กต็”Ÿๆˆ่ฝป้‡ๆกŒ้ขๅบ”็”จใ€‚";
25
25
  var engines = {
26
26
  node: ">=18.0.0"
@@ -172,6 +172,58 @@ let tauriConfig = {
172
172
  pake: pakeConf,
173
173
  };
174
174
 
175
+ // Stable exit-code contract: 0 success, 2 invalid input, 3 build/network
176
+ // failure, 4 missing environment, 1 unexpected. Documented in cli-usage docs.
177
+ const ERROR_EXIT_CODES = {
178
+ INVALID_INPUT: 2,
179
+ BUILD_FAILED: 3,
180
+ NETWORK: 3,
181
+ ENV_MISSING: 4,
182
+ UNEXPECTED: 1,
183
+ };
184
+ let machineMode = false;
185
+ const capturedWarnings = [];
186
+ /**
187
+ * Route all loglevel output to stderr, capture warnings for the final JSON
188
+ * result, and strip ANSI colors. Must be called before any logging happens.
189
+ */
190
+ function enableMachineMode() {
191
+ if (machineMode)
192
+ return;
193
+ machineMode = true;
194
+ chalk.level = 0;
195
+ log.methodFactory = (methodName) => {
196
+ return (...args) => {
197
+ if (methodName === 'warn') {
198
+ capturedWarnings.push(args.map(String).join(' '));
199
+ }
200
+ console.error(...args);
201
+ };
202
+ };
203
+ // Rebuild logging methods with the new factory.
204
+ log.setLevel(log.getLevel());
205
+ }
206
+ function isMachineMode() {
207
+ return machineMode;
208
+ }
209
+ function getCapturedWarnings() {
210
+ return [...capturedWarnings];
211
+ }
212
+ /**
213
+ * Whether Pake may prompt the user. False in machine mode, without a TTY,
214
+ * or inside CI, where prompts would hang or produce garbage.
215
+ */
216
+ function isInteractive() {
217
+ return (!machineMode &&
218
+ Boolean(process.stdin.isTTY) &&
219
+ Boolean(process.stdout.isTTY) &&
220
+ !process.env.CI &&
221
+ !process.env.GITHUB_ACTIONS);
222
+ }
223
+ function printJsonResult(result) {
224
+ process.stdout.write(`${JSON.stringify(result)}\n`);
225
+ }
226
+
175
227
  // Generates a stable identifier based on the app URL (and optionally name).
176
228
  // When name is provided it is included in the hash so two apps wrapping
177
229
  // the same URL can coexist. Omitting name preserves backward compatibility
@@ -217,6 +269,8 @@ function getSpinner(text) {
217
269
  text: `${chalk.cyan(text)}\n`,
218
270
  spinner: loadingType,
219
271
  color: 'cyan',
272
+ // In machine mode stdout must stay parseable and stderr low-noise.
273
+ isSilent: isMachineMode(),
220
274
  }).start();
221
275
  }
222
276
 
@@ -324,7 +378,11 @@ async function shellExec(command, timeout = 300000, env) {
324
378
  cwd: npmDirectory,
325
379
  // Use 'inherit' to show all output directly to user in real-time.
326
380
  // This ensures linuxdeploy and other tool outputs are visible during builds.
327
- stdio: 'inherit',
381
+ // In machine mode (--json) stdout is reserved for the final JSON result,
382
+ // so subprocess stdout is rerouted to stderr instead.
383
+ stdin: 'inherit',
384
+ stdout: isMachineMode() ? process.stderr : 'inherit',
385
+ stderr: 'inherit',
328
386
  shell: true,
329
387
  timeout,
330
388
  env: env ? { ...process.env, ...env } : process.env,
@@ -502,6 +560,31 @@ function generateIdentifierSafeName(name) {
502
560
  return cleaned;
503
561
  }
504
562
 
563
+ /**
564
+ * Error class used for user-facing CLI errors.
565
+ *
566
+ * The top-level catch in `bin/cli.ts` prints `message` directly without a
567
+ * stack trace and exits with the code mapped from `code` (see
568
+ * ERROR_EXIT_CODES in utils/output.ts). Use this for predictable failures
569
+ * (invalid names, missing files, etc.) so users see a clean message instead
570
+ * of a Node.js stack dump. `code` and `hint` also feed the `--json` result.
571
+ */
572
+ class PakeError extends Error {
573
+ constructor(message, options) {
574
+ super(message);
575
+ this.isUserError = true;
576
+ this.name = 'PakeError';
577
+ this.code = options?.code;
578
+ this.hint = options?.hint;
579
+ }
580
+ }
581
+ function isPakeError(error) {
582
+ return (error instanceof PakeError ||
583
+ (typeof error === 'object' &&
584
+ error !== null &&
585
+ error.isUserError === true));
586
+ }
587
+
505
588
  const LINUX_TARGET_TYPES = ['deb', 'appimage', 'rpm', 'zst'];
506
589
  // Returns the valid Linux build targets from a comma-separated targets
507
590
  // string, preserving LINUX_TARGET_TYPES order. Unknown entries are dropped.
@@ -589,30 +672,108 @@ async function copyTemplateConfigs() {
589
672
  }
590
673
  }));
591
674
  }
592
- async function handleLocalFile(url, useLocalFile, tauriConf) {
593
- const pathExists = await fsExtra.pathExists(url);
594
- if (pathExists) {
595
- logger.warn('โœผ Your input might be a local file.');
596
- const fileName = path.basename(url);
597
- const dirName = path.dirname(url);
598
- const distDir = path.join(npmDirectory, 'dist');
599
- const distBakDir = path.join(npmDirectory, 'dist_bak');
600
- if (!useLocalFile) {
601
- const urlPath = path.join(distDir, fileName);
602
- await fsExtra.copy(url, urlPath);
675
+ // Replace the CLI's own dist/ with the user's static files while keeping the
676
+ // build artifacts (cli.js) the packaged app does not need but the CLI does.
677
+ // dist_bak always holds the ORIGINAL package dist: once it exists, later
678
+ // stagings must not overwrite it with a previous user tree, or the original
679
+ // files would be unrecoverable across repeated local builds.
680
+ async function stageLocalTree(sourceDir) {
681
+ const distDir = path.join(npmDirectory, 'dist');
682
+ const distBakDir = path.join(npmDirectory, 'dist_bak');
683
+ // Resolve symlinked input up front: staging must produce a real copy, or
684
+ // the cli.js copy-back below would write through the link into the user's
685
+ // own directory.
686
+ const resolvedSource = await fsExtra.realpath(sourceDir);
687
+ const resolvedPackage = await fsExtra
688
+ .realpath(npmDirectory)
689
+ .catch(() => path.resolve(npmDirectory));
690
+ const packageDist = path.join(resolvedPackage, 'dist');
691
+ if (resolvedSource === resolvedPackage ||
692
+ resolvedPackage.startsWith(resolvedSource + path.sep) ||
693
+ resolvedSource === packageDist ||
694
+ resolvedSource.startsWith(packageDist + path.sep)) {
695
+ throw new PakeError(`Local input "${sourceDir}" contains the Pake CLI installation itself.`, {
696
+ code: 'INVALID_INPUT',
697
+ hint: 'Point Pake at your built output directory, not at a directory containing pake-cli.',
698
+ });
699
+ }
700
+ try {
701
+ if (await fsExtra.pathExists(distBakDir)) {
702
+ fsExtra.removeSync(distDir);
603
703
  }
604
704
  else {
605
- fsExtra.moveSync(distDir, distBakDir, { overwrite: true });
606
- fsExtra.copySync(dirName, distDir, { overwrite: true });
607
- const filesToCopyBack = ['cli.js'];
608
- await Promise.all(filesToCopyBack.map((file) => fsExtra.copy(path.join(distBakDir, file), path.join(distDir, file))));
705
+ fsExtra.moveSync(distDir, distBakDir);
609
706
  }
610
- tauriConf.pake.windows[0].url = fileName;
707
+ fsExtra.copySync(resolvedSource, distDir, {
708
+ overwrite: true,
709
+ dereference: true,
710
+ });
711
+ const filesToCopyBack = ['cli.js'];
712
+ await Promise.all(filesToCopyBack.map((file) => fsExtra.copy(path.join(distBakDir, file), path.join(distDir, file))));
713
+ }
714
+ catch (error) {
715
+ // Never leave the package without its own dist/: cli.js lives there and
716
+ // every later `pake` invocation would fail until a manual reinstall.
717
+ restoreLocalTree();
718
+ throw error;
719
+ }
720
+ }
721
+ // Put the package's original dist/ back once a local-input run is over (or
722
+ // failed). Tauri bakes `frontendDist: ../dist` into every binary, so a stale
723
+ // staged tree would leak this user's files into the next app built from the
724
+ // same install. Safe to call on any run: a present dist_bak always holds the
725
+ // original package dist, including one stranded by an older crashed run.
726
+ function restoreLocalTree() {
727
+ const distDir = path.join(npmDirectory, 'dist');
728
+ const distBakDir = path.join(npmDirectory, 'dist_bak');
729
+ if (!fsExtra.pathExistsSync(distBakDir)) {
730
+ return;
731
+ }
732
+ try {
733
+ fsExtra.removeSync(distDir);
734
+ fsExtra.moveSync(distBakDir, distDir);
735
+ }
736
+ catch (error) {
737
+ const detail = error instanceof Error ? error.message : String(error);
738
+ logger.warn(`Failed to restore the CLI's original dist/ from dist_bak: ${detail}`);
739
+ }
740
+ }
741
+ // Exported for unit tests (web fallback and directory entry guard).
742
+ async function handleLocalFile(url, useLocalFile, tauriConf) {
743
+ const pathExists = await fsExtra.pathExists(url);
744
+ if (!pathExists) {
745
+ tauriConf.pake.windows[0].url_type = 'web';
746
+ return;
747
+ }
748
+ const stat = await fsExtra.stat(url);
749
+ if (stat.isDirectory()) {
750
+ // A directory of static web assets (e.g. a generated dist/): the whole
751
+ // tree is packaged and the app entry is its root index.html.
752
+ const entryFile = 'index.html';
753
+ if (!(await fsExtra.pathExists(path.join(url, entryFile)))) {
754
+ throw new PakeError(`Local directory "${url}" has no ${entryFile} at its root.`, {
755
+ code: 'INVALID_INPUT',
756
+ hint: 'Point Pake at the built output directory that contains index.html.',
757
+ });
758
+ }
759
+ logger.info(`โœบ Packaging local directory: ${url}`);
760
+ await stageLocalTree(url);
761
+ tauriConf.pake.windows[0].url = entryFile;
611
762
  tauriConf.pake.windows[0].url_type = 'local';
763
+ return;
764
+ }
765
+ logger.info(`โœบ Packaging local file: ${url}`);
766
+ const fileName = path.basename(url);
767
+ const distDir = path.join(npmDirectory, 'dist');
768
+ if (!useLocalFile) {
769
+ const urlPath = path.join(distDir, fileName);
770
+ await fsExtra.copy(url, urlPath);
612
771
  }
613
772
  else {
614
- tauriConf.pake.windows[0].url_type = 'web';
773
+ await stageLocalTree(path.dirname(url));
615
774
  }
775
+ tauriConf.pake.windows[0].url = fileName;
776
+ tauriConf.pake.windows[0].url_type = 'local';
616
777
  }
617
778
  function buildLinuxDesktopContent(name, title, linuxBinaryName) {
618
779
  const chineseName = title && /[\u4e00-\u9fa5]/.test(title) ? title : null;
@@ -1048,8 +1209,61 @@ const APPIMAGE_FAILURE_GUIDANCE = `\n\n${APPIMAGE_BAR}\n` +
1048
1209
  APPIMAGE_BAR;
1049
1210
  class BaseBuilder {
1050
1211
  constructor(options) {
1212
+ this.artifacts = [];
1051
1213
  this.options = options;
1052
1214
  }
1215
+ /** Final artifacts produced by this build, for the `--json` result. */
1216
+ getArtifacts() {
1217
+ return [...this.artifacts];
1218
+ }
1219
+ /** Architecture reported in the `--json` result. */
1220
+ getReportArch() {
1221
+ return this.options.multiArch ? 'universal' : process.arch;
1222
+ }
1223
+ // Drop a recorded artifact whose file was later removed (e.g. the
1224
+ // temporary .deb consumed by zst repacking), so --json never lists a
1225
+ // path that no longer exists.
1226
+ removeArtifact(artifactPath) {
1227
+ const resolved = path.resolve(artifactPath);
1228
+ this.artifacts = this.artifacts.filter((artifact) => artifact.path !== resolved);
1229
+ }
1230
+ async recordArtifact(artifactPath, format) {
1231
+ try {
1232
+ const stat = await fsExtra.stat(artifactPath);
1233
+ let sizeBytes = stat.size;
1234
+ if (stat.isDirectory()) {
1235
+ sizeBytes = await BaseBuilder.getPathSize(artifactPath);
1236
+ }
1237
+ this.artifacts.push({
1238
+ path: path.resolve(artifactPath),
1239
+ sizeBytes,
1240
+ format,
1241
+ });
1242
+ }
1243
+ catch {
1244
+ // Never fail a finished build over size bookkeeping.
1245
+ this.artifacts.push({
1246
+ path: path.resolve(artifactPath),
1247
+ sizeBytes: 0,
1248
+ format,
1249
+ });
1250
+ }
1251
+ }
1252
+ static async getPathSize(directory) {
1253
+ let size = 0;
1254
+ for (const entry of await fsExtra.readdir(directory, {
1255
+ withFileTypes: true,
1256
+ })) {
1257
+ const entryPath = path.join(directory, entry.name);
1258
+ if (entry.isDirectory()) {
1259
+ size += await BaseBuilder.getPathSize(entryPath);
1260
+ }
1261
+ else if (entry.isFile()) {
1262
+ size += (await fsExtra.stat(entryPath)).size;
1263
+ }
1264
+ }
1265
+ return size;
1266
+ }
1053
1267
  async prepare() {
1054
1268
  const tauriSrcPath = path.join(npmDirectory, 'src-tauri');
1055
1269
  const tauriTargetPath = path.join(tauriSrcPath, 'target');
@@ -1060,6 +1274,12 @@ class BaseBuilder {
1060
1274
  }
1061
1275
  ensureRustEnv();
1062
1276
  if (!checkRustInstalled()) {
1277
+ if (!isInteractive()) {
1278
+ throw new PakeError('Rust required to package your webapp.', {
1279
+ code: 'ENV_MISSING',
1280
+ hint: 'Install Rust via https://rustup.rs, then rerun the same command.',
1281
+ });
1282
+ }
1063
1283
  const res = await prompts({
1064
1284
  type: 'confirm',
1065
1285
  message: 'Rust not detected. Install now?',
@@ -1069,8 +1289,10 @@ class BaseBuilder {
1069
1289
  await installRust();
1070
1290
  }
1071
1291
  else {
1072
- logger.error('โœ• Rust required to package your webapp.');
1073
- process.exit(1);
1292
+ throw new PakeError('Rust required to package your webapp.', {
1293
+ code: 'ENV_MISSING',
1294
+ hint: 'Install Rust via https://rustup.rs, then rerun the same command.',
1295
+ });
1074
1296
  }
1075
1297
  }
1076
1298
  const spinner = getSpinner('Installing package...');
@@ -1129,8 +1351,9 @@ class BaseBuilder {
1129
1351
  // Let spinner run for a moment so user can see it, then stop before package manager command
1130
1352
  await new Promise((resolve) => setTimeout(resolve, 500));
1131
1353
  buildSpinner.stop();
1132
- // Show static message to keep the status visible
1133
- logger.warn('โœธ Building app...');
1354
+ // Show static message to keep the status visible. Info, not warn: warn
1355
+ // entries feed the --json warnings array and this is a status line.
1356
+ logger.info('โœธ Building app...');
1134
1357
  const baseEnv = getBuildEnvironment();
1135
1358
  let buildEnv = {
1136
1359
  ...(baseEnv ?? {}),
@@ -1174,6 +1397,7 @@ class BaseBuilder {
1174
1397
  // executable the build produced instead.
1175
1398
  if (this.options.bundle === false) {
1176
1399
  await this.copyRawBinary(npmDirectory, name);
1400
+ await this.recordArtifact(this.getRawBinaryPath(name), 'binary');
1177
1401
  if (logSuccess) {
1178
1402
  logger.success('โœ” Build success!');
1179
1403
  logger.success('โœ” Raw binary located in', path.resolve(this.getRawBinaryPath(name)));
@@ -1186,9 +1410,11 @@ class BaseBuilder {
1186
1410
  const appPath = this.getBuildAppPath(npmDirectory, fileName, fileType);
1187
1411
  const distPath = path.resolve(`${name}.${fileType}`);
1188
1412
  await fsExtra.copy(appPath, distPath);
1413
+ await this.recordArtifact(distPath, fileType);
1189
1414
  // Copy raw binary if requested
1190
1415
  if (this.options.keepBinary) {
1191
1416
  await this.copyRawBinary(npmDirectory, name);
1417
+ await this.recordArtifact(this.getRawBinaryPath(name), 'binary');
1192
1418
  }
1193
1419
  await fsExtra.remove(appPath);
1194
1420
  if (logSuccess) {
@@ -1215,6 +1441,13 @@ class BaseBuilder {
1215
1441
  // fsExtra.move uses fs.rename (atomic on same filesystem) and falls back
1216
1442
  // to copy+remove only when moving across volumes.
1217
1443
  await fsExtra.move(appBundlePath, appDest, { overwrite: true });
1444
+ // Keep the JSON result pointing at where the artifact actually lives.
1445
+ const movedFrom = path.resolve(appBundlePath);
1446
+ for (const artifact of this.artifacts) {
1447
+ if (artifact.path === movedFrom) {
1448
+ artifact.path = appDest;
1449
+ }
1450
+ }
1218
1451
  logger.success(`โœ” ${appBundleName.replace(/\.app$/, '')} installed to /Applications`);
1219
1452
  }
1220
1453
  catch (error) {
@@ -1430,6 +1663,9 @@ class MacBuilder extends BaseBuilder {
1430
1663
  }
1431
1664
  return `${name}_${tauriConfig.version}_${arch}`;
1432
1665
  }
1666
+ getReportArch() {
1667
+ return this.getActualArch();
1668
+ }
1433
1669
  getActualArch() {
1434
1670
  if (this.buildArch === 'universal' || this.options.multiArch) {
1435
1671
  return 'universal';
@@ -1483,6 +1719,9 @@ class WinBuilder extends BaseBuilder {
1483
1719
  : this.resolveTargetArch('auto');
1484
1720
  this.options.targets = this.buildFormat;
1485
1721
  }
1722
+ getReportArch() {
1723
+ return this.buildArch;
1724
+ }
1486
1725
  getFileName() {
1487
1726
  const { name } = this.options;
1488
1727
  const language = tauriConfig.bundle.windows.wix.language[0];
@@ -1538,6 +1777,9 @@ class LinuxBuilder extends BaseBuilder {
1538
1777
  }
1539
1778
  this.options.targets = this.buildFormat;
1540
1779
  }
1780
+ getReportArch() {
1781
+ return this.buildArch;
1782
+ }
1541
1783
  getFileName() {
1542
1784
  const { name = 'pake-app', targets } = this.options;
1543
1785
  const version = tauriConfig.version;
@@ -1686,12 +1928,14 @@ post_remove() {
1686
1928
  }
1687
1929
  `);
1688
1930
  await shellExec(`bsdtar --zstd -cf "${packagePath}" -C "${dataDir}" .PKGINFO .INSTALL usr`);
1931
+ await this.recordArtifact(packagePath, 'zst');
1689
1932
  logger.success('โœ” Build success!');
1690
1933
  logger.success('โœ” App installer located in', packagePath);
1691
1934
  }
1692
1935
  finally {
1693
1936
  if (removeSourceDeb) {
1694
1937
  await fsExtra.remove(debPath);
1938
+ this.removeArtifact(debPath);
1695
1939
  }
1696
1940
  await fsExtra.remove(workDir);
1697
1941
  }
@@ -2389,8 +2633,8 @@ async function handleIcon(options, url) {
2389
2633
  return localIconPath;
2390
2634
  }
2391
2635
  }
2392
- // Try favicon from website
2393
- if (url && options.name) {
2636
+ // Try favicon from website; local file/directory input has no favicon.
2637
+ if (url && options.name && /^https?:\/\//i.test(url)) {
2394
2638
  const faviconPath = await tryGetFavicon(url, options.name);
2395
2639
  if (faviconPath)
2396
2640
  return faviconPath;
@@ -2633,28 +2877,6 @@ function safeDomainsToRegex(domains) {
2633
2877
  : '';
2634
2878
  }
2635
2879
 
2636
- /**
2637
- * Error class used for user-facing CLI errors.
2638
- *
2639
- * The top-level catch in `bin/cli.ts` prints `message` directly without a
2640
- * stack trace and exits with code 1. Use this for predictable failures
2641
- * (invalid names, missing files, etc.) so users see a clean message instead
2642
- * of a Node.js stack dump.
2643
- */
2644
- class PakeError extends Error {
2645
- constructor(message) {
2646
- super(message);
2647
- this.isUserError = true;
2648
- this.name = 'PakeError';
2649
- }
2650
- }
2651
- function isPakeError(error) {
2652
- return (error instanceof PakeError ||
2653
- (typeof error === 'object' &&
2654
- error !== null &&
2655
- error.isUserError === true));
2656
- }
2657
-
2658
2880
  function resolveAppName(name, platform) {
2659
2881
  const domain = getDomain(name) || 'pake';
2660
2882
  return platform !== 'linux' ? capitalizeFirstLetter(domain) : domain;
@@ -2686,9 +2908,14 @@ async function handleOptions(options, url) {
2686
2908
  const defaultName = pathExists
2687
2909
  ? resolveLocalAppName(url, platform)
2688
2910
  : resolveAppName(url, platform);
2689
- const promptMessage = 'Enter your application name';
2690
- const namePrompt = await promptText(promptMessage, defaultName);
2691
- name = namePrompt?.trim() || defaultName;
2911
+ if (isInteractive()) {
2912
+ const promptMessage = 'Enter your application name';
2913
+ const namePrompt = await promptText(promptMessage, defaultName);
2914
+ name = namePrompt?.trim() || defaultName;
2915
+ }
2916
+ else {
2917
+ name = defaultName;
2918
+ }
2692
2919
  }
2693
2920
  if (name && platform === 'linux') {
2694
2921
  name = generateLinuxPackageName(name);
@@ -2732,6 +2959,7 @@ const DEFAULT_PAKE_OPTIONS = {
2732
2959
  width: 1200,
2733
2960
  fullscreen: false,
2734
2961
  maximize: false,
2962
+ resizable: true,
2735
2963
  hideTitleBar: false,
2736
2964
  hideWindowDecorations: false,
2737
2965
  alwaysOnTop: false,
@@ -2758,6 +2986,7 @@ const DEFAULT_PAKE_OPTIONS = {
2758
2986
  systemTrayIcon: '',
2759
2987
  proxyUrl: '',
2760
2988
  debug: false,
2989
+ json: false,
2761
2990
  inject: [],
2762
2991
  installerLanguage: 'en-US',
2763
2992
  hideOnClose: undefined, // Platform-specific: true for macOS, false for others
@@ -2797,9 +3026,16 @@ function validateNumberInput(value) {
2797
3026
  }
2798
3027
  return parsedValue;
2799
3028
  }
3029
+ // Path-shaped input (./x, ../x, /x, ~/x, C:\x). A missing path must fail
3030
+ // loudly: appending https:// to "./typo" would otherwise produce a valid URL
3031
+ // like https://./typo and a silently broken app (worst case for agents).
3032
+ const PATH_LIKE_PATTERN = /^(\.{1,2}[\\/]|[\\/]|~[\\/]|[a-zA-Z]:[\\/])/;
2800
3033
  function validateUrlInput(url) {
2801
3034
  const isFile = fs.existsSync(url);
2802
3035
  if (!isFile) {
3036
+ if (PATH_LIKE_PATTERN.test(url)) {
3037
+ throw new InvalidArgumentError(`Local path "${url}" does not exist. Check the path, or pass a web URL instead.`);
3038
+ }
2803
3039
  try {
2804
3040
  return normalizeUrl(url);
2805
3041
  }
@@ -2849,6 +3085,8 @@ ${green('|_| \\__,_|_|\\_\\___| can turn any webpage into a desktop app with
2849
3085
  return previous ? [...previous, ...files] : files;
2850
3086
  }, DEFAULT_PAKE_OPTIONS.inject)
2851
3087
  .option('--debug', 'Debug build and more output', DEFAULT_PAKE_OPTIONS.debug)
3088
+ .option('--json', 'Machine-readable output: logs to stderr, one JSON result on stdout', DEFAULT_PAKE_OPTIONS.json)
3089
+ .option('--config <path>', 'Load options from a JSON config file (fields mirror CLI options, see schema/pake.schema.json)')
2852
3090
  .addOption(new Option('--proxy-url <url>', 'Proxy URL for all network requests (http://, https://, socks5://)')
2853
3091
  .default(DEFAULT_PAKE_OPTIONS.proxyUrl)
2854
3092
  .hideHelp())
@@ -2981,16 +3219,207 @@ ${green('|_| \\__,_|_|\\_\\___| can turn any webpage into a desktop app with
2981
3219
  });
2982
3220
  }
2983
3221
 
3222
+ // Invocation concerns, not app manifest fields; pass these as CLI flags.
3223
+ const REJECTED_KEYS = new Set(['config', 'json', 'version']);
3224
+ // Optional CLI options that have no entry in DEFAULT_PAKE_OPTIONS.
3225
+ const EXTRA_STRING_KEYS = new Set(['name', 'title', 'identifier']);
3226
+ // Numeric fields share the CLI flag ranges (see cli-program.ts validators),
3227
+ // so a config file cannot smuggle a value the same flag would reject.
3228
+ const NUMBER_RANGES = {
3229
+ width: { min: 0 },
3230
+ height: { min: 0 },
3231
+ minWidth: { min: 0 },
3232
+ minHeight: { min: 0 },
3233
+ zoom: { min: 50, max: 200 },
3234
+ };
3235
+ function expectedTypeFor(key) {
3236
+ if (key === 'inject')
3237
+ return 'string[]';
3238
+ if (key === 'hideOnClose')
3239
+ return 'boolean';
3240
+ if (EXTRA_STRING_KEYS.has(key))
3241
+ return 'string';
3242
+ const defaultValue = DEFAULT_PAKE_OPTIONS[key];
3243
+ const type = typeof defaultValue;
3244
+ if (type === 'string' || type === 'number' || type === 'boolean') {
3245
+ return type;
3246
+ }
3247
+ return null;
3248
+ }
3249
+ function matchesType(value, type) {
3250
+ if (type === 'string[]') {
3251
+ return Array.isArray(value) && value.every((v) => typeof v === 'string');
3252
+ }
3253
+ return typeof value === type;
3254
+ }
3255
+ async function loadConfigFile(configPath, validKeys) {
3256
+ if (!(await fsExtra.pathExists(configPath))) {
3257
+ throw new PakeError(`Config file not found: ${configPath}`, {
3258
+ code: 'INVALID_INPUT',
3259
+ hint: 'Pass a path to a JSON file matching schema/pake.schema.json.',
3260
+ });
3261
+ }
3262
+ let parsed;
3263
+ try {
3264
+ parsed = JSON.parse(await fsExtra.readFile(configPath, 'utf8'));
3265
+ }
3266
+ catch (error) {
3267
+ const detail = error instanceof Error ? error.message : String(error);
3268
+ throw new PakeError(`Config file is not valid JSON: ${detail}`, {
3269
+ code: 'INVALID_INPUT',
3270
+ hint: `Fix the JSON syntax in ${configPath}.`,
3271
+ });
3272
+ }
3273
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
3274
+ throw new PakeError('Config file must contain a JSON object.', {
3275
+ code: 'INVALID_INPUT',
3276
+ hint: 'See schema/pake.schema.json for the expected shape.',
3277
+ });
3278
+ }
3279
+ const result = { options: {} };
3280
+ for (const [key, value] of Object.entries(parsed)) {
3281
+ if (key === '$schema')
3282
+ continue;
3283
+ if (key === 'url') {
3284
+ if (typeof value !== 'string') {
3285
+ throw new PakeError('Config field "url" must be a string.', {
3286
+ code: 'INVALID_INPUT',
3287
+ hint: 'Use a web URL or a local file/directory path.',
3288
+ });
3289
+ }
3290
+ result.url = value;
3291
+ continue;
3292
+ }
3293
+ if (REJECTED_KEYS.has(key)) {
3294
+ throw new PakeError(`Config field "${key}" is not allowed in a config file.`, {
3295
+ code: 'INVALID_INPUT',
3296
+ hint: `Pass --${key} on the command line instead.`,
3297
+ });
3298
+ }
3299
+ if (!validKeys.has(key)) {
3300
+ throw new PakeError(`Unknown config field "${key}".`, {
3301
+ code: 'INVALID_INPUT',
3302
+ hint: 'Field names are camelCase CLI option names; see schema/pake.schema.json.',
3303
+ });
3304
+ }
3305
+ const expected = expectedTypeFor(key);
3306
+ if (expected && !matchesType(value, expected)) {
3307
+ throw new PakeError(`Config field "${key}" must be of type ${expected}.`, {
3308
+ code: 'INVALID_INPUT',
3309
+ hint: 'See schema/pake.schema.json for field types.',
3310
+ });
3311
+ }
3312
+ if (typeof value === 'number') {
3313
+ const range = NUMBER_RANGES[key];
3314
+ const min = range?.min ?? 0;
3315
+ const max = range?.max;
3316
+ if (!Number.isFinite(value) ||
3317
+ value < min ||
3318
+ (max !== undefined && value > max)) {
3319
+ const bounds = max !== undefined ? `${min}-${max}` : `>= ${min}`;
3320
+ throw new PakeError(`Config field "${key}" must be a finite number (${bounds}).`, {
3321
+ code: 'INVALID_INPUT',
3322
+ hint: 'See schema/pake.schema.json for field ranges.',
3323
+ });
3324
+ }
3325
+ }
3326
+ if (!expected && (typeof value === 'object' || value === null)) {
3327
+ throw new PakeError(`Config field "${key}" must be a string, number, or boolean.`, {
3328
+ code: 'INVALID_INPUT',
3329
+ hint: 'See schema/pake.schema.json for field types.',
3330
+ });
3331
+ }
3332
+ result.options[key] = value;
3333
+ }
3334
+ return result;
3335
+ }
3336
+
2984
3337
  const program = getCliProgram();
3338
+ // Make commander throw instead of exiting so option/argument parse errors
3339
+ // honor the exit-code contract (2 = invalid input) and still emit the JSON
3340
+ // result object when --json was requested.
3341
+ program.exitOverride();
3342
+ function isCommanderExit(error) {
3343
+ return (typeof error === 'object' &&
3344
+ error !== null &&
3345
+ typeof error.code === 'string' &&
3346
+ error.code.startsWith('commander.'));
3347
+ }
3348
+ const PHASE_ERROR_CODES = {
3349
+ input: 'INVALID_INPUT',
3350
+ prepare: 'ENV_MISSING',
3351
+ build: 'BUILD_FAILED',
3352
+ };
3353
+ function classifyError(error, phase) {
3354
+ if (isPakeError(error)) {
3355
+ return {
3356
+ code: error.code ?? PHASE_ERROR_CODES[phase],
3357
+ message: error.message,
3358
+ hint: error.hint ?? null,
3359
+ };
3360
+ }
3361
+ if (error instanceof Error) {
3362
+ return {
3363
+ code: PHASE_ERROR_CODES[phase],
3364
+ message: error.message,
3365
+ hint: null,
3366
+ };
3367
+ }
3368
+ return {
3369
+ code: 'UNEXPECTED',
3370
+ message: `Unexpected error: ${String(error)}`,
3371
+ hint: null,
3372
+ };
3373
+ }
2985
3374
  async function checkUpdateTips() {
2986
3375
  updateNotifier({ pkg: packageJson, updateCheckInterval: 1000 * 60 }).notify({
2987
3376
  isGlobal: true,
2988
3377
  });
2989
3378
  }
2990
- program.action(async (url, options) => {
3379
+ program.action(async (urlArg, options) => {
3380
+ const jsonMode = Boolean(options.json);
3381
+ if (jsonMode) {
3382
+ enableMachineMode();
3383
+ }
3384
+ let phase = 'input';
3385
+ let appName = null;
3386
+ let url = urlArg;
2991
3387
  try {
2992
- await checkUpdateTips();
3388
+ // Heal a dist_bak stranded by an earlier crashed local-input run before
3389
+ // building, or this build would embed that run's staged files.
3390
+ restoreLocalTree();
3391
+ if (!jsonMode) {
3392
+ await checkUpdateTips();
3393
+ }
3394
+ // Config file fills in whatever the command line did not set explicitly:
3395
+ // CLI flag > config field > built-in default.
3396
+ if (options.config) {
3397
+ const validKeys = new Set(program.options.map((option) => option.attributeName()));
3398
+ const loaded = await loadConfigFile(options.config, validKeys);
3399
+ for (const [key, value] of Object.entries(loaded.options)) {
3400
+ if (program.getOptionValueSource(key) !== 'cli') {
3401
+ options[key] = value;
3402
+ }
3403
+ }
3404
+ if (!url && loaded.url) {
3405
+ try {
3406
+ url = validateUrlInput(loaded.url);
3407
+ }
3408
+ catch (error) {
3409
+ const detail = error instanceof Error ? error.message : String(error);
3410
+ throw new PakeError(`Invalid "url" in config file: ${detail}`, {
3411
+ code: 'INVALID_INPUT',
3412
+ });
3413
+ }
3414
+ }
3415
+ }
2993
3416
  if (!url) {
3417
+ if (jsonMode) {
3418
+ throw new PakeError('No URL or local path to package.', {
3419
+ code: 'INVALID_INPUT',
3420
+ hint: 'Pass a URL/path argument or a config file with a "url" field.',
3421
+ });
3422
+ }
2994
3423
  program.help({
2995
3424
  error: false,
2996
3425
  });
@@ -3002,13 +3431,47 @@ program.action(async (url, options) => {
3002
3431
  log.setLevel('debug');
3003
3432
  }
3004
3433
  const appOptions = await handleOptions(options, url);
3434
+ appName = appOptions.name ?? null;
3005
3435
  const builder = BuilderProvider.create(appOptions);
3436
+ phase = 'prepare';
3006
3437
  await builder.prepare();
3438
+ phase = 'build';
3007
3439
  await builder.build(url);
3440
+ if (jsonMode) {
3441
+ printJsonResult({
3442
+ ok: true,
3443
+ name: appName,
3444
+ platform: process.platform,
3445
+ arch: builder.getReportArch(),
3446
+ outputs: builder.getArtifacts(),
3447
+ warnings: getCapturedWarnings(),
3448
+ error: null,
3449
+ });
3450
+ }
3008
3451
  }
3009
3452
  catch (error) {
3010
- if (isPakeError(error)) {
3011
- console.error(chalk.red(error.message));
3453
+ // program.help() and --help/--version throw under exitOverride with
3454
+ // exitCode 0; a clean commander exit is not a failure.
3455
+ if (isCommanderExit(error) && error.exitCode === 0) {
3456
+ return;
3457
+ }
3458
+ const classified = classifyError(error, phase);
3459
+ if (jsonMode) {
3460
+ printJsonResult({
3461
+ ok: false,
3462
+ name: appName,
3463
+ platform: process.platform,
3464
+ arch: null,
3465
+ outputs: [],
3466
+ warnings: getCapturedWarnings(),
3467
+ error: classified,
3468
+ });
3469
+ }
3470
+ else if (isPakeError(error)) {
3471
+ console.error(chalk.red(classified.message));
3472
+ if (classified.hint) {
3473
+ console.error(chalk.yellow(`โœผ ${classified.hint}`));
3474
+ }
3012
3475
  }
3013
3476
  else if (error instanceof Error) {
3014
3477
  console.error(chalk.red(`โœ• ${error.message}`));
@@ -3019,15 +3482,49 @@ program.action(async (url, options) => {
3019
3482
  else {
3020
3483
  console.error(chalk.red(`โœ• Unexpected error: ${String(error)}`));
3021
3484
  }
3022
- process.exit(1);
3485
+ // exitCode + natural exit instead of process.exit: lets the finally
3486
+ // restore run and guarantees the JSON result is flushed on piped stdout.
3487
+ process.exitCode = ERROR_EXIT_CODES[classified.code];
3488
+ }
3489
+ finally {
3490
+ // A local-input run replaces the package's own dist/ during staging; put
3491
+ // it back so the CLI stays intact and later builds cannot embed this
3492
+ // user's files.
3493
+ restoreLocalTree();
3023
3494
  }
3024
3495
  });
3025
3496
  program.parseAsync().catch((error) => {
3497
+ if (isCommanderExit(error)) {
3498
+ // --help / --version and friends exit clean; commander already printed.
3499
+ if (error.exitCode === 0) {
3500
+ return;
3501
+ }
3502
+ // Parse errors (unknown option, invalid argument, missing value) are
3503
+ // invalid input. Commander already printed the message to stderr; in
3504
+ // json mode also emit the machine-readable result on stdout.
3505
+ if (process.argv.includes('--json')) {
3506
+ printJsonResult({
3507
+ ok: false,
3508
+ name: null,
3509
+ platform: process.platform,
3510
+ arch: null,
3511
+ outputs: [],
3512
+ warnings: [],
3513
+ error: {
3514
+ code: 'INVALID_INPUT',
3515
+ message: error.message.trim(),
3516
+ hint: 'Run pake --help for the accepted options.',
3517
+ },
3518
+ });
3519
+ }
3520
+ process.exitCode = ERROR_EXIT_CODES.INVALID_INPUT;
3521
+ return;
3522
+ }
3026
3523
  if (error instanceof Error) {
3027
3524
  console.error(chalk.red(`โœ• ${error.message}`));
3028
3525
  }
3029
3526
  else {
3030
3527
  console.error(chalk.red(`โœ• Unexpected error: ${String(error)}`));
3031
3528
  }
3032
- process.exit(1);
3529
+ process.exitCode = 1;
3033
3530
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pake-cli",
3
- "version": "3.14.0",
3
+ "version": "3.15.1",
4
4
  "description": "๐Ÿคฑ๐Ÿป Turn any webpage into a desktop app with one command. ๐Ÿคฑ๐Ÿป ไธ€้”ฎๆ‰“ๅŒ…็ฝ‘้กต็”Ÿๆˆ่ฝป้‡ๆกŒ้ขๅบ”็”จใ€‚",
5
5
  "engines": {
6
6
  "node": ">=18.0.0"
@@ -2564,7 +2564,7 @@ dependencies = [
2564
2564
 
2565
2565
  [[package]]
2566
2566
  name = "pake"
2567
- version = "3.14.0"
2567
+ version = "3.15.1"
2568
2568
  dependencies = [
2569
2569
  "objc2",
2570
2570
  "objc2-app-kit",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "pake"
3
- version = "3.14.0"
3
+ version = "3.15.1"
4
4
  description = "๐Ÿคฑ๐Ÿป Turn any webpage into a desktop app with Rust."
5
5
  authors = ["Tw93"]
6
6
  license = "GPL-3.0-or-later"
@@ -22,7 +22,10 @@ pub fn set_system_tray(
22
22
  return Ok(());
23
23
  }
24
24
 
25
- let new_window = MenuItemBuilder::with_id("new_window", "New Window").build(app)?;
25
+ // Menu events are broadcast to every handler in Tauri v2, so the tray item
26
+ // must not share the "new_window" id with the app menu accelerator
27
+ // (Cmd/Ctrl+N), or one click opens two windows.
28
+ let new_window = MenuItemBuilder::with_id("tray_new_window", "New Window").build(app)?;
26
29
  let hide_app = MenuItemBuilder::with_id("hide_app", "Hide").build(app)?;
27
30
  let show_app = MenuItemBuilder::with_id("show_app", "Show").build(app)?;
28
31
  let quit = MenuItemBuilder::with_id("quit", "Quit").build(app)?;
@@ -42,7 +45,7 @@ pub fn set_system_tray(
42
45
  let mut tray_builder = TrayIconBuilder::new()
43
46
  .menu(&menu)
44
47
  .on_menu_event(move |app, event| match event.id().as_ref() {
45
- "new_window" => {
48
+ "tray_new_window" => {
46
49
  open_additional_window_safe(app);
47
50
  }
48
51
  "hide_app" => {
@@ -206,19 +206,14 @@ function insertTextIntoEditableElement(element, text) {
206
206
  return false;
207
207
  }
208
208
 
209
- function runBrowserPasteCommand() {
210
- try {
211
- return document.execCommand("paste") === true;
212
- } catch (error) {
213
- return false;
214
- }
215
- }
209
+ let clipboardPasteFallbackTarget;
210
+ let clipboardPasteFallbackArmedAt = 0;
211
+ // An armed fallback older than this is a leftover from a keyup the window
212
+ // never saw (alt-tab mid-press); firing it on a later plain "v" keyup would
213
+ // paste unexpectedly.
214
+ const CLIPBOARD_PASTE_FALLBACK_TTL_MS = 5000;
216
215
 
217
216
  function pasteClipboardText(activeElement) {
218
- if (runBrowserPasteCommand()) {
219
- return;
220
- }
221
-
222
217
  const readText = navigator.clipboard?.readText;
223
218
  if (typeof readText !== "function") {
224
219
  return;
@@ -261,9 +256,19 @@ function handleClipboardShortcut(event) {
261
256
  }
262
257
 
263
258
  if (key === "v" && canPasteIntoEditableElement(activeElement)) {
264
- event.preventDefault();
265
- pasteClipboardText(activeElement);
266
- return true;
259
+ // Let the native WebView paste event run first so images, files, and rich
260
+ // clipboard formats remain intact. If the platform does not emit paste,
261
+ // keyup applies the existing text-only fallback. Key-repeat must not
262
+ // re-arm: after a native paste already fired and disarmed the fallback,
263
+ // a repeat keydown re-arming it would make keyup paste text a second
264
+ // time. Repeats only refresh the TTL of a still-armed target.
265
+ if (!event.repeat) {
266
+ clipboardPasteFallbackTarget = activeElement;
267
+ clipboardPasteFallbackArmedAt = Date.now();
268
+ } else if (clipboardPasteFallbackTarget === activeElement) {
269
+ clipboardPasteFallbackArmedAt = Date.now();
270
+ }
271
+ return false;
267
272
  }
268
273
 
269
274
  if (key === "a" && isEditable && selectEditableElement(activeElement)) {
@@ -274,6 +279,44 @@ function handleClipboardShortcut(event) {
274
279
  return false;
275
280
  }
276
281
 
282
+ function handleClipboardPasteFallback(event) {
283
+ if (
284
+ event.isTrusted !== true ||
285
+ !isNonMacDesktop() ||
286
+ event.key?.toLowerCase() !== "v"
287
+ ) {
288
+ return false;
289
+ }
290
+
291
+ const activeElement = clipboardPasteFallbackTarget;
292
+ const armedAt = clipboardPasteFallbackArmedAt;
293
+ clipboardPasteFallbackTarget = undefined;
294
+ if (
295
+ !activeElement ||
296
+ Date.now() - armedAt > CLIPBOARD_PASTE_FALLBACK_TTL_MS ||
297
+ document.activeElement !== activeElement ||
298
+ !canPasteIntoEditableElement(activeElement)
299
+ ) {
300
+ return false;
301
+ }
302
+
303
+ pasteClipboardText(activeElement);
304
+ return true;
305
+ }
306
+
307
+ function handlePaste(event) {
308
+ clipboardPasteFallbackTarget = undefined;
309
+ if (!pasteAsPlainTextPending) return;
310
+
311
+ event.preventDefault();
312
+ event.stopImmediatePropagation();
313
+
314
+ const text = event.clipboardData?.getData("text/plain") || "";
315
+ if (text) {
316
+ document.execCommand("insertText", false, text);
317
+ }
318
+ }
319
+
277
320
  const DOWNLOADABLE_FILE_EXTENSIONS = {
278
321
  documents: [
279
322
  "pdf",
@@ -590,22 +633,8 @@ document.addEventListener("DOMContentLoaded", () => {
590
633
  }
591
634
 
592
635
  document.addEventListener("keydown", handleClipboardShortcut, true);
593
-
594
- document.addEventListener(
595
- "paste",
596
- (event) => {
597
- if (pasteAsPlainTextPending) {
598
- event.preventDefault();
599
- event.stopImmediatePropagation();
600
-
601
- const text = event.clipboardData?.getData("text/plain") || "";
602
- if (text) {
603
- document.execCommand("insertText", false, text);
604
- }
605
- }
606
- },
607
- true,
608
- );
636
+ document.addEventListener("keyup", handleClipboardPasteFallback, true);
637
+ document.addEventListener("paste", handlePaste, true);
609
638
 
610
639
  // Trigger a native browser download via a transient anchor click. The Rust
611
640
  // on_download handler then writes the file to the Downloads folder. This is
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "productName": "Weekly",
3
3
  "identifier": "com.pake.weekly",
4
- "version": "3.14.0",
4
+ "version": "3.15.1",
5
5
  "app": {
6
6
  "withGlobalTauri": true,
7
7
  "trayIcon": {