@instawp/cli 0.0.1-beta.2 → 0.0.1-beta.20
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/CHANGELOG.md +197 -0
- package/README.md +151 -17
- package/dist/commands/db.d.ts +2 -0
- package/dist/commands/db.js +305 -0
- package/dist/commands/db.js.map +1 -0
- package/dist/commands/exec.js +62 -3
- package/dist/commands/exec.js.map +1 -1
- package/dist/commands/local.js +483 -123
- package/dist/commands/local.js.map +1 -1
- package/dist/commands/login.js +23 -7
- package/dist/commands/login.js.map +1 -1
- package/dist/commands/logs.d.ts +2 -0
- package/dist/commands/logs.js +239 -0
- package/dist/commands/logs.js.map +1 -0
- package/dist/commands/open.d.ts +2 -0
- package/dist/commands/open.js +114 -0
- package/dist/commands/open.js.map +1 -0
- package/dist/commands/sites.js +240 -2
- package/dist/commands/sites.js.map +1 -1
- package/dist/commands/sync.js +6 -3
- package/dist/commands/sync.js.map +1 -1
- package/dist/commands/versions.d.ts +2 -0
- package/dist/commands/versions.js +324 -0
- package/dist/commands/versions.js.map +1 -0
- package/dist/index.js +49 -8
- package/dist/index.js.map +1 -1
- package/dist/lib/local-env.d.ts +31 -0
- package/dist/lib/local-env.js +60 -13
- package/dist/lib/local-env.js.map +1 -1
- package/dist/lib/local-instance.d.ts +43 -0
- package/dist/lib/local-instance.js +60 -0
- package/dist/lib/local-instance.js.map +1 -0
- package/dist/lib/output.js +14 -1
- package/dist/lib/output.js.map +1 -1
- package/dist/lib/paths.d.ts +22 -0
- package/dist/lib/paths.js +41 -0
- package/dist/lib/paths.js.map +1 -0
- package/dist/lib/sftp-sync.d.ts +35 -0
- package/dist/lib/sftp-sync.js +290 -0
- package/dist/lib/sftp-sync.js.map +1 -0
- package/dist/lib/site-resolver.js +25 -3
- package/dist/lib/site-resolver.js.map +1 -1
- package/dist/lib/sqlite-to-mysql.d.ts +47 -0
- package/dist/lib/sqlite-to-mysql.js +133 -0
- package/dist/lib/sqlite-to-mysql.js.map +1 -0
- package/dist/lib/ssh-connection.d.ts +11 -0
- package/dist/lib/ssh-connection.js +99 -3
- package/dist/lib/ssh-connection.js.map +1 -1
- package/dist/lib/ssh-keys.js +12 -5
- package/dist/lib/ssh-keys.js.map +1 -1
- package/dist/lib/windows-binaries.d.ts +10 -0
- package/dist/lib/windows-binaries.js +34 -0
- package/dist/lib/windows-binaries.js.map +1 -0
- package/dist/types.d.ts +14 -0
- package/package.json +12 -3
- package/vendor/win32/NOTICE.md +31 -0
- package/vendor/win32/busybox.exe +0 -0
package/dist/commands/local.js
CHANGED
|
@@ -1,14 +1,22 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
|
-
import { join
|
|
3
|
-
import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync, unlinkSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { randomBytes } from 'node:crypto';
|
|
4
6
|
import chalk from 'chalk';
|
|
5
7
|
import open from 'open';
|
|
8
|
+
import Database from 'better-sqlite3';
|
|
9
|
+
import { resolveFromModule } from '../lib/paths.js';
|
|
10
|
+
import { bundledBusybox } from '../lib/windows-binaries.js';
|
|
6
11
|
import { getLocalInstances, getLocalInstance, setLocalInstance, removeLocalInstance, } from '../lib/config.js';
|
|
7
12
|
import { getNextPort, createInstanceDir, deleteInstanceDir, startServer, startServerBackground, stopServer as stopServerProcess, isServerRunning, checkPlaygroundConnectivity, ensureAutoLogin, } from '../lib/local-env.js';
|
|
8
13
|
import { requireAuth, getClient } from '../lib/api.js';
|
|
9
14
|
import { resolveSite } from '../lib/site-resolver.js';
|
|
10
15
|
import { ensureSshAccess } from '../lib/ssh-keys.js';
|
|
11
|
-
import {
|
|
16
|
+
import { syncFiles, execViaSsh, execViaSshToFile, scpUpload } from '../lib/ssh-connection.js';
|
|
17
|
+
import { listLocalFiles } from '../lib/sftp-sync.js';
|
|
18
|
+
import { sanitizeName, defaultInstanceName, pushTargetRef, parseTablePrefix, parseSqlTableNames } from '../lib/local-instance.js';
|
|
19
|
+
import { generateMysqlDump } from '../lib/sqlite-to-mysql.js';
|
|
12
20
|
import { success, error, table, spinner, info, isJsonMode } from '../lib/output.js';
|
|
13
21
|
export function registerLocalCommand(program) {
|
|
14
22
|
const local = program
|
|
@@ -54,11 +62,11 @@ export function registerLocalCommand(program) {
|
|
|
54
62
|
createdAt: new Date().toISOString(),
|
|
55
63
|
};
|
|
56
64
|
setLocalInstance(instance);
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
${chalk.dim('#')} Starting WordPress ${opts.wp} with PHP ${opts.php}
|
|
60
|
-
|
|
61
|
-
|
|
65
|
+
if (!isJsonMode()) {
|
|
66
|
+
success(`Instance "${name}" created`);
|
|
67
|
+
console.log(`\n${chalk.dim('#')} Starting WordPress ${opts.wp} with PHP ${opts.php}...`);
|
|
68
|
+
console.log(`${chalk.dim('#')} Data stored at: ${chalk.dim(dir)}\n`);
|
|
69
|
+
}
|
|
62
70
|
await launchServer(instance, opts);
|
|
63
71
|
}
|
|
64
72
|
catch (err) {
|
|
@@ -176,7 +184,11 @@ ${chalk.dim('#')} Data stored at: ${chalk.dim(dir)}
|
|
|
176
184
|
local
|
|
177
185
|
.command('push <local-name> [cloud-site]')
|
|
178
186
|
.description('Push local wp-content to an InstaWP cloud site')
|
|
187
|
+
.option('--include <pattern...>', 'Include patterns (e.g. .git)')
|
|
179
188
|
.option('--exclude <pattern...>', 'Additional exclude patterns')
|
|
189
|
+
.option('--with-db', 'Also push the local database, OVERWRITING the cloud DB (backs it up first)')
|
|
190
|
+
.option('--no-backup', 'With --with-db: skip the cloud DB backup before overwrite (DANGEROUS)')
|
|
191
|
+
.option('--force', 'With --with-db: skip the overwrite confirmation prompt')
|
|
180
192
|
.option('--dry-run', 'Show what would be transferred')
|
|
181
193
|
.action(async (localName, cloudSiteArg, opts) => {
|
|
182
194
|
requireAuth();
|
|
@@ -185,19 +197,68 @@ ${chalk.dim('#')} Data stored at: ${chalk.dim(dir)}
|
|
|
185
197
|
error(`Local instance "${localName}" not found.`);
|
|
186
198
|
process.exit(1);
|
|
187
199
|
}
|
|
200
|
+
const localWpContent = join(instance.path, 'wp-content') + '/';
|
|
201
|
+
// Where does this push go? Explicit arg → the site this instance was
|
|
202
|
+
// cloned from (instance.cloudSiteId) → otherwise create a new site. This
|
|
203
|
+
// is the fix for "push after clone creates a new site": a cloned instance
|
|
204
|
+
// remembers its origin and pushes back to it.
|
|
205
|
+
const targetRef = pushTargetRef(cloudSiteArg, instance);
|
|
206
|
+
// A dry run must be side-effect free. With no target at all (no arg, not a
|
|
207
|
+
// cloned instance), a real push would *create* a site — which a dry run
|
|
208
|
+
// must never do — so preview the local files that would be pushed (pure
|
|
209
|
+
// filesystem walk, no network) and stop. Previously this provisioned a
|
|
210
|
+
// real site, then failed connecting to its not-yet-resolvable hostname.
|
|
211
|
+
if (opts.dryRun && !targetRef) {
|
|
212
|
+
const excludes = ['database', 'db.php', 'mu-plugins', '.git', 'node_modules', '.DS_Store', ...(opts.exclude ?? [])];
|
|
213
|
+
const files = listLocalFiles(join(instance.path, 'wp-content'), excludes);
|
|
214
|
+
if (isJsonMode()) {
|
|
215
|
+
console.log(JSON.stringify({ success: true, dry_run: true, would_create_site: localName, files }));
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
info(`(dry run) Would create cloud site "${localName}" and push ${chalk.dim(localWpContent)}`);
|
|
219
|
+
for (const rel of files)
|
|
220
|
+
console.log(` ${chalk.dim('↑')} ${rel}`);
|
|
221
|
+
info(`(dry run) ${files.length} file(s) would be pushed. No cloud site was created.`);
|
|
222
|
+
}
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
188
225
|
if (!checkRsync()) {
|
|
189
|
-
error('rsync is required. Install: brew install rsync');
|
|
226
|
+
error('rsync is required for sync on macOS/Linux. Install: brew install rsync (macOS) or your distro package.');
|
|
190
227
|
process.exit(1);
|
|
191
228
|
}
|
|
192
|
-
const localWpContent = join(instance.path, 'wp-content') + '/';
|
|
193
|
-
// If no cloud site specified, create one
|
|
194
229
|
let site;
|
|
195
|
-
if (
|
|
230
|
+
if (targetRef) {
|
|
231
|
+
// Push to an existing site: the explicit arg, or this instance's origin.
|
|
232
|
+
const spin = spinner('Resolving cloud site...');
|
|
233
|
+
spin.start();
|
|
234
|
+
try {
|
|
235
|
+
site = await resolveSite(targetRef);
|
|
236
|
+
spin.succeed(`Cloud site: ${site.name || site.sub_domain} (ID: ${site.id})`);
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
spin.fail('Site resolution failed');
|
|
240
|
+
process.exit(1);
|
|
241
|
+
}
|
|
242
|
+
if (!cloudSiteArg) {
|
|
243
|
+
info(`Pushing to the site this instance was cloned from (ID: ${site.id}). Pass a cloud site to override.`);
|
|
244
|
+
}
|
|
245
|
+
else if (!instance.cloudSiteId) {
|
|
246
|
+
// First explicit push from an instance with no recorded origin (e.g.
|
|
247
|
+
// cloned before linking existed): remember it so future bare pushes
|
|
248
|
+
// target this site instead of creating a new one. Don't overwrite an
|
|
249
|
+
// origin that's already set.
|
|
250
|
+
setLocalInstance({ ...instance, cloudSiteId: site.id, cloudSiteName: site.name || site.sub_domain || String(site.id) });
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
else {
|
|
254
|
+
// No arg and not a cloned instance — provision a new site named after
|
|
255
|
+
// the local instance.
|
|
196
256
|
const spin = spinner('Creating cloud site...');
|
|
197
257
|
spin.start();
|
|
198
258
|
try {
|
|
199
259
|
const client = getClient();
|
|
200
|
-
|
|
260
|
+
// Default to a reserved (permanent) site, consistent with `instawp create`.
|
|
261
|
+
const res = await client.post('/sites', { site_name: localName, is_reserved: true });
|
|
201
262
|
site = res.data?.data;
|
|
202
263
|
if (!site?.id)
|
|
203
264
|
throw new Error('Unexpected API response');
|
|
@@ -230,6 +291,9 @@ ${chalk.dim('#')} Data stored at: ${chalk.dim(dir)}
|
|
|
230
291
|
}
|
|
231
292
|
// Re-resolve to get full details
|
|
232
293
|
site = await resolveSite(String(site.id));
|
|
294
|
+
// Link this instance to the site it just created, so subsequent
|
|
295
|
+
// pushes target it instead of creating yet another site.
|
|
296
|
+
setLocalInstance({ ...instance, cloudSiteId: site.id, cloudSiteName: site.name || site.sub_domain || String(site.id) });
|
|
233
297
|
}
|
|
234
298
|
catch (err) {
|
|
235
299
|
spin.fail('Failed to create cloud site');
|
|
@@ -237,18 +301,6 @@ ${chalk.dim('#')} Data stored at: ${chalk.dim(dir)}
|
|
|
237
301
|
process.exit(1);
|
|
238
302
|
}
|
|
239
303
|
}
|
|
240
|
-
else {
|
|
241
|
-
const spin = spinner('Resolving cloud site...');
|
|
242
|
-
spin.start();
|
|
243
|
-
try {
|
|
244
|
-
site = await resolveSite(cloudSiteArg);
|
|
245
|
-
spin.succeed(`Cloud site: ${site.name || site.sub_domain} (ID: ${site.id})`);
|
|
246
|
-
}
|
|
247
|
-
catch {
|
|
248
|
-
spin.fail('Site resolution failed');
|
|
249
|
-
process.exit(1);
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
304
|
// Get SSH access
|
|
253
305
|
const conn = await ensureSshAccess(site.id);
|
|
254
306
|
const remotePath = `/home/${conn.username}/web/${conn.domain}/public_html/wp-content/`;
|
|
@@ -266,22 +318,40 @@ ${chalk.dim('#')} Data stored at: ${chalk.dim(dir)}
|
|
|
266
318
|
info(`Pushing ${chalk.dim(localWpContent)} -> ${chalk.dim(conn.host + ':' + remotePath)}`);
|
|
267
319
|
if (opts.dryRun)
|
|
268
320
|
info('(dry run)');
|
|
269
|
-
const exitCode =
|
|
270
|
-
if (exitCode
|
|
271
|
-
success('Push complete!');
|
|
272
|
-
if (site.url) {
|
|
273
|
-
console.log(`\n ${chalk.dim('Cloud site:')} ${chalk.cyan.underline(site.url)}`);
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
else {
|
|
321
|
+
const exitCode = await syncFiles(conn, localWpContent, remoteTarget, extraArgs, !!opts.dryRun, true);
|
|
322
|
+
if (exitCode !== 0) {
|
|
277
323
|
error(`rsync exited with code ${exitCode}`);
|
|
278
324
|
process.exit(exitCode);
|
|
279
325
|
}
|
|
326
|
+
// Optionally push the database too (OVERWRITES the cloud DB). Handles its
|
|
327
|
+
// own dry-run reporting and confirmation.
|
|
328
|
+
let dbStatus = null;
|
|
329
|
+
if (opts.withDb) {
|
|
330
|
+
dbStatus = await pushDatabase(instance, site, conn, opts);
|
|
331
|
+
}
|
|
332
|
+
// Dry-run output was already emitted by the file sync (and pushDatabase);
|
|
333
|
+
// don't print a misleading "complete".
|
|
334
|
+
if (opts.dryRun)
|
|
335
|
+
return;
|
|
336
|
+
if (dbStatus === 'cancelled') {
|
|
337
|
+
info('Files pushed. Database push was cancelled — the cloud database was not changed.');
|
|
338
|
+
if (site.url)
|
|
339
|
+
console.log(`\n ${chalk.dim('Cloud site:')} ${chalk.cyan.underline(site.url)}`);
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
success('Push complete!');
|
|
343
|
+
if (site.url) {
|
|
344
|
+
console.log(`\n ${chalk.dim('Cloud site:')} ${chalk.cyan.underline(site.url)}`);
|
|
345
|
+
}
|
|
346
|
+
if (!opts.withDb) {
|
|
347
|
+
info('Files only. Database/content changes (pages, posts, settings) were NOT pushed — add --with-db to overwrite the cloud database.');
|
|
348
|
+
}
|
|
280
349
|
});
|
|
281
350
|
// local pull <local-name> <cloud-site>
|
|
282
351
|
local
|
|
283
352
|
.command('pull <local-name> <cloud-site>')
|
|
284
353
|
.description('Pull wp-content from an InstaWP cloud site to local')
|
|
354
|
+
.option('--include <pattern...>', 'Include patterns (e.g. .git)')
|
|
285
355
|
.option('--exclude <pattern...>', 'Additional exclude patterns')
|
|
286
356
|
.option('--dry-run', 'Show what would be transferred')
|
|
287
357
|
.action(async (localName, cloudSiteArg, opts) => {
|
|
@@ -292,7 +362,7 @@ ${chalk.dim('#')} Data stored at: ${chalk.dim(dir)}
|
|
|
292
362
|
process.exit(1);
|
|
293
363
|
}
|
|
294
364
|
if (!checkRsync()) {
|
|
295
|
-
error('rsync is required. Install: brew install rsync');
|
|
365
|
+
error('rsync is required for sync on macOS/Linux. Install: brew install rsync (macOS) or your distro package.');
|
|
296
366
|
process.exit(1);
|
|
297
367
|
}
|
|
298
368
|
const localWpContent = join(instance.path, 'wp-content') + '/';
|
|
@@ -309,11 +379,14 @@ ${chalk.dim('#')} Data stored at: ${chalk.dim(dir)}
|
|
|
309
379
|
}
|
|
310
380
|
const conn = await ensureSshAccess(site.id);
|
|
311
381
|
const remotePath = `/home/${conn.username}/web/${conn.domain}/public_html/wp-content/`;
|
|
312
|
-
const extraArgs = [
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
382
|
+
const extraArgs = [];
|
|
383
|
+
if (opts.include) {
|
|
384
|
+
for (const pattern of opts.include) {
|
|
385
|
+
extraArgs.push(`--include=${pattern}`);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
extraArgs.push('--exclude=database', // Don't overwrite local SQLite database
|
|
389
|
+
'--exclude=db.php', '--exclude=mu-plugins');
|
|
317
390
|
if (opts.exclude) {
|
|
318
391
|
for (const pattern of opts.exclude) {
|
|
319
392
|
extraArgs.push(`--exclude=${pattern}`);
|
|
@@ -323,7 +396,7 @@ ${chalk.dim('#')} Data stored at: ${chalk.dim(dir)}
|
|
|
323
396
|
info(`Pulling ${chalk.dim(conn.host + ':' + remotePath)} -> ${chalk.dim(localWpContent)}`);
|
|
324
397
|
if (opts.dryRun)
|
|
325
398
|
info('(dry run)');
|
|
326
|
-
const exitCode =
|
|
399
|
+
const exitCode = await syncFiles(conn, remoteSource, localWpContent, extraArgs, !!opts.dryRun, true);
|
|
327
400
|
if (exitCode === 0) {
|
|
328
401
|
success('Pull complete! Restart the local site to see changes.');
|
|
329
402
|
}
|
|
@@ -338,10 +411,12 @@ ${chalk.dim('#')} Data stored at: ${chalk.dim(dir)}
|
|
|
338
411
|
.description('Clone a complete InstaWP cloud site to local')
|
|
339
412
|
.option('--name <name>', 'Local instance name (defaults to cloud site name)')
|
|
340
413
|
.option('--no-start', 'Do not start the local site after cloning')
|
|
414
|
+
.option('--force', 'Overwrite existing local instance')
|
|
415
|
+
.option('--include <pattern...>', 'Include patterns for rsync (e.g. .git)')
|
|
341
416
|
.action(async (cloudSiteArg, opts) => {
|
|
342
417
|
requireAuth();
|
|
343
418
|
if (!checkRsync()) {
|
|
344
|
-
error('rsync is required. Install: brew install rsync');
|
|
419
|
+
error('rsync is required for sync on macOS/Linux. Install: brew install rsync (macOS) or your distro package.');
|
|
345
420
|
process.exit(1);
|
|
346
421
|
}
|
|
347
422
|
// 1. Resolve cloud site
|
|
@@ -358,10 +433,17 @@ ${chalk.dim('#')} Data stored at: ${chalk.dim(dir)}
|
|
|
358
433
|
}
|
|
359
434
|
// 2. Create local instance
|
|
360
435
|
const instances = getLocalInstances();
|
|
361
|
-
const name =
|
|
436
|
+
const name = opts.name ? sanitizeName(opts.name) : defaultInstanceName(site);
|
|
362
437
|
if (instances[name]) {
|
|
363
|
-
|
|
364
|
-
|
|
438
|
+
if (!opts.force) {
|
|
439
|
+
error(`Local instance "${name}" already exists. Use --force to overwrite or --name to pick a different name.`);
|
|
440
|
+
process.exit(1);
|
|
441
|
+
}
|
|
442
|
+
// Force: delete existing instance first
|
|
443
|
+
stopServerProcess(instances[name]);
|
|
444
|
+
deleteInstanceDir(name);
|
|
445
|
+
removeLocalInstance(name);
|
|
446
|
+
info(`Existing instance "${name}" removed.`);
|
|
365
447
|
}
|
|
366
448
|
const port = await getNextPort(instances);
|
|
367
449
|
const dir = createInstanceDir(name);
|
|
@@ -372,6 +454,10 @@ ${chalk.dim('#')} Data stored at: ${chalk.dim(dir)}
|
|
|
372
454
|
wp: site.wp_version || 'latest',
|
|
373
455
|
path: dir,
|
|
374
456
|
createdAt: new Date().toISOString(),
|
|
457
|
+
// Remember the origin so `local push` (no arg) pushes back here instead
|
|
458
|
+
// of creating a new site.
|
|
459
|
+
cloudSiteId: site.id,
|
|
460
|
+
cloudSiteName: site.name || site.sub_domain || String(site.id),
|
|
375
461
|
};
|
|
376
462
|
setLocalInstance(instance);
|
|
377
463
|
success(`Local instance "${name}" created`);
|
|
@@ -402,19 +488,26 @@ ${chalk.dim('#')} Data stored at: ${chalk.dim(dir)}
|
|
|
402
488
|
const remotePath = `/home/${conn.username}/web/${conn.domain}/public_html/wp-content/`;
|
|
403
489
|
const remoteSource = `${conn.username}@${conn.host}:${remotePath}`;
|
|
404
490
|
info(`Pulling wp-content from ${chalk.dim(conn.domain)}...`);
|
|
405
|
-
const
|
|
491
|
+
const includeArgs = [];
|
|
492
|
+
if (opts.include) {
|
|
493
|
+
for (const pattern of opts.include) {
|
|
494
|
+
includeArgs.push(`--include=${pattern}`);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
const rsyncExit = await syncFiles(conn, remoteSource, localWpContent, [
|
|
498
|
+
...includeArgs,
|
|
406
499
|
'--exclude=cache',
|
|
407
500
|
'--exclude=upgrade',
|
|
408
501
|
'--exclude=wflogs',
|
|
409
502
|
'--exclude=backup*',
|
|
410
503
|
], false, true);
|
|
411
504
|
if (rsyncExit !== 0) {
|
|
412
|
-
error(`wp-content sync failed (
|
|
505
|
+
error(`wp-content sync failed (exit code ${rsyncExit})`);
|
|
413
506
|
}
|
|
414
507
|
// 5b. Pull non-core root files (CLAUDE.md, .htaccess, wp-cli.yml, etc.)
|
|
415
508
|
const remoteRoot = `/home/${conn.username}/web/${conn.domain}/public_html/`;
|
|
416
509
|
const rootRemote = `${conn.username}@${conn.host}:${remoteRoot}`;
|
|
417
|
-
|
|
510
|
+
await syncFiles(conn, rootRemote, dir + '/', [
|
|
418
511
|
'--exclude=wp-admin/',
|
|
419
512
|
'--exclude=wp-includes/',
|
|
420
513
|
'--exclude=wp-content/',
|
|
@@ -433,7 +526,7 @@ ${chalk.dim('#')} Data stored at: ${chalk.dim(dir)}
|
|
|
433
526
|
const dbSpin2 = spinner('Importing database...');
|
|
434
527
|
dbSpin2.start();
|
|
435
528
|
try {
|
|
436
|
-
const mysql2sqlitePath =
|
|
529
|
+
const mysql2sqlitePath = resolveFromModule(import.meta.url, '..', '..', 'scripts', 'mysql2sqlite');
|
|
437
530
|
const dbDir = join(dir, 'wp-content', 'database');
|
|
438
531
|
const sqliteDbPath = join(dbDir, '.ht.sqlite');
|
|
439
532
|
// Clean slate for database dir
|
|
@@ -446,8 +539,15 @@ ${chalk.dim('#')} Data stored at: ${chalk.dim(dir)}
|
|
|
446
539
|
if (sqlStart > 0) {
|
|
447
540
|
writeFileSync(dumpPath, rawDump.substring(sqlStart));
|
|
448
541
|
}
|
|
449
|
-
// Convert MySQL → SQLite
|
|
450
|
-
|
|
542
|
+
// Convert MySQL → SQLite via awk (mysql2sqlite is an awk script).
|
|
543
|
+
// Windows doesn't honor shebangs, so invoke awk explicitly.
|
|
544
|
+
const awk = findAwk();
|
|
545
|
+
if (!awk) {
|
|
546
|
+
throw new Error('awk not found. ' + (process.platform === 'win32'
|
|
547
|
+
? 'Reinstall the CLI — the bundled busybox.exe is missing.'
|
|
548
|
+
: 'Install awk/gawk.'));
|
|
549
|
+
}
|
|
550
|
+
const convertResult = spawnSync(awk.cmd, [...awk.prefixArgs, '-f', mysql2sqlitePath, dumpPath], {
|
|
451
551
|
encoding: 'utf-8',
|
|
452
552
|
maxBuffer: 500 * 1024 * 1024,
|
|
453
553
|
timeout: 120000,
|
|
@@ -458,69 +558,54 @@ ${chalk.dim('#')} Data stored at: ${chalk.dim(dir)}
|
|
|
458
558
|
// Add DROP TABLE before each CREATE TABLE
|
|
459
559
|
let sqliteSql = convertResult.stdout;
|
|
460
560
|
sqliteSql = sqliteSql.replace(/^(CREATE TABLE `([^`]+)`)/gm, 'DROP TABLE IF EXISTS `$2`;\n$1');
|
|
461
|
-
//
|
|
462
|
-
const
|
|
463
|
-
writeFileSync(tmpSql, sqliteSql);
|
|
464
|
-
spawnSync('sqlite3', [sqliteDbPath], {
|
|
465
|
-
input: `.read ${tmpSql}\n`,
|
|
466
|
-
encoding: 'utf-8',
|
|
467
|
-
timeout: 120000,
|
|
468
|
-
});
|
|
469
|
-
// Find the table prefix and rename to wp_
|
|
470
|
-
const tablesResult = spawnSync('sqlite3', [sqliteDbPath, '.tables'], { encoding: 'utf-8' });
|
|
471
|
-
const allTables = (tablesResult.stdout || '').split(/\s+/).filter(Boolean);
|
|
472
|
-
const optionsTable = allTables.find((t) => t.endsWith('_options'));
|
|
473
|
-
const oldPrefix = optionsTable ? optionsTable.replace('options', '') : 'wp_';
|
|
474
|
-
if (oldPrefix !== 'wp_') {
|
|
475
|
-
// Rename tables
|
|
476
|
-
const renameStatements = allTables
|
|
477
|
-
.filter((t) => t.startsWith(oldPrefix))
|
|
478
|
-
.map((t) => `ALTER TABLE \`${t}\` RENAME TO \`wp_${t.substring(oldPrefix.length)}\`;`)
|
|
479
|
-
.join('\n');
|
|
480
|
-
spawnSync('sqlite3', [sqliteDbPath, renameStatements], { encoding: 'utf-8' });
|
|
481
|
-
// Rename meta keys and option names that contain the old prefix
|
|
482
|
-
const fixPrefixSql = [
|
|
483
|
-
`UPDATE wp_usermeta SET meta_key = REPLACE(meta_key, '${oldPrefix}', 'wp_') WHERE meta_key LIKE '${oldPrefix}%';`,
|
|
484
|
-
`UPDATE wp_options SET option_name = REPLACE(option_name, '${oldPrefix}', 'wp_') WHERE option_name LIKE '${oldPrefix}%';`,
|
|
485
|
-
].join('\n');
|
|
486
|
-
spawnSync('sqlite3', [sqliteDbPath, fixPrefixSql], { encoding: 'utf-8' });
|
|
487
|
-
}
|
|
488
|
-
// Search-replace old cloud URL → localhost
|
|
489
|
-
const localUrl = `http://127.0.0.1:${instance.port}`;
|
|
490
|
-
const oldDomain = site.url || site.sub_domain || '';
|
|
491
|
-
const oldUrls = [
|
|
492
|
-
oldDomain,
|
|
493
|
-
oldDomain.replace('https://', 'http://'),
|
|
494
|
-
].filter(Boolean);
|
|
495
|
-
for (const oldUrl of oldUrls) {
|
|
496
|
-
const replaceSql = [
|
|
497
|
-
`UPDATE wp_options SET option_value = REPLACE(option_value, '${oldUrl}', '${localUrl}') WHERE option_value LIKE '%${oldUrl}%';`,
|
|
498
|
-
`UPDATE wp_posts SET post_content = REPLACE(post_content, '${oldUrl}', '${localUrl}') WHERE post_content LIKE '%${oldUrl}%';`,
|
|
499
|
-
`UPDATE wp_posts SET guid = REPLACE(guid, '${oldUrl}', '${localUrl}') WHERE guid LIKE '%${oldUrl}%';`,
|
|
500
|
-
`UPDATE wp_postmeta SET meta_value = REPLACE(meta_value, '${oldUrl}', '${localUrl}') WHERE meta_value LIKE '%${oldUrl}%';`,
|
|
501
|
-
`UPDATE wp_comments SET comment_content = REPLACE(comment_content, '${oldUrl}', '${localUrl}') WHERE comment_content LIKE '%${oldUrl}%';`,
|
|
502
|
-
].join('\n');
|
|
503
|
-
spawnSync('sqlite3', [sqliteDbPath, replaceSql], { encoding: 'utf-8' });
|
|
504
|
-
}
|
|
505
|
-
// Ensure siteurl/home are correct
|
|
506
|
-
spawnSync('sqlite3', [sqliteDbPath,
|
|
507
|
-
`UPDATE wp_options SET option_value='${localUrl}' WHERE option_name IN ('siteurl','home');`,
|
|
508
|
-
], { encoding: 'utf-8' });
|
|
509
|
-
// Get admin username for blueprint login step
|
|
510
|
-
const adminResult = spawnSync('sqlite3', [sqliteDbPath,
|
|
511
|
-
"SELECT user_login FROM wp_users WHERE ID = (SELECT user_id FROM wp_usermeta WHERE meta_key = 'wp_capabilities' AND meta_value LIKE '%administrator%' LIMIT 1);",
|
|
512
|
-
], { encoding: 'utf-8' });
|
|
513
|
-
adminUsername = (adminResult.stdout || '').trim() || 'admin';
|
|
514
|
-
// Count tables for output
|
|
515
|
-
const countResult = spawnSync('sqlite3', [sqliteDbPath,
|
|
516
|
-
"SELECT COUNT(*) FROM sqlite_master WHERE type='table';",
|
|
517
|
-
], { encoding: 'utf-8' });
|
|
518
|
-
// Clean up temp file
|
|
561
|
+
// Import directly via better-sqlite3 (no external sqlite3 CLI needed)
|
|
562
|
+
const db = new Database(sqliteDbPath);
|
|
519
563
|
try {
|
|
520
|
-
|
|
564
|
+
db.exec(sqliteSql);
|
|
565
|
+
// Find the table prefix and rename to wp_
|
|
566
|
+
const tableRows = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").all();
|
|
567
|
+
const allTables = tableRows.map(r => r.name);
|
|
568
|
+
const optionsTable = allTables.find(t => t.endsWith('_options'));
|
|
569
|
+
const oldPrefix = optionsTable ? optionsTable.replace('options', '') : 'wp_';
|
|
570
|
+
if (oldPrefix !== 'wp_') {
|
|
571
|
+
const renames = allTables
|
|
572
|
+
.filter(t => t.startsWith(oldPrefix))
|
|
573
|
+
.map(t => `ALTER TABLE \`${t}\` RENAME TO \`wp_${t.substring(oldPrefix.length)}\``);
|
|
574
|
+
db.exec(renames.join(';\n') + ';');
|
|
575
|
+
// Rename meta keys and option names that contain the old prefix
|
|
576
|
+
db.prepare('UPDATE wp_usermeta SET meta_key = REPLACE(meta_key, ?, ?) WHERE meta_key LIKE ?').run(oldPrefix, 'wp_', oldPrefix + '%');
|
|
577
|
+
db.prepare('UPDATE wp_options SET option_name = REPLACE(option_name, ?, ?) WHERE option_name LIKE ?').run(oldPrefix, 'wp_', oldPrefix + '%');
|
|
578
|
+
}
|
|
579
|
+
// Search-replace old cloud URL → localhost (bound params: no SQL injection)
|
|
580
|
+
const localUrl = `http://127.0.0.1:${instance.port}`;
|
|
581
|
+
const oldDomain = site.url || site.sub_domain || '';
|
|
582
|
+
const oldUrls = [
|
|
583
|
+
oldDomain,
|
|
584
|
+
oldDomain.replace('https://', 'http://'),
|
|
585
|
+
].filter(Boolean);
|
|
586
|
+
const replaceStmts = [
|
|
587
|
+
'UPDATE wp_options SET option_value = REPLACE(option_value, ?, ?) WHERE option_value LIKE ?',
|
|
588
|
+
'UPDATE wp_posts SET post_content = REPLACE(post_content, ?, ?) WHERE post_content LIKE ?',
|
|
589
|
+
'UPDATE wp_posts SET guid = REPLACE(guid, ?, ?) WHERE guid LIKE ?',
|
|
590
|
+
'UPDATE wp_postmeta SET meta_value = REPLACE(meta_value, ?, ?) WHERE meta_value LIKE ?',
|
|
591
|
+
'UPDATE wp_comments SET comment_content = REPLACE(comment_content, ?, ?) WHERE comment_content LIKE ?',
|
|
592
|
+
].map(s => db.prepare(s));
|
|
593
|
+
for (const oldUrl of oldUrls) {
|
|
594
|
+
const likePattern = '%' + oldUrl + '%';
|
|
595
|
+
for (const stmt of replaceStmts) {
|
|
596
|
+
stmt.run(oldUrl, localUrl, likePattern);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
db.prepare("UPDATE wp_options SET option_value = ? WHERE option_name IN ('siteurl','home')").run(localUrl);
|
|
600
|
+
// Get admin username for blueprint login step
|
|
601
|
+
const adminRow = db.prepare("SELECT user_login FROM wp_users WHERE ID = (SELECT user_id FROM wp_usermeta WHERE meta_key = 'wp_capabilities' AND meta_value LIKE '%administrator%' LIMIT 1)").get();
|
|
602
|
+
adminUsername = adminRow?.user_login || 'admin';
|
|
603
|
+
const tableCount = db.prepare("SELECT COUNT(*) AS c FROM sqlite_master WHERE type='table'").get().c;
|
|
604
|
+
dbSpin2.succeed(`Database imported (${tableCount} tables, admin: ${adminUsername})`);
|
|
605
|
+
}
|
|
606
|
+
finally {
|
|
607
|
+
db.close();
|
|
521
608
|
}
|
|
522
|
-
catch { }
|
|
523
|
-
dbSpin2.succeed(`Database imported (${(countResult.stdout || '').trim()} tables, admin: ${adminUsername})`);
|
|
524
609
|
}
|
|
525
610
|
catch (err) {
|
|
526
611
|
dbSpin2.fail('Database import failed: ' + err.message);
|
|
@@ -578,12 +663,263 @@ ${chalk.bold.green('Clone complete!')}
|
|
|
578
663
|
}
|
|
579
664
|
});
|
|
580
665
|
}
|
|
666
|
+
/** True if a URL is a plain http(s) URL safe to embed in a single-quoted shell arg. */
|
|
667
|
+
function isShellSafeUrl(u) {
|
|
668
|
+
return /^https?:\/\/[^\s'"\\$`]+$/.test(u);
|
|
669
|
+
}
|
|
670
|
+
/**
|
|
671
|
+
* Push the local Playground SQLite database to the cloud site's MySQL, OVERWRITING
|
|
672
|
+
* it. Steps: read the local site URL, discover the cloud table prefix + existing
|
|
673
|
+
* tables, generate a data-only MySQL dump (TRUNCATE+INSERT for tables present on
|
|
674
|
+
* both), back up the cloud DB, upload + import, then `wp search-replace` the URL
|
|
675
|
+
* (serialization-safe). Honors --dry-run / --no-backup / --force.
|
|
676
|
+
*/
|
|
677
|
+
async function pushDatabase(instance, site, conn, opts) {
|
|
678
|
+
const sqlitePath = join(instance.path, 'wp-content', 'database', '.ht.sqlite');
|
|
679
|
+
if (!existsSync(sqlitePath)) {
|
|
680
|
+
error('No local database found (expected wp-content/database/.ht.sqlite). Skipping DB push.');
|
|
681
|
+
process.exit(1);
|
|
682
|
+
}
|
|
683
|
+
const wpPath = `/home/${conn.username}/web/${conn.domain}/public_html`;
|
|
684
|
+
// Authoritative local URL from the DB (handles port drift); cloud URL from the site.
|
|
685
|
+
let fromUrl = `http://127.0.0.1:${instance.port}`;
|
|
686
|
+
try {
|
|
687
|
+
const ldb = new Database(sqlitePath, { readonly: true });
|
|
688
|
+
try {
|
|
689
|
+
const row = ldb.prepare("SELECT option_value AS v FROM wp_options WHERE option_name='siteurl'").get();
|
|
690
|
+
if (row?.v)
|
|
691
|
+
fromUrl = String(row.v).replace(/\/+$/, '');
|
|
692
|
+
}
|
|
693
|
+
finally {
|
|
694
|
+
ldb.close();
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
catch { /* fall back to the constructed local URL */ }
|
|
698
|
+
const toUrl = String(site.url || `https://${conn.domain}`).replace(/\/+$/, '');
|
|
699
|
+
// Destructive confirmation (skipped on --force; --json requires --force).
|
|
700
|
+
if (!opts.force && !opts.dryRun) {
|
|
701
|
+
if (isJsonMode()) {
|
|
702
|
+
error('--force is required with --with-db in --json mode (cannot prompt before overwriting the cloud DB).');
|
|
703
|
+
process.exit(1);
|
|
704
|
+
}
|
|
705
|
+
const backupLine = opts.backup !== false
|
|
706
|
+
? `The cloud DB will be backed up to ~/db-backup-<ts>.sql.gz first.`
|
|
707
|
+
: chalk.red('NO cloud backup will be taken (--no-backup). This is irreversible.');
|
|
708
|
+
console.log(`\nThis will ${chalk.bold.red('OVERWRITE')} the database on ${chalk.bold(conn.domain)} with your local data.`);
|
|
709
|
+
console.log(backupLine);
|
|
710
|
+
const readline = await import('node:readline');
|
|
711
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
712
|
+
const ans = await new Promise((r) => rl.question('Continue? (y/N) ', r));
|
|
713
|
+
rl.close();
|
|
714
|
+
const yes = ans.trim().toLowerCase();
|
|
715
|
+
if (yes !== 'y' && yes !== 'yes') {
|
|
716
|
+
return 'cancelled';
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
// Discover cloud prefix + existing tables so we only TRUNCATE/INSERT tables
|
|
720
|
+
// that exist there (a missing-table TRUNCATE would abort the whole import).
|
|
721
|
+
const inspectSpin = spinner('Inspecting cloud database...');
|
|
722
|
+
inspectSpin.start();
|
|
723
|
+
// Parse discovery output defensively — InstaWP SSH prepends a login banner to
|
|
724
|
+
// non-interactive stdout (the clone flow strips the same banner). A banner
|
|
725
|
+
// leaking into the prefix would mismatch every table and silently push nothing.
|
|
726
|
+
const prefixRes = execViaSsh(conn, `cd ${wpPath} && wp config get table_prefix`);
|
|
727
|
+
const cloudPrefix = parseTablePrefix(prefixRes.exitCode === 0 ? prefixRes.stdout : '', 'wp_');
|
|
728
|
+
const tablesRes = execViaSsh(conn, `cd ${wpPath} && wp db query 'SHOW TABLES' --skip-column-names`);
|
|
729
|
+
if (tablesRes.exitCode !== 0) {
|
|
730
|
+
inspectSpin.fail('Could not read the cloud database');
|
|
731
|
+
if (tablesRes.stderr)
|
|
732
|
+
error(tablesRes.stderr.trim());
|
|
733
|
+
process.exit(1);
|
|
734
|
+
}
|
|
735
|
+
const cloudTables = parseSqlTableNames(tablesRes.stdout);
|
|
736
|
+
inspectSpin.succeed(`Cloud DB: prefix "${cloudPrefix}", ${cloudTables.size} tables`);
|
|
737
|
+
// Generate the data-only MySQL dump from local SQLite.
|
|
738
|
+
const localDumpPath = join(tmpdir(), `instawp-localpush-${randomBytes(6).toString('hex')}.sql`);
|
|
739
|
+
const genSpin = spinner('Generating database dump...');
|
|
740
|
+
genSpin.start();
|
|
741
|
+
let dump;
|
|
742
|
+
try {
|
|
743
|
+
dump = generateMysqlDump({ sqlitePath, cloudPrefix, cloudTables, outPath: localDumpPath });
|
|
744
|
+
}
|
|
745
|
+
catch (err) {
|
|
746
|
+
genSpin.fail('Failed to generate the database dump');
|
|
747
|
+
error(err?.message || String(err));
|
|
748
|
+
try {
|
|
749
|
+
unlinkSync(localDumpPath);
|
|
750
|
+
}
|
|
751
|
+
catch { /* ignore */ }
|
|
752
|
+
process.exit(1);
|
|
753
|
+
}
|
|
754
|
+
genSpin.succeed(`Dump ready: ${dump.tables.length} table(s), ${dump.totalRows} row(s)` +
|
|
755
|
+
(dump.skipped.length ? ` (skipped ${dump.skipped.length} local-only table(s))` : ''));
|
|
756
|
+
// Cloud tables (with the cloud prefix) that have no local counterpart — they
|
|
757
|
+
// keep their existing data on an overwrite. Surface them so it's not surprising.
|
|
758
|
+
const dumpedCloud = new Set(dump.tables.map((t) => t.cloud));
|
|
759
|
+
const untouched = [...cloudTables].filter((t) => t.startsWith(cloudPrefix) && !dumpedCloud.has(t));
|
|
760
|
+
// Dry run: report and stop (no cloud writes).
|
|
761
|
+
if (opts.dryRun) {
|
|
762
|
+
if (isJsonMode()) {
|
|
763
|
+
console.log(JSON.stringify({ success: true, dry_run: true, db: { from_url: fromUrl, to_url: toUrl, tables: dump.tables, skipped: dump.skipped, untouched, total_rows: dump.totalRows } }));
|
|
764
|
+
}
|
|
765
|
+
else {
|
|
766
|
+
info(`(dry run) Would OVERWRITE the cloud DB on ${conn.domain}:`);
|
|
767
|
+
for (const t of dump.tables)
|
|
768
|
+
console.log(` ${chalk.dim('•')} ${t.cloud} (${t.rows} rows)`);
|
|
769
|
+
if (dump.skipped.length)
|
|
770
|
+
info(`(dry run) Skipped local-only tables: ${dump.skipped.join(', ')}`);
|
|
771
|
+
if (untouched.length)
|
|
772
|
+
info(`(dry run) Cloud tables kept as-is (no local counterpart): ${untouched.join(', ')}`);
|
|
773
|
+
if (fromUrl !== toUrl)
|
|
774
|
+
info(`(dry run) Then: wp search-replace ${fromUrl} ${toUrl}`);
|
|
775
|
+
}
|
|
776
|
+
try {
|
|
777
|
+
unlinkSync(localDumpPath);
|
|
778
|
+
}
|
|
779
|
+
catch { /* ignore */ }
|
|
780
|
+
return 'dry';
|
|
781
|
+
}
|
|
782
|
+
// Refuse to push an empty dump: if nothing intersected, the cloud discovery
|
|
783
|
+
// (prefix/tables) almost certainly went wrong — overwriting would be a silent
|
|
784
|
+
// no-op that looks successful. Fail loud and change nothing.
|
|
785
|
+
if (dump.tables.length === 0) {
|
|
786
|
+
error(`No local tables matched the cloud database (cloud prefix "${cloudPrefix}", ${cloudTables.size} cloud tables). Refusing to push an empty database — nothing was changed.`);
|
|
787
|
+
try {
|
|
788
|
+
unlinkSync(localDumpPath);
|
|
789
|
+
}
|
|
790
|
+
catch { /* ignore */ }
|
|
791
|
+
process.exit(1);
|
|
792
|
+
}
|
|
793
|
+
if (untouched.length) {
|
|
794
|
+
info(`${untouched.length} cloud table(s) have no local counterpart and will KEEP their existing data: ${untouched.slice(0, 8).join(', ')}${untouched.length > 8 ? ', …' : ''}`);
|
|
795
|
+
}
|
|
796
|
+
// Back up the cloud DB first (unless --no-backup). Random suffix so same-second
|
|
797
|
+
// reruns never clobber a prior backup.
|
|
798
|
+
const takeBackup = opts.backup !== false;
|
|
799
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-').replace(/-\d{3}Z$/, '');
|
|
800
|
+
const backupFilename = `db-backup-${ts}-${randomBytes(3).toString('hex')}.sql.gz`;
|
|
801
|
+
const backupRemotePath = `/home/${conn.username}/${backupFilename}`;
|
|
802
|
+
if (takeBackup) {
|
|
803
|
+
const bSpin = spinner(`Backing up cloud database to ~/${backupFilename}...`);
|
|
804
|
+
bSpin.start();
|
|
805
|
+
const bRes = execViaSsh(conn, `cd ${wpPath} && wp db export --single-transaction - | gzip > ${backupRemotePath}`);
|
|
806
|
+
if (bRes.exitCode !== 0) {
|
|
807
|
+
bSpin.fail('Cloud DB backup failed — aborting DB push');
|
|
808
|
+
if (bRes.stderr)
|
|
809
|
+
error(bRes.stderr.trim());
|
|
810
|
+
try {
|
|
811
|
+
unlinkSync(localDumpPath);
|
|
812
|
+
}
|
|
813
|
+
catch { /* ignore */ }
|
|
814
|
+
process.exit(1);
|
|
815
|
+
}
|
|
816
|
+
bSpin.succeed(`Cloud DB backed up: ~/${backupFilename}`);
|
|
817
|
+
}
|
|
818
|
+
else {
|
|
819
|
+
info('Skipping cloud DB backup (--no-backup).');
|
|
820
|
+
}
|
|
821
|
+
// Upload + import.
|
|
822
|
+
const remoteTmp = `/tmp/instawp-dbimport-${randomBytes(6).toString('hex')}.sql`;
|
|
823
|
+
const upSpin = spinner('Uploading database dump...');
|
|
824
|
+
upSpin.start();
|
|
825
|
+
const scpExit = scpUpload(conn, localDumpPath, remoteTmp);
|
|
826
|
+
try {
|
|
827
|
+
unlinkSync(localDumpPath);
|
|
828
|
+
}
|
|
829
|
+
catch { /* ignore */ }
|
|
830
|
+
if (scpExit !== 0) {
|
|
831
|
+
upSpin.fail(`Upload failed (scp exit ${scpExit})`);
|
|
832
|
+
if (takeBackup)
|
|
833
|
+
info(`Cloud backup preserved: ~/${backupFilename}`);
|
|
834
|
+
process.exit(1);
|
|
835
|
+
}
|
|
836
|
+
upSpin.succeed('Upload complete');
|
|
837
|
+
const impSpin = spinner(`Importing database on ${conn.domain}...`);
|
|
838
|
+
impSpin.start();
|
|
839
|
+
const impRes = execViaSsh(conn, `cd ${wpPath} && wp db import ${remoteTmp}`);
|
|
840
|
+
if (impRes.exitCode !== 0) {
|
|
841
|
+
impSpin.fail('Database import failed');
|
|
842
|
+
if (impRes.stderr)
|
|
843
|
+
error(impRes.stderr.trim());
|
|
844
|
+
else if (impRes.stdout)
|
|
845
|
+
error(impRes.stdout.trim());
|
|
846
|
+
execViaSsh(conn, `rm -f ${remoteTmp}`);
|
|
847
|
+
if (takeBackup) {
|
|
848
|
+
info(`Cloud backup preserved at ~/${backupFilename}. Restore it with:`);
|
|
849
|
+
console.log(` ssh ${conn.username}@${conn.host} 'cd ${wpPath} && gunzip -c ${backupRemotePath} | wp db import -'`);
|
|
850
|
+
}
|
|
851
|
+
else {
|
|
852
|
+
error('No backup was taken — the cloud database may be inconsistent.');
|
|
853
|
+
}
|
|
854
|
+
process.exit(1);
|
|
855
|
+
}
|
|
856
|
+
impSpin.succeed('Database imported');
|
|
857
|
+
// Rewrite local URL → cloud URL, serialization-safe via wp-cli.
|
|
858
|
+
if (fromUrl !== toUrl) {
|
|
859
|
+
if (isShellSafeUrl(fromUrl) && isShellSafeUrl(toUrl)) {
|
|
860
|
+
const srSpin = spinner(`Rewriting URLs (${fromUrl} → ${toUrl})...`);
|
|
861
|
+
srSpin.start();
|
|
862
|
+
const srRes = execViaSsh(conn, `cd ${wpPath} && wp search-replace '${fromUrl}' '${toUrl}' --all-tables --report-changed-only`);
|
|
863
|
+
if (srRes.exitCode !== 0) {
|
|
864
|
+
srSpin.fail('URL rewrite failed (DB imported; run search-replace manually if links are wrong)');
|
|
865
|
+
if (srRes.stderr)
|
|
866
|
+
error(srRes.stderr.trim());
|
|
867
|
+
}
|
|
868
|
+
else {
|
|
869
|
+
srSpin.succeed('URLs rewritten');
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
else {
|
|
873
|
+
info(`Skipped URL rewrite (unsafe URL). Run manually: wp search-replace '<local>' '${toUrl}' --all-tables`);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
// Flush caches + clean up remote temp (best effort).
|
|
877
|
+
execViaSsh(conn, `cd ${wpPath} && wp cache flush`);
|
|
878
|
+
execViaSsh(conn, `rm -f ${remoteTmp}`);
|
|
879
|
+
if (takeBackup)
|
|
880
|
+
info(`Cloud DB backup kept at ~/${backupFilename} (on remote).`);
|
|
881
|
+
return 'done';
|
|
882
|
+
}
|
|
581
883
|
function checkRsync() {
|
|
884
|
+
// Windows transfers go over pure-JS SFTP, so rsync isn't required there.
|
|
885
|
+
if (process.platform === 'win32')
|
|
886
|
+
return true;
|
|
582
887
|
const result = spawnSync('which', ['rsync'], { stdio: 'ignore' });
|
|
583
888
|
return result.status === 0;
|
|
584
889
|
}
|
|
585
|
-
|
|
586
|
-
|
|
890
|
+
/**
|
|
891
|
+
* Locate an awk-compatible interpreter. Resolution order:
|
|
892
|
+
* 1. Bundled BusyBox-w64 in bin/win32/ (Windows only — invoked as `busybox awk`)
|
|
893
|
+
* 2. `awk` or `gawk` in PATH
|
|
894
|
+
* 3. Common Git-for-Windows install dirs (Windows only)
|
|
895
|
+
*
|
|
896
|
+
* Returns the command path plus any arg-prefix that must precede the awk
|
|
897
|
+
* arguments (busybox uses `busybox awk -f script input`).
|
|
898
|
+
*/
|
|
899
|
+
function findAwk() {
|
|
900
|
+
const bb = bundledBusybox();
|
|
901
|
+
if (bb)
|
|
902
|
+
return { cmd: bb, prefixArgs: ['awk'] };
|
|
903
|
+
const cmd = process.platform === 'win32' ? 'where' : 'which';
|
|
904
|
+
for (const name of ['awk', 'gawk']) {
|
|
905
|
+
const r = spawnSync(cmd, [name], { stdio: 'pipe' });
|
|
906
|
+
if (r.status === 0)
|
|
907
|
+
return { cmd: name, prefixArgs: [] };
|
|
908
|
+
}
|
|
909
|
+
if (process.platform === 'win32') {
|
|
910
|
+
const candidates = [
|
|
911
|
+
'C:\\Program Files\\Git\\usr\\bin\\awk.exe',
|
|
912
|
+
'C:\\Program Files (x86)\\Git\\usr\\bin\\awk.exe',
|
|
913
|
+
];
|
|
914
|
+
if (process.env.PROGRAMFILES) {
|
|
915
|
+
candidates.push(process.env.PROGRAMFILES + '\\Git\\usr\\bin\\awk.exe');
|
|
916
|
+
}
|
|
917
|
+
for (const c of candidates) {
|
|
918
|
+
if (existsSync(c))
|
|
919
|
+
return { cmd: c, prefixArgs: [] };
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
return null;
|
|
587
923
|
}
|
|
588
924
|
// Playground supports: 7.4, 8.0, 8.1, 8.2, 8.3, 8.4, 8.5
|
|
589
925
|
const PLAYGROUND_PHP_VERSIONS = ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5'];
|
|
@@ -611,27 +947,51 @@ function printUrls(port) {
|
|
|
611
947
|
}
|
|
612
948
|
async function launchServer(instance, opts) {
|
|
613
949
|
const shouldOpen = opts.open !== false;
|
|
950
|
+
const json = isJsonMode();
|
|
614
951
|
if (opts.background) {
|
|
615
|
-
const spin = spinner(`Starting "${instance.name}" in background...`);
|
|
616
|
-
spin
|
|
952
|
+
const spin = json ? null : spinner(`Starting "${instance.name}" in background...`);
|
|
953
|
+
spin?.start();
|
|
617
954
|
try {
|
|
618
955
|
const { pid, url } = await startServerBackground(instance, opts.blueprint);
|
|
619
|
-
|
|
620
|
-
|
|
956
|
+
if (json) {
|
|
957
|
+
console.log(JSON.stringify({
|
|
958
|
+
success: true,
|
|
959
|
+
data: {
|
|
960
|
+
name: instance.name,
|
|
961
|
+
url,
|
|
962
|
+
port: instance.port,
|
|
963
|
+
pid,
|
|
964
|
+
wp: instance.wp,
|
|
965
|
+
php: instance.php,
|
|
966
|
+
path: instance.path,
|
|
967
|
+
},
|
|
968
|
+
}));
|
|
969
|
+
}
|
|
970
|
+
else {
|
|
971
|
+
spin?.succeed(`Running in background (PID: ${pid})`);
|
|
972
|
+
printUrls(instance.port);
|
|
973
|
+
info(`Stop with: instawp local stop ${instance.name}`);
|
|
974
|
+
info(`Logs: ${instance.path}/server.log`);
|
|
975
|
+
}
|
|
621
976
|
if (shouldOpen)
|
|
622
977
|
await openWpAdmin(url);
|
|
623
|
-
info(`Stop with: instawp local stop ${instance.name}`);
|
|
624
|
-
info(`Logs: ${instance.path}/server.log`);
|
|
625
978
|
}
|
|
626
979
|
catch (err) {
|
|
627
|
-
|
|
628
|
-
|
|
980
|
+
if (json) {
|
|
981
|
+
console.log(JSON.stringify({ success: false, error: err.message }));
|
|
982
|
+
}
|
|
983
|
+
else {
|
|
984
|
+
spin?.fail('Failed to start');
|
|
985
|
+
error(err.message);
|
|
986
|
+
}
|
|
629
987
|
process.exit(1);
|
|
630
988
|
}
|
|
631
989
|
}
|
|
632
990
|
else {
|
|
633
|
-
|
|
634
|
-
|
|
991
|
+
if (!json) {
|
|
992
|
+
printUrls(instance.port);
|
|
993
|
+
console.log(chalk.dim('\nPress Ctrl+C to stop.\n'));
|
|
994
|
+
}
|
|
635
995
|
try {
|
|
636
996
|
await startServer(instance, {
|
|
637
997
|
blueprint: opts.blueprint,
|