@instawp/cli 0.0.1-beta.2 → 0.0.1-beta.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CHANGELOG.md +203 -0
  2. package/README.md +151 -17
  3. package/dist/commands/db.d.ts +2 -0
  4. package/dist/commands/db.js +305 -0
  5. package/dist/commands/db.js.map +1 -0
  6. package/dist/commands/exec.js +62 -3
  7. package/dist/commands/exec.js.map +1 -1
  8. package/dist/commands/local.js +514 -123
  9. package/dist/commands/local.js.map +1 -1
  10. package/dist/commands/login.js +23 -7
  11. package/dist/commands/login.js.map +1 -1
  12. package/dist/commands/logs.d.ts +2 -0
  13. package/dist/commands/logs.js +239 -0
  14. package/dist/commands/logs.js.map +1 -0
  15. package/dist/commands/open.d.ts +2 -0
  16. package/dist/commands/open.js +114 -0
  17. package/dist/commands/open.js.map +1 -0
  18. package/dist/commands/sites.js +240 -2
  19. package/dist/commands/sites.js.map +1 -1
  20. package/dist/commands/sync.js +6 -3
  21. package/dist/commands/sync.js.map +1 -1
  22. package/dist/commands/versions.d.ts +2 -0
  23. package/dist/commands/versions.js +324 -0
  24. package/dist/commands/versions.js.map +1 -0
  25. package/dist/index.js +49 -8
  26. package/dist/index.js.map +1 -1
  27. package/dist/lib/local-env.d.ts +31 -0
  28. package/dist/lib/local-env.js +60 -13
  29. package/dist/lib/local-env.js.map +1 -1
  30. package/dist/lib/local-instance.d.ts +43 -0
  31. package/dist/lib/local-instance.js +60 -0
  32. package/dist/lib/local-instance.js.map +1 -0
  33. package/dist/lib/output.js +14 -1
  34. package/dist/lib/output.js.map +1 -1
  35. package/dist/lib/paths.d.ts +22 -0
  36. package/dist/lib/paths.js +41 -0
  37. package/dist/lib/paths.js.map +1 -0
  38. package/dist/lib/sftp-sync.d.ts +35 -0
  39. package/dist/lib/sftp-sync.js +290 -0
  40. package/dist/lib/sftp-sync.js.map +1 -0
  41. package/dist/lib/site-resolver.js +25 -3
  42. package/dist/lib/site-resolver.js.map +1 -1
  43. package/dist/lib/sqlite-to-mysql.d.ts +47 -0
  44. package/dist/lib/sqlite-to-mysql.js +133 -0
  45. package/dist/lib/sqlite-to-mysql.js.map +1 -0
  46. package/dist/lib/ssh-connection.d.ts +11 -0
  47. package/dist/lib/ssh-connection.js +99 -3
  48. package/dist/lib/ssh-connection.js.map +1 -1
  49. package/dist/lib/ssh-keys.js +12 -5
  50. package/dist/lib/ssh-keys.js.map +1 -1
  51. package/dist/lib/windows-binaries.d.ts +10 -0
  52. package/dist/lib/windows-binaries.js +34 -0
  53. package/dist/lib/windows-binaries.js.map +1 -0
  54. package/dist/types.d.ts +14 -0
  55. package/package.json +12 -3
  56. package/vendor/win32/NOTICE.md +31 -0
  57. package/vendor/win32/busybox.exe +0 -0
@@ -1,14 +1,22 @@
1
1
  import { spawnSync } from 'node:child_process';
2
- import { join, resolve } from 'node:path';
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 { rsyncViaSsh, execViaSshToFile } from '../lib/ssh-connection.js';
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
- success(`Instance "${name}" created`);
58
- console.log(`
59
- ${chalk.dim('#')} Starting WordPress ${opts.wp} with PHP ${opts.php}...
60
- ${chalk.dim('#')} Data stored at: ${chalk.dim(dir)}
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 (!cloudSiteArg) {
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
- const res = await client.post('/sites', { site_name: localName });
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 = rsyncViaSsh(conn, localWpContent, remoteTarget, extraArgs, !!opts.dryRun, true);
270
- if (exitCode === 0) {
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
- '--exclude=database', // Don't overwrite local SQLite database
314
- '--exclude=db.php',
315
- '--exclude=mu-plugins',
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 = rsyncViaSsh(conn, remoteSource, localWpContent, extraArgs, !!opts.dryRun, true);
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 = sanitizeName(opts.name || site.name || site.sub_domain || `site-${site.id}`);
436
+ const name = opts.name ? sanitizeName(opts.name) : defaultInstanceName(site);
362
437
  if (instances[name]) {
363
- error(`Local instance "${name}" already exists. Use --name to pick a different name.`);
364
- process.exit(1);
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 rsyncExit = rsyncViaSsh(conn, remoteSource, localWpContent, [
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 (rsync exit code ${rsyncExit})`);
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
- rsyncViaSsh(conn, rootRemote, dir + '/', [
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 = resolve(join(new URL(import.meta.url).pathname, '..', '..', '..', 'scripts', 'mysql2sqlite'));
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
- const convertResult = spawnSync(mysql2sqlitePath, [dumpPath], {
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
- // Write and import into SQLite
462
- const tmpSql = join(dir, 'sqlite-import.sql');
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
- rmSync(tmpSql);
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,294 @@ ${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
+ // Remap table-prefix-embedded role/capability keys. WordPress stores these
858
+ // under the table prefix: wp_usermeta.{prefix}capabilities / {prefix}user_level
859
+ // and wp_options.{prefix}user_roles. The local DB uses the `wp_` prefix, so the
860
+ // imported keys are `wp_capabilities` etc.; if the cloud prefix differs, the
861
+ // admin user has NO capabilities and wp-admin becomes inaccessible. Rewrite the
862
+ // access-critical keys (exact names — safe, never touches plugin options) to the
863
+ // cloud prefix. (clone does the inverse when pulling down.)
864
+ if (cloudPrefix !== 'wp_') {
865
+ const capSpin = spinner('Remapping user roles/capabilities to the cloud prefix...');
866
+ capSpin.start();
867
+ const um = `${cloudPrefix}usermeta`;
868
+ const opt = `${cloudPrefix}options`;
869
+ const stmts = [
870
+ `UPDATE ${um} SET meta_key='${cloudPrefix}capabilities' WHERE meta_key='wp_capabilities'`,
871
+ `UPDATE ${um} SET meta_key='${cloudPrefix}user_level' WHERE meta_key='wp_user_level'`,
872
+ `UPDATE ${opt} SET option_name='${cloudPrefix}user_roles' WHERE option_name='wp_user_roles'`,
873
+ ];
874
+ let capOk = true;
875
+ for (const s of stmts) {
876
+ const r = execViaSsh(conn, `cd ${wpPath} && wp db query "${s}"`);
877
+ if (r.exitCode !== 0) {
878
+ capOk = false;
879
+ if (r.stderr)
880
+ error(r.stderr.trim());
881
+ }
882
+ }
883
+ if (capOk)
884
+ capSpin.succeed('Roles/capabilities remapped to cloud prefix');
885
+ else
886
+ capSpin.fail('Could not remap roles/capabilities — wp-admin access may need a manual fix');
887
+ }
888
+ // Rewrite local URL → cloud URL, serialization-safe via wp-cli.
889
+ if (fromUrl !== toUrl) {
890
+ if (isShellSafeUrl(fromUrl) && isShellSafeUrl(toUrl)) {
891
+ const srSpin = spinner(`Rewriting URLs (${fromUrl} → ${toUrl})...`);
892
+ srSpin.start();
893
+ const srRes = execViaSsh(conn, `cd ${wpPath} && wp search-replace '${fromUrl}' '${toUrl}' --all-tables --report-changed-only`);
894
+ if (srRes.exitCode !== 0) {
895
+ srSpin.fail('URL rewrite failed (DB imported; run search-replace manually if links are wrong)');
896
+ if (srRes.stderr)
897
+ error(srRes.stderr.trim());
898
+ }
899
+ else {
900
+ srSpin.succeed('URLs rewritten');
901
+ }
902
+ }
903
+ else {
904
+ info(`Skipped URL rewrite (unsafe URL). Run manually: wp search-replace '<local>' '${toUrl}' --all-tables`);
905
+ }
906
+ }
907
+ // Flush caches + clean up remote temp (best effort).
908
+ execViaSsh(conn, `cd ${wpPath} && wp cache flush`);
909
+ execViaSsh(conn, `rm -f ${remoteTmp}`);
910
+ if (takeBackup)
911
+ info(`Cloud DB backup kept at ~/${backupFilename} (on remote).`);
912
+ return 'done';
913
+ }
581
914
  function checkRsync() {
915
+ // Windows transfers go over pure-JS SFTP, so rsync isn't required there.
916
+ if (process.platform === 'win32')
917
+ return true;
582
918
  const result = spawnSync('which', ['rsync'], { stdio: 'ignore' });
583
919
  return result.status === 0;
584
920
  }
585
- function sanitizeName(name) {
586
- return name.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase();
921
+ /**
922
+ * Locate an awk-compatible interpreter. Resolution order:
923
+ * 1. Bundled BusyBox-w64 in bin/win32/ (Windows only — invoked as `busybox awk`)
924
+ * 2. `awk` or `gawk` in PATH
925
+ * 3. Common Git-for-Windows install dirs (Windows only)
926
+ *
927
+ * Returns the command path plus any arg-prefix that must precede the awk
928
+ * arguments (busybox uses `busybox awk -f script input`).
929
+ */
930
+ function findAwk() {
931
+ const bb = bundledBusybox();
932
+ if (bb)
933
+ return { cmd: bb, prefixArgs: ['awk'] };
934
+ const cmd = process.platform === 'win32' ? 'where' : 'which';
935
+ for (const name of ['awk', 'gawk']) {
936
+ const r = spawnSync(cmd, [name], { stdio: 'pipe' });
937
+ if (r.status === 0)
938
+ return { cmd: name, prefixArgs: [] };
939
+ }
940
+ if (process.platform === 'win32') {
941
+ const candidates = [
942
+ 'C:\\Program Files\\Git\\usr\\bin\\awk.exe',
943
+ 'C:\\Program Files (x86)\\Git\\usr\\bin\\awk.exe',
944
+ ];
945
+ if (process.env.PROGRAMFILES) {
946
+ candidates.push(process.env.PROGRAMFILES + '\\Git\\usr\\bin\\awk.exe');
947
+ }
948
+ for (const c of candidates) {
949
+ if (existsSync(c))
950
+ return { cmd: c, prefixArgs: [] };
951
+ }
952
+ }
953
+ return null;
587
954
  }
588
955
  // Playground supports: 7.4, 8.0, 8.1, 8.2, 8.3, 8.4, 8.5
589
956
  const PLAYGROUND_PHP_VERSIONS = ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5'];
@@ -611,27 +978,51 @@ function printUrls(port) {
611
978
  }
612
979
  async function launchServer(instance, opts) {
613
980
  const shouldOpen = opts.open !== false;
981
+ const json = isJsonMode();
614
982
  if (opts.background) {
615
- const spin = spinner(`Starting "${instance.name}" in background...`);
616
- spin.start();
983
+ const spin = json ? null : spinner(`Starting "${instance.name}" in background...`);
984
+ spin?.start();
617
985
  try {
618
986
  const { pid, url } = await startServerBackground(instance, opts.blueprint);
619
- spin.succeed(`Running in background (PID: ${pid})`);
620
- printUrls(instance.port);
987
+ if (json) {
988
+ console.log(JSON.stringify({
989
+ success: true,
990
+ data: {
991
+ name: instance.name,
992
+ url,
993
+ port: instance.port,
994
+ pid,
995
+ wp: instance.wp,
996
+ php: instance.php,
997
+ path: instance.path,
998
+ },
999
+ }));
1000
+ }
1001
+ else {
1002
+ spin?.succeed(`Running in background (PID: ${pid})`);
1003
+ printUrls(instance.port);
1004
+ info(`Stop with: instawp local stop ${instance.name}`);
1005
+ info(`Logs: ${instance.path}/server.log`);
1006
+ }
621
1007
  if (shouldOpen)
622
1008
  await openWpAdmin(url);
623
- info(`Stop with: instawp local stop ${instance.name}`);
624
- info(`Logs: ${instance.path}/server.log`);
625
1009
  }
626
1010
  catch (err) {
627
- spin.fail('Failed to start');
628
- error(err.message);
1011
+ if (json) {
1012
+ console.log(JSON.stringify({ success: false, error: err.message }));
1013
+ }
1014
+ else {
1015
+ spin?.fail('Failed to start');
1016
+ error(err.message);
1017
+ }
629
1018
  process.exit(1);
630
1019
  }
631
1020
  }
632
1021
  else {
633
- printUrls(instance.port);
634
- console.log(chalk.dim('\nPress Ctrl+C to stop.\n'));
1022
+ if (!json) {
1023
+ printUrls(instance.port);
1024
+ console.log(chalk.dim('\nPress Ctrl+C to stop.\n'));
1025
+ }
635
1026
  try {
636
1027
  await startServer(instance, {
637
1028
  blueprint: opts.blueprint,