kizlo 0.28.0 → 0.29.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.
@@ -1,11 +1,11 @@
1
1
  import { DEFAULT_WORDPRESS_TAG, LOCAL_DIR_REL } from "../wp/constants.js";
2
2
  import { composeStop, createStack, dockerHint, dockerStatus } from "../wp/docker.js";
3
3
  import { DEFAULT_ENV_KEYS, availablePackageManagers, detectInvokingPackageManager, ensureGitignored, envGroups, envKeysPresent, mergeEnv, pickStackPort, writeFileIfAbsent } from "../utils.js";
4
+ import { removeProjectContainers } from "../wp/session.js";
4
5
  import { DEFAULT_DEV_DB_PORT, DEFAULT_DEV_PORT, DEFAULT_MCP_PORT, resolveStackName, stackProject } from "../daemon/config.js";
5
6
  import { promptFragment, resolvePromptDefault } from "../presets/template.js";
6
7
  import { createAdminAppPassword } from "../wp/bootstrap.js";
7
8
  import { bootstrapDev } from "../wp/dev.js";
8
- import { removeProjectContainers } from "../wp/session.js";
9
9
  import { syncSiteSettings } from "../wp/settings.js";
10
10
  import { devStack } from "../wp/stack.js";
11
11
  import { isPluginVersionSupported, pluginUpdateMessage } from "@kizlo/shared";
@@ -2,9 +2,11 @@ import { createStack, dockerHint, dockerStatus, stackStatus } from "../wp/docker
2
2
  import { findConfigDir } from "../wp/utils.js";
3
3
  import { log } from "../daemon/logger.js";
4
4
  import { DEFAULT_ENV_KEYS, ensureGitignored, envGroups, groupDefault, mergeEnv, pickStackPort, withSpinner } from "../utils.js";
5
+ import { ownerStatus } from "../process-owner.js";
6
+ import { operationLock, withOperation } from "../daemon/lock.js";
7
+ import { projectSession, reapOrphans, registerSession, removeProjectContainers, spawnWatchdog, unregisterSession } from "../wp/session.js";
5
8
  import { resolveDevConfig, resolveMcpConfig, usesLocalWordPress } from "../daemon/config.js";
6
9
  import { bootstrapDev } from "../wp/dev.js";
7
- import { reapOrphans, registerSession, removeProjectContainers, spawnWatchdog, unregisterSession } from "../wp/session.js";
8
10
  import { syncSiteSettings } from "../wp/settings.js";
9
11
  import { devStack } from "../wp/stack.js";
10
12
  import { palette } from "../banner.js";
@@ -12,6 +14,7 @@ import { watchReload } from "../daemon/reload.js";
12
14
  import { startWatcher } from "../daemon/watch.js";
13
15
  import { createMcpState } from "../mcp/state.js";
14
16
  import { resolveMcpPort, startMcp } from "../mcp/index.js";
17
+ import { exportDev } from "../wp/export.js";
15
18
  import { join } from "node:path";
16
19
  import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
17
20
  import { defineCommand } from "citty";
@@ -147,8 +150,9 @@ function syncLocalUrl(configDir, url) {
147
150
  * an `npm`/`pnpm` wrapper silently swallows — leaving us orphaned but alive otherwise).
148
151
  */
149
152
  async function armForegroundTeardown(cfg) {
150
- const owner = await registerSession(cfg.project);
151
- spawnWatchdog(cfg.project, owner);
153
+ const file = operationLock(cfg.configDir);
154
+ const owner = await registerSession(cfg.project, file);
155
+ spawnWatchdog(cfg.project, owner, file);
152
156
  let exiting = false;
153
157
  const shutdown = (line) => {
154
158
  if (exiting) return;
@@ -295,35 +299,51 @@ async function rebootStack(cwd, label, mcpPort) {
295
299
  */
296
300
  async function startForeground(cfg) {
297
301
  const cwd = process.cwd();
298
- let ready = await prepareStack(cfg);
299
- const shutdown = await armForegroundTeardown(ready);
300
- const { creds, start } = await bootAndSync(ready);
301
- const stack = {
302
- status: () => stackStatus(ready.project),
303
- onStopped: () => shutdown()
304
- };
305
- const mcp = createMcpState();
306
- const mcpPort = ready.mcpPort;
307
- let stopContract = await startWatcher(ready.configDir, {
308
- stack,
309
- mcp
302
+ let ready = cfg;
303
+ const session = await withOperation(cfg.configDir, async () => {
304
+ const existing = projectSession(cfg.project);
305
+ if (existing && await ownerStatus(existing) !== "dead") throw new Error("Local WordPress already has an active dev session. Stop it before starting another.");
306
+ ready = await prepareStack(cfg);
307
+ const shutdown = await armForegroundTeardown(ready);
308
+ const { creds, start } = await bootAndSync(ready);
309
+ const stack$1 = {
310
+ status: () => stackStatus(ready.project),
311
+ onStopped: () => shutdown()
312
+ };
313
+ const mcp$1 = createMcpState();
314
+ const mcpPort$1 = ready.mcpPort;
315
+ const stopContract$1 = await startWatcher(ready.configDir, {
316
+ stack: stack$1,
317
+ mcp: mcp$1
318
+ });
319
+ const mcpServer$1 = stopContract$1 && mcpPort$1 !== void 0 ? await startMcp(mcp$1, mcpPort$1) : void 0;
320
+ if (!stopContract$1) log.info("Another `kizlo dev` is serving MCP for this project.");
321
+ printReady(creds, false, start, mcpServer$1?.url);
322
+ return {
323
+ stack: stack$1,
324
+ mcp: mcp$1,
325
+ mcpPort: mcpPort$1,
326
+ stopContract: stopContract$1,
327
+ mcpServer: mcpServer$1
328
+ };
310
329
  });
311
- const mcpServer = stopContract && mcpPort !== void 0 ? await startMcp(mcp, mcpPort) : void 0;
312
- if (!stopContract) log.info("Another `kizlo dev` is serving MCP for this project.");
313
- printReady(creds, false, start, mcpServer?.url);
330
+ let { stopContract } = session;
331
+ const { stack, mcp, mcpPort, mcpServer } = session;
314
332
  let reloading = false;
315
333
  const reload = async (changed) => {
316
334
  if (reloading) return;
317
335
  reloading = true;
318
336
  try {
319
- stopContract?.();
320
- const rebooted = await rebootStack(cwd, `${changed} changed — restarting the session`, mcpPort);
321
- ready = rebooted.ready;
322
- stopContract = await startWatcher(ready.configDir, {
323
- stack,
324
- mcp
337
+ await withOperation(ready.configDir, async () => {
338
+ stopContract?.();
339
+ const rebooted = await rebootStack(cwd, `${changed} changed — restarting the session`, mcpPort);
340
+ ready = rebooted.ready;
341
+ stopContract = await startWatcher(ready.configDir, {
342
+ stack,
343
+ mcp
344
+ });
345
+ printReady(rebooted.creds, true, rebooted.start, mcpServer?.url);
325
346
  });
326
- printReady(rebooted.creds, true, rebooted.start, mcpServer?.url);
327
347
  } catch (error) {
328
348
  log.error("Failed to reload the dev session:", error);
329
349
  } finally {
@@ -416,10 +436,17 @@ const subCommands = {
416
436
  name: "stop",
417
437
  description: "Stop local WordPress, keeping the database volume"
418
438
  },
419
- async run() {
439
+ args: { export: {
440
+ type: "boolean",
441
+ description: "Export the existing local site"
442
+ } },
443
+ async run({ args }) {
444
+ if (args.export) throw new Error("--export cannot be combined with stop or reset. Run `kizlo dev --export`.");
420
445
  if (!await requireLocalWordPress(process.cwd())) return;
421
- const { stack } = await resolve$1(process.cwd());
422
- await withSpinner("Stopping local WordPress", () => stack.composeStop(), "Local WordPress stopped (volumes kept)");
446
+ await withOperation((await resolveDevConfig(process.cwd())).configDir, async () => {
447
+ const { stack } = await resolve$1(process.cwd());
448
+ await withSpinner("Stopping local WordPress", () => stack.composeStop(), "Local WordPress stopped (volumes kept)");
449
+ });
423
450
  }
424
451
  }),
425
452
  reset: defineCommand({
@@ -427,25 +454,43 @@ const subCommands = {
427
454
  name: "reset",
428
455
  description: "Wipe the database and the install folder so the next `kizlo dev` rebuilds fresh"
429
456
  },
430
- async run() {
457
+ args: { export: {
458
+ type: "boolean",
459
+ description: "Export the existing local site"
460
+ } },
461
+ async run({ args }) {
462
+ if (args.export) throw new Error("--export cannot be combined with stop or reset. Run `kizlo dev --export`.");
431
463
  if (!await requireLocalWordPress(process.cwd())) return;
432
- const { cfg, stack } = await resolve$1(process.cwd());
433
- await withSpinner("Wiping local WordPress", () => stack.composeDown({ volumes: true }), "Local WordPress reset successfully");
434
- rmSync(cfg.wordpressDir, {
435
- recursive: true,
436
- force: true
464
+ const cfg = await resolveDevConfig(process.cwd());
465
+ await withOperation(cfg.configDir, async () => {
466
+ const { stack } = await resolve$1(process.cwd());
467
+ await withSpinner("Wiping local WordPress", () => stack.composeDown({ volumes: true }), "Local WordPress reset successfully");
468
+ rmSync(cfg.wordpressDir, {
469
+ recursive: true,
470
+ force: true
471
+ });
472
+ note("Run `kizlo dev` to rebuild fresh.");
437
473
  });
438
- note("Run `kizlo dev` to rebuild fresh.");
439
474
  }
440
475
  })
441
476
  };
442
477
  const dev = defineCommand({
443
478
  meta: {
444
479
  name: "dev",
445
- description: "Run local WordPress and the contract watcher (stop | reset)"
480
+ description: "Run local WordPress and the contract watcher (stop | reset | --export)"
446
481
  },
482
+ args: { export: {
483
+ type: "boolean",
484
+ description: "Export site content and SQL to a ZIP and stop dev"
485
+ } },
447
486
  subCommands,
448
- run: groupDefault(Object.keys(subCommands), bringUp)
487
+ setup({ args, rawArgs }) {
488
+ if (args.export && rawArgs.some((arg) => arg === "stop" || arg === "reset")) throw new Error("--export cannot be combined with stop or reset. Run `kizlo dev --export`.");
489
+ },
490
+ run: groupDefault(Object.keys(subCommands), async ({ args }) => {
491
+ if (args.export) note(`Exported ${await exportDev(process.cwd())}\n Local WordPress and its dev session are stopped.`);
492
+ else await bringUp();
493
+ })
449
494
  });
450
495
 
451
496
  //#endregion
@@ -1,53 +1,96 @@
1
- import { currentProcessOwner, ownerAlive } from "../process-owner.js";
1
+ import { currentProcessOwner, ownerStatus } from "../process-owner.js";
2
2
  import path from "node:path";
3
3
  import fs from "node:fs";
4
+ import { randomUUID } from "node:crypto";
4
5
 
5
6
  //#region src/cli/daemon/lock.ts
6
7
  function lockPath(cwd) {
7
8
  return path.join(cwd, "node_modules/.cache/kizlo/dev.lock");
8
9
  }
9
- /** True when a live daemon still owns the process identity recorded by the lock. */
10
- async function isLocked(file) {
11
- if (!fs.existsSync(file)) return false;
12
- const contents = fs.readFileSync(file, "utf8").trim();
10
+ function readLock(file) {
11
+ try {
12
+ return fs.readFileSync(file, "utf8");
13
+ } catch (error) {
14
+ if (error.code === "ENOENT") return void 0;
15
+ throw error;
16
+ }
17
+ }
18
+ async function held(contents) {
13
19
  let owner;
14
20
  try {
15
- owner = contents.startsWith("{") ? JSON.parse(contents) : { pid: Number(contents) };
21
+ owner = contents.trim().startsWith("{") ? JSON.parse(contents) : { pid: Number(contents) };
16
22
  } catch {
17
23
  return false;
18
24
  }
19
- return ownerAlive(owner);
25
+ return await ownerStatus(owner) !== "dead";
20
26
  }
21
27
  /** Claim the file for this process, or report that it was already there. */
22
28
  function claim(file, owner) {
29
+ const pending = `${file}.${randomUUID()}.tmp`;
23
30
  try {
24
- fs.writeFileSync(file, JSON.stringify(owner), { flag: "wx" });
31
+ fs.writeFileSync(pending, JSON.stringify(owner), {
32
+ flag: "wx",
33
+ mode: 384
34
+ });
35
+ fs.linkSync(pending, file);
25
36
  return true;
26
37
  } catch (error) {
27
38
  if (error.code !== "EEXIST") throw error;
28
39
  return false;
40
+ } finally {
41
+ fs.rmSync(pending, { force: true });
29
42
  }
30
43
  }
31
- /**
32
- * Take the lock for this process, answering false when a live daemon already holds it. The file is
33
- * created with `wx`, so two `kizlo dev` starting together cannot both come away believing they won.
34
- * A lock whose owner has died or whose PID belongs to another process is removed and taken over.
35
- * Losing the second claim means another process reached the same stale lock first, so this one
36
- * stands down rather than trying again.
37
- */
44
+ /** Publish one complete owner record, serializing and rechecking any stale-owner replacement. */
38
45
  async function acquire(file) {
39
46
  fs.mkdirSync(path.dirname(file), { recursive: true });
40
47
  const owner = await currentProcessOwner();
41
48
  if (claim(file, owner)) return true;
42
- if (await isLocked(file)) return false;
43
- fs.rmSync(file, { force: true });
44
- return claim(file, owner);
49
+ const contents = readLock(file);
50
+ if (contents === void 0) return claim(file, owner);
51
+ if (await held(contents)) return false;
52
+ const recovery = `${file}.reclaim`;
53
+ try {
54
+ fs.mkdirSync(recovery);
55
+ } catch (error) {
56
+ if (error.code !== "EEXIST") throw error;
57
+ throw new Error(`Lock recovery is concurrent or was interrupted. Retry; if this persists, stop this project's dev commands and remove ${recovery}.`);
58
+ }
59
+ try {
60
+ if (readLock(file) !== contents) return false;
61
+ fs.rmSync(file, { force: true });
62
+ return claim(file, owner);
63
+ } finally {
64
+ fs.rmdirSync(recovery);
65
+ }
45
66
  }
46
67
  function release(file) {
47
68
  try {
48
69
  fs.rmSync(file, { force: true });
49
70
  } catch {}
50
71
  }
72
+ /** Lifecycle operations share one lock, separate from the long-lived contract watcher. */
73
+ function operationLock(configDir) {
74
+ return path.join(configDir, ".kizlo/dev-operation.lock");
75
+ }
76
+ async function releaseOwned(file) {
77
+ const owner = await currentProcessOwner();
78
+ try {
79
+ const recorded = JSON.parse(fs.readFileSync(file, "utf8"));
80
+ if (recorded.pid === owner.pid && recorded.token === owner.token) release(file);
81
+ } catch (error) {
82
+ if (error.code !== "ENOENT") throw error;
83
+ }
84
+ }
85
+ async function withOperation(configDir, run) {
86
+ const file = operationLock(configDir);
87
+ if (!await acquire(file)) throw new Error("Another dev start, reload, stop, reset or export is active. Wait for it to finish and retry.");
88
+ try {
89
+ return await run();
90
+ } finally {
91
+ await releaseOwned(file);
92
+ }
93
+ }
51
94
 
52
95
  //#endregion
53
- export { acquire, lockPath, release };
96
+ export { acquire, lockPath, operationLock, release, releaseOwned, withOperation };
@@ -1,8 +1,8 @@
1
1
  import { log } from "./logger.js";
2
+ import { acquire, lockPath, release } from "./lock.js";
2
3
  import { resolveConfig, resolveWatchCredentials } from "./config.js";
3
4
  import { IntrospectionFetchError } from "../../wordpress/fetch-introspection.js";
4
5
  import { generateIntrospectionOnce, generateOnce, reportGenerationError } from "./generate.js";
5
- import { acquire, lockPath, release } from "./lock.js";
6
6
  import path from "node:path";
7
7
  import { FSWatcher } from "chokidar";
8
8
 
@@ -3,12 +3,7 @@ import { randomUUID } from "node:crypto";
3
3
 
4
4
  //#region src/cli/process-owner.ts
5
5
  const HOST = "127.0.0.1";
6
- /**
7
- * How long to wait for an owner to answer its liveness probe. A dead owner refuses the connection
8
- * at once and never reaches this, so the timeout only decides how long a *live* owner may be too
9
- * busy to answer before it is read as gone. The dev session answers on the same event loop it
10
- * typechecks the generated client on, which blocks it for the better part of a second.
11
- */
6
+ /** A busy dev session can miss this deadline; timeout means unknown, never confirmed exit. */
12
7
  const PROBE_TIMEOUT_MS = 1e3;
13
8
  let ownerPromise;
14
9
  const ownerServers = /* @__PURE__ */ new Set();
@@ -21,7 +16,10 @@ function currentProcessOwner() {
21
16
  if (ownerPromise) return ownerPromise;
22
17
  ownerPromise = new Promise((resolve, reject) => {
23
18
  const token = randomUUID();
24
- const server = createServer((socket) => socket.end(token));
19
+ const server = createServer((socket) => {
20
+ socket.on("error", () => {});
21
+ socket.end(token);
22
+ });
25
23
  server.unref();
26
24
  const failed = (error) => {
27
25
  ownerPromise = void 0;
@@ -49,24 +47,24 @@ function currentProcessOwner() {
49
47
  });
50
48
  return ownerPromise;
51
49
  }
52
- function pidAlive(pid) {
53
- if (!Number.isSafeInteger(pid) || pid <= 0) return false;
50
+ function pidStatus(pid) {
51
+ if (!Number.isSafeInteger(pid) || pid <= 0) return "dead";
54
52
  try {
55
53
  process.kill(pid, 0);
56
- return true;
57
- } catch {
58
- return false;
54
+ return "alive";
55
+ } catch (error) {
56
+ return error.code === "ESRCH" ? "dead" : "unknown";
59
57
  }
60
58
  }
61
59
  function hasDiscriminator(owner) {
62
60
  return "port" in owner || "token" in owner;
63
61
  }
64
- /** True only when the recorded PID and its random liveness token still match. */
65
- async function ownerAlive(owner) {
66
- if (typeof owner !== "object" || owner === null || !("pid" in owner)) return false;
67
- if (!pidAlive(owner.pid)) return false;
68
- if (!hasDiscriminator(owner)) return true;
69
- if (!Number.isSafeInteger(owner.port) || owner.port <= 0 || owner.port > 65535 || typeof owner.token !== "string" || !owner.token) return false;
62
+ /** A missed probe cannot distinguish a busy owner from a stale record. */
63
+ async function ownerStatus(owner) {
64
+ if (typeof owner !== "object" || owner === null || !("pid" in owner)) return "dead";
65
+ const pid = pidStatus(owner.pid);
66
+ if (pid !== "alive" || !hasDiscriminator(owner)) return pid;
67
+ if (!Number.isSafeInteger(owner.port) || owner.port <= 0 || owner.port > 65535 || typeof owner.token !== "string" || !owner.token) return "dead";
70
68
  return new Promise((resolve) => {
71
69
  let response = "";
72
70
  let settled = false;
@@ -76,21 +74,25 @@ async function ownerAlive(owner) {
76
74
  });
77
75
  socket.setEncoding("utf8");
78
76
  socket.setTimeout(PROBE_TIMEOUT_MS);
79
- const finish = (alive) => {
77
+ const finish = (status) => {
80
78
  if (settled) return;
81
79
  settled = true;
82
80
  socket.destroy();
83
- resolve(alive);
81
+ resolve(status);
84
82
  };
85
83
  socket.on("data", (chunk) => {
86
84
  response += chunk;
87
- if (response.length > owner.token.length) finish(false);
85
+ if (response.length > owner.token.length) finish("dead");
86
+ });
87
+ const missed = () => finish(pidStatus(owner.pid) === "dead" ? "dead" : "unknown");
88
+ socket.on("end", () => {
89
+ if (response) finish(response === owner.token ? "alive" : "dead");
90
+ else missed();
88
91
  });
89
- socket.on("end", () => finish(response === owner.token));
90
- socket.on("error", () => finish(false));
91
- socket.on("timeout", () => finish(false));
92
+ socket.on("error", missed);
93
+ socket.on("timeout", missed);
92
94
  });
93
95
  }
94
96
 
95
97
  //#endregion
96
- export { currentProcessOwner, ownerAlive };
98
+ export { currentProcessOwner, ownerStatus };
@@ -0,0 +1,82 @@
1
+ <?php
2
+ // Run in the existing wp-cli image; only read the effective mounted content tree.
3
+ set_error_handler(static function ($severity, $message, $file, $line) {
4
+ throw new ErrorException($message, 0, $severity, $file, $line);
5
+ });
6
+
7
+ try {
8
+ [$script, $workspace, $content] = $argv;
9
+ if (!class_exists('PharData') || !Phar::canCompress(Phar::GZ)) {
10
+ throw new RuntimeException('The PHP image needs PharData ZIP and zlib support.');
11
+ }
12
+ $sql = "$workspace/database.sql";
13
+ if (!is_file($sql) || filesize($sql) === 0) {
14
+ throw new RuntimeException('The database dump is empty or missing.');
15
+ }
16
+ $root = realpath($content);
17
+ if ($root === false || !is_dir($root) || is_link($content)) {
18
+ throw new RuntimeException('The wp-content directory is missing or is a link outside the install.');
19
+ }
20
+ $directories = ['wp-content'];
21
+ $total = filesize($sql);
22
+ $entryCount = 3;
23
+ $files = function ($directory, $entry, $ancestors = []) use (&$files, &$directories, &$total, &$entryCount, $root): Generator {
24
+ $real = realpath($directory);
25
+ if ($real === false || ($real !== $root && !str_starts_with($real, "$root/"))) {
26
+ throw new RuntimeException("Content link escapes wp-content: $entry");
27
+ }
28
+ if (isset($ancestors[$real])) {
29
+ throw new RuntimeException("Content contains a directory link cycle: $entry");
30
+ }
31
+ $ancestors[$real] = true;
32
+ foreach (new DirectoryIterator($directory) as $file) {
33
+ if ($file->isDot()) continue;
34
+ if (++$entryCount > 65535) throw new RuntimeException('Export exceeds the PHP ZIP entry limit (65535).');
35
+ $name = $file->getFilename();
36
+ if (str_contains($name, '\\') || str_contains($name, ':')) {
37
+ throw new RuntimeException("Content filename is unsafe in a portable ZIP: $entry/$name");
38
+ }
39
+ $path = $file->getPathname();
40
+ $target = realpath($path);
41
+ if ($target === false || ($target !== $root && !str_starts_with($target, "$root/"))) {
42
+ throw new RuntimeException("Content link is broken or escapes wp-content: $entry/$name");
43
+ }
44
+ $key = "$entry/$name";
45
+ if ($file->isDir()) {
46
+ $directories[] = $key;
47
+ yield from $files($path, $key, $ancestors);
48
+ } elseif ($file->isFile() && is_readable($path)) {
49
+ $total += filesize($path);
50
+ // Phar ZIP has no ZIP64 support. Fail instead of publishing a truncated archive.
51
+ if ($total > 0xffffffff) throw new RuntimeException('Export exceeds the PHP ZIP size limit (4 GiB).');
52
+ yield $key => $path;
53
+ } else {
54
+ throw new RuntimeException("Content is unreadable or not a regular file: $key");
55
+ }
56
+ }
57
+ };
58
+ if ($total > 0xffffffff) throw new RuntimeException('SQL exceeds the PHP ZIP size limit (4 GiB).');
59
+ $inner = new PharData("$workspace/database.sql.zip");
60
+ $inner->buildFromIterator(new ArrayIterator(['database.sql' => $sql]));
61
+ $inner->compressFiles(Phar::GZ);
62
+ unset($inner);
63
+
64
+ $entries = function () use ($workspace, $root, $files): Generator {
65
+ yield 'database.sql.zip' => "$workspace/database.sql.zip";
66
+ yield 'README.txt' => "$workspace/README.txt";
67
+ yield from $files($root, 'wp-content');
68
+ };
69
+ $outer = new PharData("$workspace/site.zip");
70
+ $outer->startBuffering();
71
+ $outer->buildFromIterator($entries());
72
+ foreach ($directories as $directory) $outer->addEmptyDir($directory);
73
+ $outer->compressFiles(Phar::GZ);
74
+ $outer->stopBuffering();
75
+ unset($outer);
76
+ if (!is_file("$workspace/site.zip") || filesize("$workspace/site.zip") === 0 || filesize("$workspace/site.zip") > 0xffffffff) {
77
+ throw new RuntimeException('PHP did not produce an export ZIP.');
78
+ }
79
+ } catch (Throwable $error) {
80
+ fwrite(STDERR, 'Export ZIP failed: ' . $error->getMessage() . PHP_EOL);
81
+ exit(1);
82
+ }
@@ -9,6 +9,8 @@ type RunInput = {
9
9
  input?: string;
10
10
  inputFile?: string;
11
11
  detached?: boolean;
12
+ signal?: AbortSignal;
13
+ timeout?: number;
12
14
  };
13
15
  /** A docker-compose stack: project id (`-p`), published port, image tag, and compose files (`-f`). */
14
16
  interface Stack {
@@ -42,6 +44,8 @@ interface RunResult {
42
44
  stdout: string;
43
45
  stderr: string;
44
46
  }
47
+ /** File transfers use Docker's copy support, keeping archive/SQL bytes out of stdout. */
48
+ declare function docker(args: string[], opts?: RunInput): Promise<RunResult>;
45
49
  /**
46
50
  * Docker's readiness in three states, so callers can tell "not installed" from "installed but not
47
51
  * running" and say the right thing:
@@ -65,7 +69,7 @@ declare function dockerAvailable(): Promise<boolean>;
65
69
  * this works from any directory and without the project's compose files. Throws when docker itself
66
70
  * could not answer, which the callers tell apart from an empty answer.
67
71
  */
68
- declare function runningProjectContainers(project: string): Promise<string[]>;
72
+ declare function runningProjectContainers(project: string, opts?: RunInput): Promise<string[]>;
69
73
  /**
70
74
  * Stop every running container for a compose project, targeted by label so this needs neither the
71
75
  * project's compose files nor its directory. Both are out of reach for a stack whose owning session
@@ -103,4 +107,4 @@ declare const wpCli: (args: string[]) => Promise<string>;
103
107
  /** Run PHP inside the active stack's loaded WordPress. */
104
108
  declare const wpEval: (php: string) => Promise<string>;
105
109
  //#endregion
106
- export { DockerStack, DockerStatus, RunResult, Stack, StackStatus, compose, composeDown, composePull, composeStop, composeUp, createStack, dockerAvailable, dockerHint, dockerStatus, runningProjectContainers, stackStatus, stopProjectContainers, wpCli, wpEval };
110
+ export { DockerStack, DockerStatus, RunResult, Stack, StackStatus, compose, composeDown, composePull, composeStop, composeUp, createStack, docker, dockerAvailable, dockerHint, dockerStatus, runningProjectContainers, stackStatus, stopProjectContainers, wpCli, wpEval };
@@ -11,7 +11,9 @@ function run(cmd, args, env, opts) {
11
11
  "pipe"
12
12
  ],
13
13
  env,
14
- detached: opts?.detached
14
+ detached: opts?.detached,
15
+ signal: opts?.signal,
16
+ timeout: opts?.timeout
15
17
  });
16
18
  let stdout = "";
17
19
  let stderr = "";
@@ -23,15 +25,20 @@ function run(cmd, args, env, opts) {
23
25
  });
24
26
  child.on("error", reject);
25
27
  child.on("close", (code) => resolvePromise({
26
- code: code ?? 0,
28
+ code: code ?? 1,
27
29
  stdout,
28
30
  stderr
29
31
  }));
32
+ child.stdin.on("error", reject);
30
33
  if (opts?.inputFile !== void 0) createReadStream(opts.inputFile).on("error", reject).pipe(child.stdin);
31
34
  else if (opts?.input !== void 0) child.stdin.end(opts.input);
32
35
  else child.stdin.end();
33
36
  });
34
37
  }
38
+ /** File transfers use Docker's copy support, keeping archive/SQL bytes out of stdout. */
39
+ function docker(args, opts) {
40
+ return run("docker", args, process.env, opts);
41
+ }
35
42
  /**
36
43
  * Probe Docker in two steps: `docker --version` only touches the client (is it installed?), then
37
44
  * `docker version` contacts the server (is the daemon up?). Used before any command that needs local
@@ -62,13 +69,13 @@ async function dockerAvailable() {
62
69
  * this works from any directory and without the project's compose files. Throws when docker itself
63
70
  * could not answer, which the callers tell apart from an empty answer.
64
71
  */
65
- async function runningProjectContainers(project) {
72
+ async function runningProjectContainers(project, opts) {
66
73
  const res = await run("docker", [
67
74
  "ps",
68
75
  "-q",
69
76
  "--filter",
70
77
  `label=com.docker.compose.project=${project}`
71
- ], process.env);
78
+ ], process.env, opts);
72
79
  if (res.code !== 0) throw new Error(`docker ps failed:\n${res.stderr || res.stdout}`);
73
80
  return res.stdout.split("\n").filter(Boolean);
74
81
  }
@@ -279,4 +286,4 @@ const wpCli = (args) => activeStack().wpCli(args);
279
286
  const wpEval = (php) => wpCli(["eval", php]);
280
287
 
281
288
  //#endregion
282
- export { compose, composeDown, composePull, composeStop, composeUp, createStack, dockerAvailable, dockerHint, dockerStatus, runningProjectContainers, stackStatus, stopProjectContainers, wpCli, wpEval };
289
+ export { compose, composeDown, composePull, composeStop, composeUp, createStack, docker, dockerAvailable, dockerHint, dockerStatus, runningProjectContainers, stackStatus, stopProjectContainers, wpCli, wpEval };