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.
- package/dist/cli/commands/_setup.js +1 -1
- package/dist/cli/commands/dev.js +82 -37
- package/dist/cli/daemon/lock.js +62 -19
- package/dist/cli/daemon/watch.js +1 -1
- package/dist/cli/process-owner.js +27 -25
- package/dist/cli/wp/compose/export.php +82 -0
- package/dist/cli/wp/docker.d.ts +6 -2
- package/dist/cli/wp/docker.js +12 -5
- package/dist/cli/wp/export.js +355 -0
- package/dist/cli/wp/session.js +33 -10
- package/dist/cli/wp/watchdog.js +18 -19
- package/dist/index.d.ts +5 -5
- package/dist/index.js +2 -2
- package/dist/kizlo.d.ts +3 -3
- package/dist/kizlo.js +3 -0
- package/dist/package.js +1 -1
- package/dist/page/schema.d.ts +3 -3
- package/dist/post/schema.d.ts +5 -5
- package/dist/procedures.d.ts +8 -8
- package/dist/test.d.ts +2 -2
- package/dist/test.js +2 -2
- package/dist/wordpress/endpoint.d.ts +3 -4
- package/dist/wordpress/index.d.ts +1 -1
- package/package.json +1 -1
|
@@ -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";
|
package/dist/cli/commands/dev.js
CHANGED
|
@@ -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
|
|
151
|
-
|
|
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 =
|
|
299
|
-
const
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
312
|
-
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
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
|
-
|
|
422
|
-
|
|
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
|
-
|
|
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
|
|
433
|
-
await
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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
|
-
|
|
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
|
package/dist/cli/daemon/lock.js
CHANGED
|
@@ -1,53 +1,96 @@
|
|
|
1
|
-
import { currentProcessOwner,
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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 };
|
package/dist/cli/daemon/watch.js
CHANGED
|
@@ -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) =>
|
|
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
|
|
53
|
-
if (!Number.isSafeInteger(pid) || pid <= 0) return
|
|
50
|
+
function pidStatus(pid) {
|
|
51
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return "dead";
|
|
54
52
|
try {
|
|
55
53
|
process.kill(pid, 0);
|
|
56
|
-
return
|
|
57
|
-
} catch {
|
|
58
|
-
return
|
|
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
|
-
/**
|
|
65
|
-
async function
|
|
66
|
-
if (typeof owner !== "object" || owner === null || !("pid" in owner)) return
|
|
67
|
-
|
|
68
|
-
if (!hasDiscriminator(owner)) return
|
|
69
|
-
if (!Number.isSafeInteger(owner.port) || owner.port <= 0 || owner.port > 65535 || typeof owner.token !== "string" || !owner.token) return
|
|
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 = (
|
|
77
|
+
const finish = (status) => {
|
|
80
78
|
if (settled) return;
|
|
81
79
|
settled = true;
|
|
82
80
|
socket.destroy();
|
|
83
|
-
resolve(
|
|
81
|
+
resolve(status);
|
|
84
82
|
};
|
|
85
83
|
socket.on("data", (chunk) => {
|
|
86
84
|
response += chunk;
|
|
87
|
-
if (response.length > owner.token.length) finish(
|
|
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("
|
|
90
|
-
socket.on("
|
|
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,
|
|
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
|
+
}
|
package/dist/cli/wp/docker.d.ts
CHANGED
|
@@ -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 };
|
package/dist/cli/wp/docker.js
CHANGED
|
@@ -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 ??
|
|
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 };
|