@writepanda/cli 1.221.0 → 1.224.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/bin/pandastudio.mjs +149 -39
  2. package/package.json +1 -1
@@ -40,7 +40,7 @@ for (const stream of [process.stdout, process.stderr]) {
40
40
  });
41
41
  }
42
42
 
43
- const VERSION = "1.221.0";
43
+ const VERSION = "1.224.0";
44
44
 
45
45
  function configDir() {
46
46
  // PANDASTUDIO_CONFIG_DIR mirrors the override the main process
@@ -69,6 +69,10 @@ USAGE
69
69
  pandastudio transcript.transcribe --id=ID --force=true --language=tamil --provider=deepgram
70
70
  pandastudio transcript.detect-language --id=ID --clipId=CLIP
71
71
  pandastudio --version Print CLI version
72
+ pandastudio brand.list-assets --json List workspace brand media
73
+ pandastudio brand.add-asset --path=/absolute/logo.png --role=logo
74
+ pandastudio project.add-brand-asset --id=<project> --assetId=<asset> --atMs=0
75
+ pandastudio brand.remove-asset --id=<asset>
72
76
  pandastudio commands List every available verb.noun
73
77
 
74
78
  OPTIONS
@@ -389,22 +393,6 @@ async function fetchJson(port, pathSuffix, opts = {}) {
389
393
  return { status: res.status, body: parsed };
390
394
  }
391
395
 
392
- async function probeHealth(port, timeoutMs) {
393
- // Single short probe with a small AbortController; used as a
394
- // liveness check before auto-launching, then in a poll loop after
395
- // launch to wait for readiness.
396
- const ctrl = new AbortController();
397
- const t = setTimeout(() => ctrl.abort(), timeoutMs);
398
- try {
399
- const res = await fetch(`http://127.0.0.1:${port}/v1/health`, { signal: ctrl.signal });
400
- clearTimeout(t);
401
- return res.ok;
402
- } catch {
403
- clearTimeout(t);
404
- return false;
405
- }
406
- }
407
-
408
396
  /**
409
397
  * One health probe: "up" (answered), "busy" (the socket is open but no answer
410
398
  * within `timeoutMs`: the app's main process is stalled, e.g. creating an
@@ -509,7 +497,61 @@ function appLaunchEnv(env) {
509
497
  return out;
510
498
  }
511
499
 
512
- async function autoLaunchAndWait(opts, log) {
500
+ // Launch guard (mirrors ensureApp in packages/mcp/bin/appConnection.mjs; this
501
+ // script ships as one standalone file). Launching whenever the port file's
502
+ // server didn't answer started a NEW PandaStudio while one was busy, booting,
503
+ // or had its token/port files replaced (Jared, Windows 2.1.15: "it restarted
504
+ // with every request"). Launch only when nothing says PandaStudio is running,
505
+ // and at most once per LAUNCH_DEBOUNCE_MS from any process on the machine.
506
+ const DEFAULT_AUTOMATION_PORT = 7878;
507
+ const INSTANCE_STALE_MS = 20_000;
508
+ const LAUNCH_DEBOUNCE_MS = 90_000;
509
+
510
+ function pidAlive(pid) {
511
+ if (!Number.isInteger(pid) || pid <= 0) return false;
512
+ try {
513
+ process.kill(pid, 0);
514
+ return true;
515
+ } catch (err) {
516
+ return err?.code === "EPERM";
517
+ }
518
+ }
519
+
520
+ /** instance.json names a live PandaStudio with a fresh heartbeat. */
521
+ async function appInstanceAlive() {
522
+ try {
523
+ const raw = await fs.readFile(path.join(configDir(), "instance.json"), "utf-8");
524
+ const rec = JSON.parse(raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw);
525
+ const beat = Number(rec?.heartbeatAt);
526
+ if (!Number.isFinite(beat) || Date.now() - beat > INSTANCE_STALE_MS) return false;
527
+ return pidAlive(rec.pid);
528
+ } catch {
529
+ return false;
530
+ }
531
+ }
532
+
533
+ async function lastLaunchAgeMs() {
534
+ try {
535
+ const at = Number(
536
+ (await fs.readFile(path.join(configDir(), "launch-requested"), "utf-8")).trim(),
537
+ );
538
+ if (!Number.isFinite(at) || at > Date.now()) return null;
539
+ return Date.now() - at;
540
+ } catch {
541
+ return null;
542
+ }
543
+ }
544
+
545
+ async function recordLaunch() {
546
+ try {
547
+ await fs.mkdir(configDir(), { recursive: true });
548
+ await fs.writeFile(path.join(configDir(), "launch-requested"), String(Date.now()), "utf-8");
549
+ } catch {
550
+ /* best-effort */
551
+ }
552
+ }
553
+
554
+ function launchApp(opts, log) {
513
555
  const bin = findInstalledApp(opts.appOverride);
514
556
  log(`auto-launching ${bin}`);
515
557
  try {
@@ -517,32 +559,80 @@ async function autoLaunchAndWait(opts, log) {
517
559
  detached: true,
518
560
  stdio: "ignore",
519
561
  env: appLaunchEnv(process.env),
562
+ // The desktop app's own window must show (windowsHide hides a GUI
563
+ // program's first window too).
564
+ windowsHide: false,
565
+ });
566
+ child.on("error", () => {
567
+ /* reported by the readiness wait */
520
568
  });
521
569
  child.unref();
522
570
  } catch (err) {
523
571
  throw new Error(`failed to launch PandaStudio (${bin}): ${err?.message ?? err}`);
524
572
  }
573
+ }
525
574
 
526
- const deadline = Date.now() + opts.timeoutSeconds * 1000;
527
- let credsKnown = null;
528
- while (Date.now() < deadline) {
529
- // Re-read credentials each iteration — the app writes them only
530
- // after binding, so the file may not exist yet.
575
+ async function autoLaunchAndWait(opts, log) {
576
+ const started = Date.now();
577
+ const deadline = started + opts.timeoutSeconds * 1000;
578
+ let launched = false;
579
+ let last = { state: null, alive: false, preferred: null };
580
+ for (;;) {
581
+ // Re-read credentials each round: the app writes them only after
582
+ // binding, and its heartbeat rewrites them if they drifted.
583
+ let creds = null;
584
+ let state = null;
531
585
  try {
532
- credsKnown = await readCredentials(opts);
533
- if (await probeHealth(credsKnown.port, 2000)) {
534
- log(
535
- `ready after ${Math.round((Date.now() - (deadline - opts.timeoutSeconds * 1000)) / 1000)}s`,
536
- );
537
- return credsKnown;
538
- }
586
+ creds = await readCredentials(opts);
587
+ state = await probeHealthState(creds.port, 1500);
539
588
  } catch {
540
589
  /* token/port not written yet */
541
590
  }
591
+ if (state === "up") {
592
+ if (launched) log(`ready after ${Math.round((Date.now() - started) / 1000)}s`);
593
+ return creds;
594
+ }
595
+ const alive = await appInstanceAlive();
596
+ let preferred = null;
597
+ if (!alive && state !== "busy" && creds?.port !== DEFAULT_AUTOMATION_PORT) {
598
+ preferred = await probeHealthState(DEFAULT_AUTOMATION_PORT, 1000);
599
+ }
600
+ last = { state, alive, preferred };
601
+ const age = launched ? 0 : await lastLaunchAgeMs();
602
+ const mayLaunch =
603
+ state !== "busy" &&
604
+ !alive &&
605
+ preferred !== "up" &&
606
+ preferred !== "busy" &&
607
+ (age === null || age >= LAUNCH_DEBOUNCE_MS);
608
+ if (mayLaunch) {
609
+ await recordLaunch();
610
+ launched = true;
611
+ launchApp(opts, log);
612
+ } else if (!launched && Date.now() - started < 600) {
613
+ log("PandaStudio is already running; waiting for it to answer (not launching another)");
614
+ }
615
+ if (Date.now() >= deadline) break;
542
616
  await new Promise((r) => setTimeout(r, 500));
543
617
  }
618
+ const secs = opts.timeoutSeconds;
619
+ if (last.state === "busy") {
620
+ throw new Error(
621
+ `PandaStudio is running but busy: it didn't answer for ${secs}s. Try again in a moment; nothing was relaunched.`,
622
+ );
623
+ }
624
+ if (last.alive) {
625
+ throw new Error(
626
+ `PandaStudio is running but its automation server didn't answer within ${secs}s. In PandaStudio open Settings → Automation and make sure Local automation is on.`,
627
+ );
628
+ }
629
+ if (last.preferred === "up") {
630
+ throw new Error(
631
+ `A PandaStudio is answering on 127.0.0.1:${DEFAULT_AUTOMATION_PORT} but its access files in ${configDir()} are missing or stale. Quit PandaStudio completely and open it again.`,
632
+ );
633
+ }
544
634
  throw new Error(
545
- `PandaStudio did not become ready within ${opts.timeoutSeconds}s. Open PandaStudio, go to Settings → Automation and turn on Local automation.`,
635
+ `PandaStudio did not become ready within ${secs}s. Open PandaStudio, go to Settings → Automation and turn on Local automation.`,
546
636
  );
547
637
  }
548
638
 
@@ -702,14 +792,34 @@ async function main() {
702
792
  // into the `args` object — the value `--slots='{"a":1}'` becomes
703
793
  // `args.slots = {a:1}` thanks to parseScalar.
704
794
  const callBody = { command, args: resolved.args };
705
- const result = await fetchJson(creds.port, "/v1/call", {
706
- method: "POST",
707
- headers: {
708
- "Content-Type": "application/json",
709
- Authorization: `Bearer ${creds.token}`,
710
- },
711
- body: JSON.stringify(callBody),
712
- });
795
+ const send = (c) =>
796
+ fetchJson(c.port, "/v1/call", {
797
+ method: "POST",
798
+ headers: {
799
+ "Content-Type": "application/json",
800
+ Authorization: `Bearer ${c.token}`,
801
+ },
802
+ body: JSON.stringify(callBody),
803
+ });
804
+ let result;
805
+ try {
806
+ result = await send(creds);
807
+ } catch (err) {
808
+ // Refused: the app moved its automation server after we read the
809
+ // port (nothing reached it, so resending can't run the command
810
+ // twice). Re-read the files once and resend to the new address.
811
+ const code = err?.code ?? err?.cause?.code;
812
+ if (code !== "ECONNREFUSED" || opts.portOverride) throw err;
813
+ let fresh;
814
+ try {
815
+ fresh = await readCredentials(opts);
816
+ } catch {
817
+ throw err;
818
+ }
819
+ if (fresh.port === creds.port && fresh.token === creds.token) throw err;
820
+ creds = fresh;
821
+ result = await send(creds);
822
+ }
713
823
 
714
824
  return emit(result, opts);
715
825
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@writepanda/cli",
3
- "version": "1.221.0",
3
+ "version": "1.224.0",
4
4
  "description": "Drive PandaStudio (a desktop video editor for YouTube creators) from the command line. Talks to a localhost HTTP API the desktop app exposes.",
5
5
  "keywords": [
6
6
  "pandastudio",