@writepanda/cli 1.119.0 → 1.180.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 +322 -34
  2. package/package.json +1 -1
@@ -21,7 +21,9 @@
21
21
  // %ProgramFiles%/PandaStudio/cli/pandastudio.exe (Windows installer)
22
22
 
23
23
  import { spawn } from "node:child_process";
24
+ import { existsSync, readFileSync } from "node:fs";
24
25
  import fs from "node:fs/promises";
26
+ import http from "node:http";
25
27
  import os from "node:os";
26
28
  import path from "node:path";
27
29
  import process from "node:process";
@@ -74,6 +76,12 @@ OPTIONS
74
76
  --token=<value> Override the on-disk token (rare; for tests).
75
77
  --port=<n> Override the on-disk port (rare; for tests).
76
78
  --app=<path> Override the path to the PandaStudio binary used for auto-launch.
79
+ --key=@file.json Read one argument's value from a file (JSON, else text).
80
+ --args-file=<path> Read all arguments as one JSON object from a file
81
+ (also: pandastudio <command> @args.json; - = stdin).
82
+ --args-stdin Read all arguments as one JSON object from stdin.
83
+ --dry-run Print the command + arguments that would be sent; don't call the app.
84
+ --help-windows How to pass JSON arguments from cmd.exe / PowerShell / Git Bash.
77
85
 
78
86
  EXAMPLES
79
87
  pandastudio system.status
@@ -89,10 +97,23 @@ DOCS
89
97
  }
90
98
 
91
99
  function parseArgv(argv) {
92
- const args = { _: [], flags: {}, json: false, noLaunch: false, timeoutSeconds: 60 };
93
- for (const raw of argv) {
100
+ const args = { _: [], flags: {}, rawFlags: {}, json: false, noLaunch: false, timeoutSeconds: 60 };
101
+ for (let i = 0; i < argv.length; i++) {
102
+ const raw = argv[i];
94
103
  if (raw === "--help" || raw === "-h") {
95
104
  args.help = true;
105
+ } else if (raw === "--help-windows") {
106
+ args.helpWindows = true;
107
+ } else if (raw === "--dry-run") {
108
+ args.dryRun = true;
109
+ } else if (raw === "--args-stdin") {
110
+ args.argsStdin = true;
111
+ } else if (raw.startsWith("--args-file=")) {
112
+ args.argsFile = raw.slice("--args-file=".length);
113
+ } else if (raw === "--args-file") {
114
+ // The one flag that also takes the `--flag value` form: a path is
115
+ // easy to mistype that way and the error would be baffling.
116
+ args.argsFile = argv[++i] ?? "";
96
117
  } else if (raw === "--version" || raw === "-v") {
97
118
  args.version = true;
98
119
  } else if (raw === "--json") {
@@ -116,8 +137,17 @@ function parseArgv(argv) {
116
137
  } else {
117
138
  const key = raw.slice(2, eq);
118
139
  const value = raw.slice(eq + 1);
140
+ args.rawFlags[key] = value;
119
141
  args.flags[key] = parseScalar(value);
120
142
  }
143
+ } else if (
144
+ args._.length > 0 &&
145
+ raw.length > 1 &&
146
+ raw.startsWith("@") &&
147
+ !raw.startsWith("@@")
148
+ ) {
149
+ // `pandastudio project.add-zoom @args.json` = --args-file=args.json
150
+ args.argsFile = raw.slice(1);
121
151
  } else {
122
152
  args._.push(raw);
123
153
  }
@@ -125,6 +155,172 @@ function parseArgv(argv) {
125
155
  return args;
126
156
  }
127
157
 
158
+ /**
159
+ * Detects JSON a Windows shell mangled on the way in, returning a short
160
+ * reason or null. Two shapes:
161
+ * - quotes stripped (cmd.exe, Windows PowerShell 5.1, Git Bash calling a
162
+ * .cmd): `[{"timeMs":0,"x":"a"}]` arrives as `[{timeMs:0,x:a}]`;
163
+ * - quotes left escaped (PowerShell 7.3+ given the 5.1 recipe):
164
+ * `[{\"timeMs\":0}]`.
165
+ * Only text that becomes valid JSON once repaired counts, so an ordinary
166
+ * string that happens to start with a bracket (`[Note: hi]`) passes through.
167
+ */
168
+ export function detectMangledJson(value) {
169
+ if (typeof value !== "string") return null;
170
+ const t = value.trim();
171
+ if (!(t.startsWith("{") || t.startsWith("["))) return null;
172
+ try {
173
+ JSON.parse(t);
174
+ return null;
175
+ } catch {
176
+ /* not JSON as given; see whether a shell broke it */
177
+ }
178
+ if (t.includes('\\"')) {
179
+ try {
180
+ JSON.parse(t.replace(/\\"/g, '"'));
181
+ return 'its double quotes arrived escaped as \\"';
182
+ } catch {
183
+ /* not that either */
184
+ }
185
+ }
186
+ // Needs real structure (an object, or a list of several items) so a
187
+ // bracketed word like "[draft]" stays plain text.
188
+ const structured = (t.includes("{") && t.includes(":")) || (t.startsWith("[") && t.includes(","));
189
+ const repaired = structured ? requoteBareJson(t) : null;
190
+ if (repaired !== null) {
191
+ try {
192
+ JSON.parse(repaired);
193
+ return "its double quotes were removed by the shell";
194
+ } catch {
195
+ /* not JSON-shaped */
196
+ }
197
+ }
198
+ return null;
199
+ }
200
+
201
+ /** Puts double quotes back around bare keys and bare string values
202
+ * (`{a:b c,d:[e,1]}` → `{"a":"b c","d":["e",1]}`); null when the text has
203
+ * a quote of its own (then it wasn't simply stripped). */
204
+ function requoteBareJson(t) {
205
+ if (t.includes('"')) return null;
206
+ const quoteToken = (tok) => {
207
+ const s = tok.trim();
208
+ if (s === "") return tok;
209
+ if (/^(true|false|null)$/.test(s) || /^-?\d+(\.\d+)?([eE][+-]?\d+)?$/.test(s)) return tok;
210
+ return tok.replace(s, JSON.stringify(s));
211
+ };
212
+ // Split on structural characters, keeping them, and quote everything in
213
+ // between that isn't a number/literal.
214
+ return t
215
+ .split(/([{}[\],:])/)
216
+ .map((part) => (/^[{}[\],:]$/.test(part) ? part : quoteToken(part)))
217
+ .join("");
218
+ }
219
+
220
+ const WINDOWS_QUOTING_HELP = `Windows shells change JSON arguments on the way in: cmd.exe and Windows
221
+ PowerShell 5.1 strip the double quotes, so
222
+ --keyframes=[{"timeMs":0}] arrives as [{timeMs:0}]. These work on every shell:
223
+ --keyframes=@keyframes.json read one value from a file (JSON, else text)
224
+ --args-file=args.json all arguments as one JSON object from a file
225
+ pandastudio <command> @args.json (same as --args-file)
226
+ --args-stdin all arguments as one JSON object on stdin,
227
+ e.g. Get-Content args.json | pandastudio <command> --args-stdin
228
+ Inline, if you must:
229
+ cmd.exe: "--keyframes=[{\\"timeMs\\":0}]"
230
+ Windows PowerShell 5.1: '--keyframes=[{\\"timeMs\\":0}]'
231
+ PowerShell 7.3+: '--keyframes=[{"timeMs":0}]'
232
+ Add --dry-run to print the exact arguments that would be sent, without calling the app.
233
+ A literal value starting with @ is written @@ (--text=@@handle sends "@handle").`;
234
+
235
+ /**
236
+ * The final args object: --args-file / --args-stdin first, then the
237
+ * command-line flags on top. A value `@path` is read from that file (JSON
238
+ * when it parses, else the text; `@@x` is the literal "@x"). Errors (a
239
+ * missing file, an args file that isn't an object, JSON a Windows shell
240
+ * mangled) come back as `error` so nothing half-parsed is ever sent.
241
+ */
242
+ export async function resolveArgs(opts, { readStdin, cwd = process.cwd() } = {}) {
243
+ const out = {};
244
+ const readStdinText = () => (readStdin ? readStdin() : readAllStdin());
245
+ const readJsonObject = (text, where) => {
246
+ let v;
247
+ try {
248
+ v = JSON.parse(text.replace(/^/, ""));
249
+ } catch (e) {
250
+ const why = detectMangledJson(text);
251
+ throw new Error(
252
+ why
253
+ ? `${where} is not valid JSON: ${why}.\n\n${WINDOWS_QUOTING_HELP}`
254
+ : `${where} is not valid JSON (${e.message}).`,
255
+ );
256
+ }
257
+ if (!v || typeof v !== "object" || Array.isArray(v)) {
258
+ throw new Error(
259
+ `${where} must be a JSON object of arguments, e.g. {"id":"abc","atMs":1000}.`,
260
+ );
261
+ }
262
+ return v;
263
+ };
264
+ try {
265
+ if (opts.argsFile === "") throw new Error("--args-file needs a path: --args-file=args.json");
266
+ if (opts.argsFile === "-") {
267
+ Object.assign(out, readJsonObject(await readStdinText(), "--args-file=- (stdin)"));
268
+ } else if (opts.argsFile) {
269
+ const p = path.resolve(cwd, opts.argsFile);
270
+ let text;
271
+ try {
272
+ text = await fs.readFile(p, "utf-8");
273
+ } catch {
274
+ throw new Error(`--args-file: can't read ${p}.`);
275
+ }
276
+ Object.assign(out, readJsonObject(text, `--args-file ${p}`));
277
+ }
278
+ if (opts.argsStdin) {
279
+ Object.assign(out, readJsonObject(await readStdinText(), "--args-stdin input"));
280
+ }
281
+ for (const [key, value] of Object.entries(opts.flags)) {
282
+ const raw = opts.rawFlags?.[key];
283
+ if (typeof raw === "string" && raw.startsWith("@@")) {
284
+ out[key] = raw.slice(1);
285
+ } else if (typeof raw === "string" && raw.length > 1 && raw.startsWith("@")) {
286
+ const p = path.resolve(cwd, raw.slice(1));
287
+ let text;
288
+ try {
289
+ text = await fs.readFile(p, "utf-8");
290
+ } catch {
291
+ throw new Error(
292
+ `--${key}=@${raw.slice(1)}: can't read ${p}. (A literal value starting with @ is written @@.)`,
293
+ );
294
+ }
295
+ const trimmed = text.replace(/^/, "").replace(/\r?\n$/, "");
296
+ try {
297
+ out[key] = JSON.parse(trimmed);
298
+ } catch {
299
+ const why = detectMangledJson(trimmed);
300
+ if (why)
301
+ throw new Error(`--${key}=@${raw.slice(1)}: the file is not valid JSON (${why}).`);
302
+ out[key] = trimmed;
303
+ }
304
+ } else if (typeof raw === "string" && detectMangledJson(raw)) {
305
+ throw new Error(
306
+ `--${key}=${raw} looks like JSON that the shell changed: ${detectMangledJson(raw)}. Nothing was sent.\n\n${WINDOWS_QUOTING_HELP}`,
307
+ );
308
+ } else {
309
+ out[key] = value;
310
+ }
311
+ }
312
+ } catch (err) {
313
+ return { error: err?.message ?? String(err) };
314
+ }
315
+ return { args: out };
316
+ }
317
+
318
+ async function readAllStdin() {
319
+ const chunks = [];
320
+ for await (const c of process.stdin) chunks.push(c);
321
+ return Buffer.concat(chunks).toString("utf-8");
322
+ }
323
+
128
324
  function parseScalar(value) {
129
325
  // JSON pass-through for objects/arrays/booleans/numbers; fall back
130
326
  // to the literal string. Lets `--slots='{"title":"x"}'` work without
@@ -155,13 +351,33 @@ async function readCredentials(opts) {
155
351
  return { port, token };
156
352
  }
157
353
 
354
+ // The app answers /v1/call when the command finishes, and some commands run
355
+ // for many minutes (an AI presenter take, a long music track). Node's global
356
+ // fetch gives up on a response after 5 minutes ("fetch failed"), so calls go
357
+ // through node:http, which waits as long as the command takes.
358
+ function httpRequestLocal(port, pathSuffix, { method = "GET", headers = {}, body } = {}) {
359
+ return new Promise((resolve, reject) => {
360
+ const req = http.request(
361
+ { host: "127.0.0.1", port, path: pathSuffix, method, headers },
362
+ (res) => {
363
+ const chunks = [];
364
+ res.on("data", (c) => chunks.push(c));
365
+ res.on("end", () =>
366
+ resolve({ status: res.statusCode ?? 0, text: Buffer.concat(chunks).toString("utf-8") }),
367
+ );
368
+ res.on("error", reject);
369
+ },
370
+ );
371
+ req.on("error", reject);
372
+ req.setTimeout(0);
373
+ if (body !== undefined) req.write(body);
374
+ req.end();
375
+ });
376
+ }
377
+
158
378
  async function fetchJson(port, pathSuffix, opts = {}) {
159
- // Use the global fetch (Node 18+ ships it); we only require Node 22
160
- // at runtime per the app's package.json engines block, so this is
161
- // safe to assume.
162
- const url = `http://127.0.0.1:${port}${pathSuffix}`;
163
- const res = await fetch(url, opts);
164
- const text = await res.text();
379
+ const res = await httpRequestLocal(port, pathSuffix, opts);
380
+ const text = res.text;
165
381
  let parsed;
166
382
  try {
167
383
  parsed = text.length === 0 ? null : JSON.parse(text);
@@ -187,32 +403,86 @@ async function probeHealth(port, timeoutMs) {
187
403
  }
188
404
  }
189
405
 
406
+ /**
407
+ * One health probe: "up" (answered), "busy" (the socket is open but no answer
408
+ * within `timeoutMs`: the app's main process is stalled, e.g. creating an
409
+ * editor window on a loaded machine) or "down" (connection refused / reset:
410
+ * not running).
411
+ */
412
+ async function probeHealthState(port, timeoutMs) {
413
+ const ctrl = new AbortController();
414
+ const t = setTimeout(() => ctrl.abort(), timeoutMs);
415
+ try {
416
+ const res = await fetch(`http://127.0.0.1:${port}/v1/health`, { signal: ctrl.signal });
417
+ return res.ok ? "up" : "down";
418
+ } catch {
419
+ return ctrl.signal.aborted ? "busy" : "down";
420
+ } finally {
421
+ clearTimeout(t);
422
+ }
423
+ }
424
+
425
+ /**
426
+ * Is the app reachable? A busy app is waited for (up to `busyWaitMs`) rather
427
+ * than reported as not running: a single 1.5 s probe used to fail whenever the
428
+ * main process stalled for longer (window creation takes ~0.4 s idle and
429
+ * several seconds under load), so agents saw "not reachable" at random.
430
+ */
431
+ async function appReachable(port, { probeMs = 1500, busyWaitMs = 30_000, onBusy } = {}) {
432
+ const deadline = Date.now() + busyWaitMs;
433
+ let warned = false;
434
+ for (;;) {
435
+ const s = await probeHealthState(port, probeMs);
436
+ if (s === "up") return true;
437
+ if (s === "down" || Date.now() >= deadline) return false;
438
+ if (!warned && onBusy) onBusy();
439
+ warned = true;
440
+ probeMs = Math.min(probeMs * 2, 5000);
441
+ }
442
+ }
443
+
190
444
  function findInstalledApp(appOverride) {
191
445
  if (appOverride) return appOverride;
192
446
  if (process.env.PANDASTUDIO_BIN) return process.env.PANDASTUDIO_BIN;
193
-
194
- if (process.platform === "darwin") {
195
- const macCandidates = [
196
- "/Applications/PandaStudio.app/Contents/MacOS/PandaStudio",
197
- path.join(
198
- os.homedir(),
199
- "Applications",
200
- "PandaStudio.app",
201
- "Contents",
202
- "MacOS",
203
- "PandaStudio",
204
- ),
205
- ];
206
- return macCandidates[0]; // best-effort; spawn() will surface ENOENT clearly
207
- }
208
- if (process.platform === "win32") {
209
- const programFiles = process.env["ProgramFiles"] ?? "C:\\Program Files";
210
- return path.join(programFiles, "PandaStudio", "PandaStudio.exe");
447
+ // The app records its own location (it can live anywhere on Windows,
448
+ // and in ~/Applications on a Mac).
449
+ try {
450
+ const recorded = readFileSync(path.join(configDir(), "app-path"), "utf-8").trim();
451
+ if (recorded && existsSync(recorded)) return recorded;
452
+ } catch {
453
+ /* older app, or never launched */
211
454
  }
212
- // Linux is unsupported by the installer today, but scaffolding it
213
- // here means the day we publish a .AppImage or .deb the auto-launch
214
- // path is one PR away.
215
- return "/opt/PandaStudio/pandastudio";
455
+ const candidates =
456
+ process.platform === "darwin"
457
+ ? [
458
+ "/Applications/PandaStudio.app/Contents/MacOS/PandaStudio",
459
+ path.join(
460
+ os.homedir(),
461
+ "Applications",
462
+ "PandaStudio.app",
463
+ "Contents",
464
+ "MacOS",
465
+ "PandaStudio",
466
+ ),
467
+ ]
468
+ : process.platform === "win32"
469
+ ? [
470
+ // Per-user install (the installer default).
471
+ path.join(
472
+ process.env.LOCALAPPDATA ?? path.join(os.homedir(), "AppData", "Local"),
473
+ "Programs",
474
+ "PandaStudio",
475
+ "PandaStudio.exe",
476
+ ),
477
+ path.join(
478
+ process.env.ProgramFiles ?? "C:\\Program Files",
479
+ "PandaStudio",
480
+ "PandaStudio.exe",
481
+ ),
482
+ ]
483
+ : ["/opt/PandaStudio/pandastudio"];
484
+ // spawn() surfaces ENOENT clearly when none exists.
485
+ return candidates.find((c) => existsSync(c)) ?? candidates[0];
216
486
  }
217
487
 
218
488
  async function autoLaunchAndWait(opts, log) {
@@ -248,7 +518,7 @@ async function autoLaunchAndWait(opts, log) {
248
518
  await new Promise((r) => setTimeout(r, 500));
249
519
  }
250
520
  throw new Error(
251
- `PandaStudio did not become ready within ${opts.timeoutSeconds}s. Open the app and enable "Allow local automation" in Settings.`,
521
+ `PandaStudio did not become ready within ${opts.timeoutSeconds}s. Open PandaStudio, go to Settings → Automation and turn on Local automation.`,
252
522
  );
253
523
  }
254
524
 
@@ -337,6 +607,10 @@ async function main() {
337
607
  process.stdout.write(`${VERSION}\n`);
338
608
  return 0;
339
609
  }
610
+ if (opts.helpWindows) {
611
+ process.stdout.write(`${WINDOWS_QUOTING_HELP}\n`);
612
+ return 0;
613
+ }
340
614
  if (opts.help || (opts._.length === 0 && Object.keys(opts.flags).length === 0)) {
341
615
  printHelp();
342
616
  return 0;
@@ -362,6 +636,16 @@ async function main() {
362
636
  return 2;
363
637
  }
364
638
 
639
+ const resolved = await resolveArgs(opts);
640
+ if (resolved.error) {
641
+ process.stderr.write(`error: ${resolved.error}\n`);
642
+ return 2;
643
+ }
644
+ if (opts.dryRun) {
645
+ process.stdout.write(`${JSON.stringify({ command, args: resolved.args }, null, 2)}\n`);
646
+ return 0;
647
+ }
648
+
365
649
  let creds;
366
650
  try {
367
651
  creds = await readCredentials(opts);
@@ -369,11 +653,15 @@ async function main() {
369
653
  creds = null;
370
654
  }
371
655
 
372
- let alive = creds ? await probeHealth(creds.port, 1500) : false;
656
+ let alive = creds
657
+ ? await appReachable(creds.port, {
658
+ onBusy: () => log("PandaStudio is busy; waiting for it to answer"),
659
+ })
660
+ : false;
373
661
  if (!alive) {
374
662
  if (opts.noLaunch) {
375
663
  process.stderr.write(
376
- "error: PandaStudio is not reachable and --no-launch was set.\n Open the app and enable 'Allow local automation' in Settings.\n",
664
+ "error: PandaStudio is not reachable and --no-launch was set.\n Open PandaStudio, go to Settings → Automation and turn on Local automation.\n",
377
665
  );
378
666
  return 3;
379
667
  }
@@ -389,7 +677,7 @@ async function main() {
389
677
  // Build the call envelope. Everything after the command is mapped
390
678
  // into the `args` object — the value `--slots='{"a":1}'` becomes
391
679
  // `args.slots = {a:1}` thanks to parseScalar.
392
- const callBody = { command, args: { ...opts.flags } };
680
+ const callBody = { command, args: resolved.args };
393
681
  const result = await fetchJson(creds.port, "/v1/call", {
394
682
  method: "POST",
395
683
  headers: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@writepanda/cli",
3
- "version": "1.119.0",
3
+ "version": "1.180.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",