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

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.
@@ -0,0 +1,2 @@
1
+ import { Command } from 'commander';
2
+ export declare function registerLocalCommand(program: Command): void;
@@ -0,0 +1,672 @@
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';
4
+ import chalk from 'chalk';
5
+ import open from 'open';
6
+ import { getLocalInstances, getLocalInstance, setLocalInstance, removeLocalInstance, } from '../lib/config.js';
7
+ import { getNextPort, createInstanceDir, deleteInstanceDir, startServer, startServerBackground, stopServer as stopServerProcess, isServerRunning, checkPlaygroundConnectivity, ensureAutoLogin, } from '../lib/local-env.js';
8
+ import { requireAuth, getClient } from '../lib/api.js';
9
+ import { resolveSite } from '../lib/site-resolver.js';
10
+ import { ensureSshAccess } from '../lib/ssh-keys.js';
11
+ import { rsyncViaSsh, execViaSshToFile } from '../lib/ssh-connection.js';
12
+ import { success, error, table, spinner, info, isJsonMode } from '../lib/output.js';
13
+ export function registerLocalCommand(program) {
14
+ const local = program
15
+ .command('local')
16
+ .description('Manage local WordPress sites (powered by WordPress Playground)');
17
+ // local create
18
+ local
19
+ .command('create')
20
+ .description('Create and start a local WordPress site')
21
+ .option('--name <name>', 'Instance name (auto-generated if omitted)')
22
+ .option('--wp <version>', 'WordPress version', 'latest')
23
+ .option('--php <version>', 'PHP version (7.4-8.5)', '8.3')
24
+ .option('--port <port>', 'Server port')
25
+ .option('--blueprint <path>', 'Blueprint JSON file for setup')
26
+ .option('--no-open', 'Do not open browser')
27
+ .option('--background', 'Run server in background and return immediately')
28
+ .action(async (opts) => {
29
+ const instances = getLocalInstances();
30
+ const name = sanitizeName(opts.name || nextAutoName(instances));
31
+ if (instances[name]) {
32
+ error(`Instance "${name}" already exists. Use 'instawp local start ${name}' or choose a different name.`);
33
+ process.exit(1);
34
+ }
35
+ const spin = spinner(`Creating local WordPress site "${name}"...`);
36
+ spin.start();
37
+ try {
38
+ // Pre-check connectivity
39
+ const connErr = await checkPlaygroundConnectivity();
40
+ if (connErr) {
41
+ spin.fail('Network check failed');
42
+ error(connErr);
43
+ process.exit(1);
44
+ }
45
+ const port = opts.port ? parseInt(opts.port) : await getNextPort(instances);
46
+ const dir = createInstanceDir(name);
47
+ spin.stop();
48
+ const instance = {
49
+ name,
50
+ port,
51
+ php: opts.php,
52
+ wp: opts.wp,
53
+ path: dir,
54
+ createdAt: new Date().toISOString(),
55
+ };
56
+ 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
+ `);
62
+ await launchServer(instance, opts);
63
+ }
64
+ catch (err) {
65
+ spin.stop();
66
+ // Clean up on failure
67
+ deleteInstanceDir(name);
68
+ removeLocalInstance(name);
69
+ error('Failed to create local site', err.message);
70
+ process.exit(1);
71
+ }
72
+ });
73
+ // local start <name>
74
+ local
75
+ .command('start [name]')
76
+ .description('Start a local WordPress site')
77
+ .option('--blueprint <path>', 'Blueprint JSON file')
78
+ .option('--no-open', 'Do not open browser')
79
+ .option('--background', 'Run server in background and return immediately')
80
+ .action(async (name, opts) => {
81
+ const instanceName = name || 'my-site';
82
+ const instance = getLocalInstance(instanceName);
83
+ if (!instance) {
84
+ error(`Instance "${instanceName}" not found. Run 'instawp local create --name ${instanceName}' first.`);
85
+ const instances = getLocalInstances();
86
+ const names = Object.keys(instances);
87
+ if (names.length > 0) {
88
+ info(`Available instances: ${names.join(', ')}`);
89
+ }
90
+ process.exit(1);
91
+ }
92
+ ensureAutoLogin(instance);
93
+ await launchServer(instance, opts);
94
+ });
95
+ // local stop [name]
96
+ local
97
+ .command('stop [name]')
98
+ .description('Stop a background local site')
99
+ .action((name) => {
100
+ const instanceName = name || 'my-site';
101
+ const instance = getLocalInstance(instanceName);
102
+ if (!instance) {
103
+ error(`Instance "${instanceName}" not found.`);
104
+ process.exit(1);
105
+ }
106
+ if (stopServerProcess(instance)) {
107
+ success(`Stopped "${instanceName}"`);
108
+ }
109
+ else {
110
+ info(`"${instanceName}" is not running in background.`);
111
+ }
112
+ });
113
+ // local list
114
+ local
115
+ .command('list')
116
+ .description('List local WordPress sites')
117
+ .action(() => {
118
+ const instances = getLocalInstances();
119
+ const entries = Object.values(instances);
120
+ if (entries.length === 0) {
121
+ if (isJsonMode()) {
122
+ console.log(JSON.stringify([]));
123
+ }
124
+ else {
125
+ info('No local sites. Create one with: instawp local create');
126
+ }
127
+ return;
128
+ }
129
+ if (isJsonMode()) {
130
+ console.log(JSON.stringify(entries));
131
+ return;
132
+ }
133
+ const rows = entries.map((i) => ({
134
+ name: i.name,
135
+ status: isServerRunning(i) ? 'running' : 'stopped',
136
+ url: `http://127.0.0.1:${i.port}`,
137
+ wp: i.wp,
138
+ php: i.php,
139
+ path: i.path,
140
+ }));
141
+ table(['Name', 'Status', 'URL', 'WP', 'PHP', 'Path'], rows);
142
+ });
143
+ // local delete <name>
144
+ local
145
+ .command('delete <name>')
146
+ .description('Delete a local WordPress site and its data')
147
+ .option('--force', 'Skip confirmation')
148
+ .action(async (name, opts) => {
149
+ const instance = getLocalInstance(name);
150
+ if (!instance) {
151
+ error(`Instance "${name}" not found.`);
152
+ process.exit(1);
153
+ }
154
+ if (!opts.force && !isJsonMode()) {
155
+ const readline = await import('node:readline');
156
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
157
+ const answer = await new Promise((resolve) => {
158
+ rl.question(`Delete local site "${name}" and all its data? (y/N) `, resolve);
159
+ });
160
+ rl.close();
161
+ if (answer.toLowerCase() !== 'y') {
162
+ info('Cancelled.');
163
+ return;
164
+ }
165
+ }
166
+ deleteInstanceDir(name);
167
+ removeLocalInstance(name);
168
+ if (isJsonMode()) {
169
+ console.log(JSON.stringify({ deleted: name }));
170
+ }
171
+ else {
172
+ success(`Instance "${name}" deleted.`);
173
+ }
174
+ });
175
+ // local push <local-name> [cloud-site]
176
+ local
177
+ .command('push <local-name> [cloud-site]')
178
+ .description('Push local wp-content to an InstaWP cloud site')
179
+ .option('--exclude <pattern...>', 'Additional exclude patterns')
180
+ .option('--dry-run', 'Show what would be transferred')
181
+ .action(async (localName, cloudSiteArg, opts) => {
182
+ requireAuth();
183
+ const instance = getLocalInstance(localName);
184
+ if (!instance) {
185
+ error(`Local instance "${localName}" not found.`);
186
+ process.exit(1);
187
+ }
188
+ if (!checkRsync()) {
189
+ error('rsync is required. Install: brew install rsync');
190
+ process.exit(1);
191
+ }
192
+ const localWpContent = join(instance.path, 'wp-content') + '/';
193
+ // If no cloud site specified, create one
194
+ let site;
195
+ if (!cloudSiteArg) {
196
+ const spin = spinner('Creating cloud site...');
197
+ spin.start();
198
+ try {
199
+ const client = getClient();
200
+ const res = await client.post('/sites', { site_name: localName });
201
+ site = res.data?.data;
202
+ if (!site?.id)
203
+ throw new Error('Unexpected API response');
204
+ spin.succeed(`Cloud site created (ID: ${site.id})`);
205
+ // Wait for provisioning
206
+ const provSpin = spinner('Waiting for site to provision...');
207
+ provSpin.start();
208
+ const taskId = site.task_id;
209
+ const maxWait = 5 * 60 * 1000;
210
+ const start = Date.now();
211
+ while (Date.now() - start < maxWait) {
212
+ if (taskId) {
213
+ try {
214
+ const taskRes = await client.get(`/tasks/${taskId}/status`);
215
+ const task = taskRes.data?.data;
216
+ if (task?.status === 'completed' || parseFloat(task?.percentage_complete) >= 100) {
217
+ provSpin.succeed('Site provisioned');
218
+ break;
219
+ }
220
+ if (task?.status === 'error') {
221
+ provSpin.fail('Provisioning failed');
222
+ error(task?.comment || 'Unknown error');
223
+ process.exit(1);
224
+ }
225
+ provSpin.text = `Provisioning... (${Math.round(parseFloat(task?.percentage_complete) || 0)}%)`;
226
+ }
227
+ catch { /* ignore poll errors */ }
228
+ }
229
+ await new Promise(r => setTimeout(r, 3000));
230
+ }
231
+ // Re-resolve to get full details
232
+ site = await resolveSite(String(site.id));
233
+ }
234
+ catch (err) {
235
+ spin.fail('Failed to create cloud site');
236
+ error(err.response?.data?.message || err.message);
237
+ process.exit(1);
238
+ }
239
+ }
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
+ // Get SSH access
253
+ const conn = await ensureSshAccess(site.id);
254
+ const remotePath = `/home/${conn.username}/web/${conn.domain}/public_html/wp-content/`;
255
+ const extraArgs = [
256
+ '--exclude=database', // Don't push SQLite database to cloud (cloud uses MySQL)
257
+ '--exclude=db.php',
258
+ '--exclude=mu-plugins', // Playground mu-plugins are local-only
259
+ ];
260
+ if (opts.exclude) {
261
+ for (const pattern of opts.exclude) {
262
+ extraArgs.push(`--exclude=${pattern}`);
263
+ }
264
+ }
265
+ const remoteTarget = `${conn.username}@${conn.host}:${remotePath}`;
266
+ info(`Pushing ${chalk.dim(localWpContent)} -> ${chalk.dim(conn.host + ':' + remotePath)}`);
267
+ if (opts.dryRun)
268
+ 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 {
277
+ error(`rsync exited with code ${exitCode}`);
278
+ process.exit(exitCode);
279
+ }
280
+ });
281
+ // local pull <local-name> <cloud-site>
282
+ local
283
+ .command('pull <local-name> <cloud-site>')
284
+ .description('Pull wp-content from an InstaWP cloud site to local')
285
+ .option('--exclude <pattern...>', 'Additional exclude patterns')
286
+ .option('--dry-run', 'Show what would be transferred')
287
+ .action(async (localName, cloudSiteArg, opts) => {
288
+ requireAuth();
289
+ const instance = getLocalInstance(localName);
290
+ if (!instance) {
291
+ error(`Local instance "${localName}" not found.`);
292
+ process.exit(1);
293
+ }
294
+ if (!checkRsync()) {
295
+ error('rsync is required. Install: brew install rsync');
296
+ process.exit(1);
297
+ }
298
+ const localWpContent = join(instance.path, 'wp-content') + '/';
299
+ const spin = spinner('Resolving cloud site...');
300
+ spin.start();
301
+ let site;
302
+ try {
303
+ site = await resolveSite(cloudSiteArg);
304
+ spin.succeed(`Cloud site: ${site.name || site.sub_domain} (ID: ${site.id})`);
305
+ }
306
+ catch {
307
+ spin.fail('Site resolution failed');
308
+ process.exit(1);
309
+ }
310
+ const conn = await ensureSshAccess(site.id);
311
+ 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
+ ];
317
+ if (opts.exclude) {
318
+ for (const pattern of opts.exclude) {
319
+ extraArgs.push(`--exclude=${pattern}`);
320
+ }
321
+ }
322
+ const remoteSource = `${conn.username}@${conn.host}:${remotePath}`;
323
+ info(`Pulling ${chalk.dim(conn.host + ':' + remotePath)} -> ${chalk.dim(localWpContent)}`);
324
+ if (opts.dryRun)
325
+ info('(dry run)');
326
+ const exitCode = rsyncViaSsh(conn, remoteSource, localWpContent, extraArgs, !!opts.dryRun, true);
327
+ if (exitCode === 0) {
328
+ success('Pull complete! Restart the local site to see changes.');
329
+ }
330
+ else {
331
+ error(`rsync exited with code ${exitCode}`);
332
+ process.exit(exitCode);
333
+ }
334
+ });
335
+ // local clone <cloud-site>
336
+ local
337
+ .command('clone <cloud-site>')
338
+ .description('Clone a complete InstaWP cloud site to local')
339
+ .option('--name <name>', 'Local instance name (defaults to cloud site name)')
340
+ .option('--no-start', 'Do not start the local site after cloning')
341
+ .action(async (cloudSiteArg, opts) => {
342
+ requireAuth();
343
+ if (!checkRsync()) {
344
+ error('rsync is required. Install: brew install rsync');
345
+ process.exit(1);
346
+ }
347
+ // 1. Resolve cloud site
348
+ const spin = spinner('Resolving cloud site...');
349
+ spin.start();
350
+ let site;
351
+ try {
352
+ site = await resolveSite(cloudSiteArg);
353
+ spin.succeed(`Cloud site: ${site.name || site.sub_domain} (ID: ${site.id})`);
354
+ }
355
+ catch {
356
+ spin.fail('Site resolution failed');
357
+ process.exit(1);
358
+ }
359
+ // 2. Create local instance
360
+ const instances = getLocalInstances();
361
+ const name = sanitizeName(opts.name || site.name || site.sub_domain || `site-${site.id}`);
362
+ if (instances[name]) {
363
+ error(`Local instance "${name}" already exists. Use --name to pick a different name.`);
364
+ process.exit(1);
365
+ }
366
+ const port = await getNextPort(instances);
367
+ const dir = createInstanceDir(name);
368
+ const instance = {
369
+ name,
370
+ port,
371
+ php: normalizePhpVersion(site.php_version) || '8.3',
372
+ wp: site.wp_version || 'latest',
373
+ path: dir,
374
+ createdAt: new Date().toISOString(),
375
+ };
376
+ setLocalInstance(instance);
377
+ success(`Local instance "${name}" created`);
378
+ // 3. Get SSH access
379
+ const conn = await ensureSshAccess(site.id);
380
+ // 4. Export database from cloud
381
+ const dumpPath = join(dir, 'database.sql');
382
+ const dbSpin = spinner('Exporting database...');
383
+ dbSpin.start();
384
+ try {
385
+ const wpPath = `/home/${conn.username}/web/${conn.domain}/public_html`;
386
+ const { exitCode, stderr } = execViaSshToFile(conn, `cd ${wpPath} && wp db export --single-transaction -`, dumpPath);
387
+ if (exitCode !== 0) {
388
+ dbSpin.fail('Database export failed (will start with fresh DB)');
389
+ if (stderr)
390
+ info(stderr.trim());
391
+ }
392
+ else {
393
+ const size = statSync(dumpPath).size;
394
+ dbSpin.succeed(`Database exported (${(size / 1024 / 1024).toFixed(1)} MB)`);
395
+ }
396
+ }
397
+ catch (err) {
398
+ dbSpin.fail('Database export failed: ' + err.message);
399
+ }
400
+ // 5. Pull wp-content via rsync
401
+ const localWpContent = join(dir, 'wp-content') + '/';
402
+ const remotePath = `/home/${conn.username}/web/${conn.domain}/public_html/wp-content/`;
403
+ const remoteSource = `${conn.username}@${conn.host}:${remotePath}`;
404
+ info(`Pulling wp-content from ${chalk.dim(conn.domain)}...`);
405
+ const rsyncExit = rsyncViaSsh(conn, remoteSource, localWpContent, [
406
+ '--exclude=cache',
407
+ '--exclude=upgrade',
408
+ '--exclude=wflogs',
409
+ '--exclude=backup*',
410
+ ], false, true);
411
+ if (rsyncExit !== 0) {
412
+ error(`wp-content sync failed (rsync exit code ${rsyncExit})`);
413
+ }
414
+ // 5b. Pull non-core root files (CLAUDE.md, .htaccess, wp-cli.yml, etc.)
415
+ const remoteRoot = `/home/${conn.username}/web/${conn.domain}/public_html/`;
416
+ const rootRemote = `${conn.username}@${conn.host}:${remoteRoot}`;
417
+ rsyncViaSsh(conn, rootRemote, dir + '/', [
418
+ '--exclude=wp-admin/',
419
+ '--exclude=wp-includes/',
420
+ '--exclude=wp-content/',
421
+ '--exclude=wp-*.php',
422
+ '--exclude=index.php',
423
+ '--exclude=xmlrpc.php',
424
+ '--exclude=license.txt',
425
+ '--exclude=readme.html',
426
+ ], false, false);
427
+ // 6. Ensure auto-login mu-plugin
428
+ ensureAutoLogin(instance);
429
+ // 7. Convert MySQL dump → SQLite, import directly, fix URLs and table prefix
430
+ const hasDump = existsSync(dumpPath) && statSync(dumpPath).size > 0;
431
+ let adminUsername = 'admin';
432
+ if (hasDump) {
433
+ const dbSpin2 = spinner('Importing database...');
434
+ dbSpin2.start();
435
+ try {
436
+ const mysql2sqlitePath = resolve(join(new URL(import.meta.url).pathname, '..', '..', '..', 'scripts', 'mysql2sqlite'));
437
+ const dbDir = join(dir, 'wp-content', 'database');
438
+ const sqliteDbPath = join(dbDir, '.ht.sqlite');
439
+ // Clean slate for database dir
440
+ if (existsSync(dbDir))
441
+ rmSync(dbDir, { recursive: true, force: true });
442
+ mkdirSync(dbDir, { recursive: true });
443
+ // Strip SSH MOTD from dump
444
+ const rawDump = readFileSync(dumpPath, 'utf-8');
445
+ const sqlStart = rawDump.search(/^(\/\*|--|CREATE |DROP |SET |INSERT )/m);
446
+ if (sqlStart > 0) {
447
+ writeFileSync(dumpPath, rawDump.substring(sqlStart));
448
+ }
449
+ // Convert MySQL → SQLite
450
+ const convertResult = spawnSync(mysql2sqlitePath, [dumpPath], {
451
+ encoding: 'utf-8',
452
+ maxBuffer: 500 * 1024 * 1024,
453
+ timeout: 120000,
454
+ });
455
+ if (convertResult.status !== 0) {
456
+ throw new Error(convertResult.stderr || 'mysql2sqlite conversion failed');
457
+ }
458
+ // Add DROP TABLE before each CREATE TABLE
459
+ let sqliteSql = convertResult.stdout;
460
+ 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
519
+ try {
520
+ rmSync(tmpSql);
521
+ }
522
+ catch { }
523
+ dbSpin2.succeed(`Database imported (${(countResult.stdout || '').trim()} tables, admin: ${adminUsername})`);
524
+ }
525
+ catch (err) {
526
+ dbSpin2.fail('Database import failed: ' + err.message);
527
+ }
528
+ }
529
+ // 8. Write clone blueprint with AST driver + login as actual admin user
530
+ const cloneBlueprintPath = join(dir, 'clone-blueprint.json');
531
+ const cloneBlueprint = {
532
+ steps: [
533
+ {
534
+ step: 'defineWpConfigConsts',
535
+ consts: {
536
+ WP_SQLITE_AST_DRIVER: true,
537
+ WP_DEBUG: false,
538
+ WP_DEBUG_DISPLAY: false,
539
+ },
540
+ },
541
+ {
542
+ step: 'login',
543
+ username: adminUsername,
544
+ },
545
+ ],
546
+ };
547
+ writeFileSync(cloneBlueprintPath, JSON.stringify(cloneBlueprint));
548
+ // 9. Write error suppression mu-plugin
549
+ const muDir = join(dir, 'wp-content', 'mu-plugins');
550
+ mkdirSync(muDir, { recursive: true });
551
+ writeFileSync(join(muDir, '0-suppress-errors.php'), "<?php\nerror_reporting(E_ERROR | E_PARSE);\n@ini_set('display_errors', '0');\n");
552
+ console.log(`
553
+ ${chalk.bold.green('Clone complete!')}
554
+
555
+ ${chalk.dim('Name:')} ${name}
556
+ ${chalk.dim('PHP:')} ${instance.php}
557
+ ${chalk.dim('WordPress:')} ${instance.wp}
558
+ ${chalk.dim('Port:')} ${port}
559
+ ${chalk.dim('Data:')} ${chalk.dim(dir)}
560
+ ${chalk.dim('Admin:')} ${adminUsername}
561
+ `);
562
+ if (opts.start !== false) {
563
+ printUrls(port);
564
+ console.log(chalk.dim('\nPress Ctrl+C to stop.\n'));
565
+ try {
566
+ await startServer(instance, {
567
+ blueprint: cloneBlueprintPath,
568
+ onReady: (url) => openWpAdmin(url),
569
+ });
570
+ }
571
+ catch (err) {
572
+ error('Failed to start local site', err.message);
573
+ process.exit(1);
574
+ }
575
+ }
576
+ else {
577
+ info(`Start with: instawp local start ${name}`);
578
+ }
579
+ });
580
+ }
581
+ function checkRsync() {
582
+ const result = spawnSync('which', ['rsync'], { stdio: 'ignore' });
583
+ return result.status === 0;
584
+ }
585
+ function sanitizeName(name) {
586
+ return name.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase();
587
+ }
588
+ // Playground supports: 7.4, 8.0, 8.1, 8.2, 8.3, 8.4, 8.5
589
+ const PLAYGROUND_PHP_VERSIONS = ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5'];
590
+ function normalizePhpVersion(version) {
591
+ if (!version)
592
+ return '8.3';
593
+ // Extract major.minor (e.g., "8.2.15" → "8.2")
594
+ const match = version.match(/^(\d+\.\d+)/);
595
+ const majorMinor = match ? match[1] : version;
596
+ if (PLAYGROUND_PHP_VERSIONS.includes(majorMinor))
597
+ return majorMinor;
598
+ // Fall back to closest supported version, prefer not going higher
599
+ return '8.3';
600
+ }
601
+ function nextAutoName(instances) {
602
+ let i = 1;
603
+ while (instances[`insta-local-site-${i}`])
604
+ i++;
605
+ return `insta-local-site-${i}`;
606
+ }
607
+ function printUrls(port) {
608
+ const url = `http://127.0.0.1:${port}`;
609
+ console.log(` ${chalk.dim('Site:')} ${chalk.cyan.underline(url)}`);
610
+ console.log(` ${chalk.dim('WP Admin:')} ${chalk.cyan.underline(`${url}/?instawp-login`)}`);
611
+ }
612
+ async function launchServer(instance, opts) {
613
+ const shouldOpen = opts.open !== false;
614
+ if (opts.background) {
615
+ const spin = spinner(`Starting "${instance.name}" in background...`);
616
+ spin.start();
617
+ try {
618
+ const { pid, url } = await startServerBackground(instance, opts.blueprint);
619
+ spin.succeed(`Running in background (PID: ${pid})`);
620
+ printUrls(instance.port);
621
+ if (shouldOpen)
622
+ await openWpAdmin(url);
623
+ info(`Stop with: instawp local stop ${instance.name}`);
624
+ info(`Logs: ${instance.path}/server.log`);
625
+ }
626
+ catch (err) {
627
+ spin.fail('Failed to start');
628
+ error(err.message);
629
+ process.exit(1);
630
+ }
631
+ }
632
+ else {
633
+ printUrls(instance.port);
634
+ console.log(chalk.dim('\nPress Ctrl+C to stop.\n'));
635
+ try {
636
+ await startServer(instance, {
637
+ blueprint: opts.blueprint,
638
+ onReady: shouldOpen ? (url) => openWpAdmin(url) : undefined,
639
+ });
640
+ }
641
+ catch (err) {
642
+ error('Failed to start local site', err.message);
643
+ process.exit(1);
644
+ }
645
+ }
646
+ }
647
+ async function openWpAdmin(serverUrl) {
648
+ // Use the magic login URL — hits frontend (no auth wall),
649
+ // sets cookie via mu-plugin, then redirects to wp-admin
650
+ const loginUrl = `${serverUrl}/?instawp-login`;
651
+ // Wait for WordPress to be fully ready
652
+ for (let i = 0; i < 30; i++) {
653
+ try {
654
+ const controller = new AbortController();
655
+ const timer = setTimeout(() => controller.abort(), 2000);
656
+ const res = await fetch(serverUrl, {
657
+ signal: controller.signal,
658
+ redirect: 'manual',
659
+ });
660
+ clearTimeout(timer);
661
+ if (res.status === 200 || res.status === 302) {
662
+ break;
663
+ }
664
+ }
665
+ catch {
666
+ // Server not ready yet
667
+ }
668
+ await new Promise(r => setTimeout(r, 1000));
669
+ }
670
+ open(loginUrl).catch(() => { });
671
+ }
672
+ //# sourceMappingURL=local.js.map