@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.
- package/dist/commands/local.d.ts +2 -0
- package/dist/commands/local.js +672 -0
- package/dist/commands/local.js.map +1 -0
- package/dist/commands/sites.js +26 -8
- package/dist/commands/sites.js.map +1 -1
- package/dist/commands/teams.js +52 -3
- package/dist/commands/teams.js.map +1 -1
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/api.js +6 -1
- package/dist/lib/api.js.map +1 -1
- package/dist/lib/config.d.ts +10 -1
- package/dist/lib/config.js +47 -0
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/local-env.d.ts +35 -0
- package/dist/lib/local-env.js +296 -0
- package/dist/lib/local-env.js.map +1 -0
- package/dist/lib/site-resolver.js +32 -11
- package/dist/lib/site-resolver.js.map +1 -1
- package/dist/lib/ssh-connection.d.ts +8 -0
- package/dist/lib/ssh-connection.js +26 -2
- package/dist/lib/ssh-connection.js.map +1 -1
- package/dist/types.d.ts +8 -0
- package/package.json +3 -2
- package/scripts/mysql2sqlite +289 -0
|
@@ -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
|