@instawp/cli 0.0.1-beta.1 → 0.0.1-beta.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +138 -0
- package/README.md +58 -10
- package/dist/commands/db.d.ts +2 -0
- package/dist/commands/db.js +331 -0
- package/dist/commands/db.js.map +1 -0
- package/dist/commands/exec.js +62 -3
- package/dist/commands/exec.js.map +1 -1
- package/dist/commands/local.d.ts +2 -0
- package/dist/commands/local.js +750 -0
- package/dist/commands/local.js.map +1 -0
- package/dist/commands/login.js +23 -7
- package/dist/commands/login.js.map +1 -1
- package/dist/commands/logs.d.ts +2 -0
- package/dist/commands/logs.js +239 -0
- package/dist/commands/logs.js.map +1 -0
- package/dist/commands/open.d.ts +2 -0
- package/dist/commands/open.js +114 -0
- package/dist/commands/open.js.map +1 -0
- package/dist/commands/sites.js +266 -10
- package/dist/commands/sites.js.map +1 -1
- package/dist/commands/sync.js +6 -3
- package/dist/commands/sync.js.map +1 -1
- package/dist/commands/teams.js +52 -3
- package/dist/commands/teams.js.map +1 -1
- package/dist/index.js +54 -8
- 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 +299 -0
- package/dist/lib/local-env.js.map +1 -0
- package/dist/lib/output.js +14 -1
- package/dist/lib/output.js.map +1 -1
- package/dist/lib/paths.d.ts +22 -0
- package/dist/lib/paths.js +41 -0
- package/dist/lib/paths.js.map +1 -0
- package/dist/lib/sftp-sync.d.ts +29 -0
- package/dist/lib/sftp-sync.js +266 -0
- package/dist/lib/sftp-sync.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 +17 -0
- package/dist/lib/ssh-connection.js +103 -5
- package/dist/lib/ssh-connection.js.map +1 -1
- package/dist/lib/ssh-keys.js +12 -5
- package/dist/lib/ssh-keys.js.map +1 -1
- package/dist/lib/windows-binaries.d.ts +10 -0
- package/dist/lib/windows-binaries.js +34 -0
- package/dist/lib/windows-binaries.js.map +1 -0
- package/dist/types.d.ts +8 -0
- package/package.json +11 -3
- package/scripts/mysql2sqlite +289 -0
- package/vendor/win32/NOTICE.md +31 -0
- package/vendor/win32/busybox.exe +0 -0
|
@@ -0,0 +1,750 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { join } 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 Database from 'better-sqlite3';
|
|
7
|
+
import { resolveFromModule } from '../lib/paths.js';
|
|
8
|
+
import { bundledBusybox } from '../lib/windows-binaries.js';
|
|
9
|
+
import { getLocalInstances, getLocalInstance, setLocalInstance, removeLocalInstance, } from '../lib/config.js';
|
|
10
|
+
import { getNextPort, createInstanceDir, deleteInstanceDir, startServer, startServerBackground, stopServer as stopServerProcess, isServerRunning, checkPlaygroundConnectivity, ensureAutoLogin, } from '../lib/local-env.js';
|
|
11
|
+
import { requireAuth, getClient } from '../lib/api.js';
|
|
12
|
+
import { resolveSite } from '../lib/site-resolver.js';
|
|
13
|
+
import { ensureSshAccess } from '../lib/ssh-keys.js';
|
|
14
|
+
import { syncFiles, execViaSshToFile } from '../lib/ssh-connection.js';
|
|
15
|
+
import { success, error, table, spinner, info, isJsonMode } from '../lib/output.js';
|
|
16
|
+
export function registerLocalCommand(program) {
|
|
17
|
+
const local = program
|
|
18
|
+
.command('local')
|
|
19
|
+
.description('Manage local WordPress sites (powered by WordPress Playground)');
|
|
20
|
+
// local create
|
|
21
|
+
local
|
|
22
|
+
.command('create')
|
|
23
|
+
.description('Create and start a local WordPress site')
|
|
24
|
+
.option('--name <name>', 'Instance name (auto-generated if omitted)')
|
|
25
|
+
.option('--wp <version>', 'WordPress version', 'latest')
|
|
26
|
+
.option('--php <version>', 'PHP version (7.4-8.5)', '8.3')
|
|
27
|
+
.option('--port <port>', 'Server port')
|
|
28
|
+
.option('--blueprint <path>', 'Blueprint JSON file for setup')
|
|
29
|
+
.option('--no-open', 'Do not open browser')
|
|
30
|
+
.option('--background', 'Run server in background and return immediately')
|
|
31
|
+
.action(async (opts) => {
|
|
32
|
+
const instances = getLocalInstances();
|
|
33
|
+
const name = sanitizeName(opts.name || nextAutoName(instances));
|
|
34
|
+
if (instances[name]) {
|
|
35
|
+
error(`Instance "${name}" already exists. Use 'instawp local start ${name}' or choose a different name.`);
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
const spin = spinner(`Creating local WordPress site "${name}"...`);
|
|
39
|
+
spin.start();
|
|
40
|
+
try {
|
|
41
|
+
// Pre-check connectivity
|
|
42
|
+
const connErr = await checkPlaygroundConnectivity();
|
|
43
|
+
if (connErr) {
|
|
44
|
+
spin.fail('Network check failed');
|
|
45
|
+
error(connErr);
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
const port = opts.port ? parseInt(opts.port) : await getNextPort(instances);
|
|
49
|
+
const dir = createInstanceDir(name);
|
|
50
|
+
spin.stop();
|
|
51
|
+
const instance = {
|
|
52
|
+
name,
|
|
53
|
+
port,
|
|
54
|
+
php: opts.php,
|
|
55
|
+
wp: opts.wp,
|
|
56
|
+
path: dir,
|
|
57
|
+
createdAt: new Date().toISOString(),
|
|
58
|
+
};
|
|
59
|
+
setLocalInstance(instance);
|
|
60
|
+
if (!isJsonMode()) {
|
|
61
|
+
success(`Instance "${name}" created`);
|
|
62
|
+
console.log(`\n${chalk.dim('#')} Starting WordPress ${opts.wp} with PHP ${opts.php}...`);
|
|
63
|
+
console.log(`${chalk.dim('#')} Data stored at: ${chalk.dim(dir)}\n`);
|
|
64
|
+
}
|
|
65
|
+
await launchServer(instance, opts);
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
spin.stop();
|
|
69
|
+
// Clean up on failure
|
|
70
|
+
deleteInstanceDir(name);
|
|
71
|
+
removeLocalInstance(name);
|
|
72
|
+
error('Failed to create local site', err.message);
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
// local start <name>
|
|
77
|
+
local
|
|
78
|
+
.command('start [name]')
|
|
79
|
+
.description('Start a local WordPress site')
|
|
80
|
+
.option('--blueprint <path>', 'Blueprint JSON file')
|
|
81
|
+
.option('--no-open', 'Do not open browser')
|
|
82
|
+
.option('--background', 'Run server in background and return immediately')
|
|
83
|
+
.action(async (name, opts) => {
|
|
84
|
+
const instanceName = name || 'my-site';
|
|
85
|
+
const instance = getLocalInstance(instanceName);
|
|
86
|
+
if (!instance) {
|
|
87
|
+
error(`Instance "${instanceName}" not found. Run 'instawp local create --name ${instanceName}' first.`);
|
|
88
|
+
const instances = getLocalInstances();
|
|
89
|
+
const names = Object.keys(instances);
|
|
90
|
+
if (names.length > 0) {
|
|
91
|
+
info(`Available instances: ${names.join(', ')}`);
|
|
92
|
+
}
|
|
93
|
+
process.exit(1);
|
|
94
|
+
}
|
|
95
|
+
ensureAutoLogin(instance);
|
|
96
|
+
await launchServer(instance, opts);
|
|
97
|
+
});
|
|
98
|
+
// local stop [name]
|
|
99
|
+
local
|
|
100
|
+
.command('stop [name]')
|
|
101
|
+
.description('Stop a background local site')
|
|
102
|
+
.action((name) => {
|
|
103
|
+
const instanceName = name || 'my-site';
|
|
104
|
+
const instance = getLocalInstance(instanceName);
|
|
105
|
+
if (!instance) {
|
|
106
|
+
error(`Instance "${instanceName}" not found.`);
|
|
107
|
+
process.exit(1);
|
|
108
|
+
}
|
|
109
|
+
if (stopServerProcess(instance)) {
|
|
110
|
+
success(`Stopped "${instanceName}"`);
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
info(`"${instanceName}" is not running in background.`);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
// local list
|
|
117
|
+
local
|
|
118
|
+
.command('list')
|
|
119
|
+
.description('List local WordPress sites')
|
|
120
|
+
.action(() => {
|
|
121
|
+
const instances = getLocalInstances();
|
|
122
|
+
const entries = Object.values(instances);
|
|
123
|
+
if (entries.length === 0) {
|
|
124
|
+
if (isJsonMode()) {
|
|
125
|
+
console.log(JSON.stringify([]));
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
info('No local sites. Create one with: instawp local create');
|
|
129
|
+
}
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (isJsonMode()) {
|
|
133
|
+
console.log(JSON.stringify(entries));
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const rows = entries.map((i) => ({
|
|
137
|
+
name: i.name,
|
|
138
|
+
status: isServerRunning(i) ? 'running' : 'stopped',
|
|
139
|
+
url: `http://127.0.0.1:${i.port}`,
|
|
140
|
+
wp: i.wp,
|
|
141
|
+
php: i.php,
|
|
142
|
+
path: i.path,
|
|
143
|
+
}));
|
|
144
|
+
table(['Name', 'Status', 'URL', 'WP', 'PHP', 'Path'], rows);
|
|
145
|
+
});
|
|
146
|
+
// local delete <name>
|
|
147
|
+
local
|
|
148
|
+
.command('delete <name>')
|
|
149
|
+
.description('Delete a local WordPress site and its data')
|
|
150
|
+
.option('--force', 'Skip confirmation')
|
|
151
|
+
.action(async (name, opts) => {
|
|
152
|
+
const instance = getLocalInstance(name);
|
|
153
|
+
if (!instance) {
|
|
154
|
+
error(`Instance "${name}" not found.`);
|
|
155
|
+
process.exit(1);
|
|
156
|
+
}
|
|
157
|
+
if (!opts.force && !isJsonMode()) {
|
|
158
|
+
const readline = await import('node:readline');
|
|
159
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
160
|
+
const answer = await new Promise((resolve) => {
|
|
161
|
+
rl.question(`Delete local site "${name}" and all its data? (y/N) `, resolve);
|
|
162
|
+
});
|
|
163
|
+
rl.close();
|
|
164
|
+
if (answer.toLowerCase() !== 'y') {
|
|
165
|
+
info('Cancelled.');
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
deleteInstanceDir(name);
|
|
170
|
+
removeLocalInstance(name);
|
|
171
|
+
if (isJsonMode()) {
|
|
172
|
+
console.log(JSON.stringify({ deleted: name }));
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
success(`Instance "${name}" deleted.`);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
// local push <local-name> [cloud-site]
|
|
179
|
+
local
|
|
180
|
+
.command('push <local-name> [cloud-site]')
|
|
181
|
+
.description('Push local wp-content to an InstaWP cloud site')
|
|
182
|
+
.option('--include <pattern...>', 'Include patterns (e.g. .git)')
|
|
183
|
+
.option('--exclude <pattern...>', 'Additional exclude patterns')
|
|
184
|
+
.option('--dry-run', 'Show what would be transferred')
|
|
185
|
+
.action(async (localName, cloudSiteArg, opts) => {
|
|
186
|
+
requireAuth();
|
|
187
|
+
const instance = getLocalInstance(localName);
|
|
188
|
+
if (!instance) {
|
|
189
|
+
error(`Local instance "${localName}" not found.`);
|
|
190
|
+
process.exit(1);
|
|
191
|
+
}
|
|
192
|
+
if (!checkRsync()) {
|
|
193
|
+
error('rsync is required for sync on macOS/Linux. Install: brew install rsync (macOS) or your distro package.');
|
|
194
|
+
process.exit(1);
|
|
195
|
+
}
|
|
196
|
+
const localWpContent = join(instance.path, 'wp-content') + '/';
|
|
197
|
+
// If no cloud site specified, create one
|
|
198
|
+
let site;
|
|
199
|
+
if (!cloudSiteArg) {
|
|
200
|
+
const spin = spinner('Creating cloud site...');
|
|
201
|
+
spin.start();
|
|
202
|
+
try {
|
|
203
|
+
const client = getClient();
|
|
204
|
+
// Default to a reserved (permanent) site, consistent with `instawp create`.
|
|
205
|
+
const res = await client.post('/sites', { site_name: localName, is_reserved: true });
|
|
206
|
+
site = res.data?.data;
|
|
207
|
+
if (!site?.id)
|
|
208
|
+
throw new Error('Unexpected API response');
|
|
209
|
+
spin.succeed(`Cloud site created (ID: ${site.id})`);
|
|
210
|
+
// Wait for provisioning
|
|
211
|
+
const provSpin = spinner('Waiting for site to provision...');
|
|
212
|
+
provSpin.start();
|
|
213
|
+
const taskId = site.task_id;
|
|
214
|
+
const maxWait = 5 * 60 * 1000;
|
|
215
|
+
const start = Date.now();
|
|
216
|
+
while (Date.now() - start < maxWait) {
|
|
217
|
+
if (taskId) {
|
|
218
|
+
try {
|
|
219
|
+
const taskRes = await client.get(`/tasks/${taskId}/status`);
|
|
220
|
+
const task = taskRes.data?.data;
|
|
221
|
+
if (task?.status === 'completed' || parseFloat(task?.percentage_complete) >= 100) {
|
|
222
|
+
provSpin.succeed('Site provisioned');
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
if (task?.status === 'error') {
|
|
226
|
+
provSpin.fail('Provisioning failed');
|
|
227
|
+
error(task?.comment || 'Unknown error');
|
|
228
|
+
process.exit(1);
|
|
229
|
+
}
|
|
230
|
+
provSpin.text = `Provisioning... (${Math.round(parseFloat(task?.percentage_complete) || 0)}%)`;
|
|
231
|
+
}
|
|
232
|
+
catch { /* ignore poll errors */ }
|
|
233
|
+
}
|
|
234
|
+
await new Promise(r => setTimeout(r, 3000));
|
|
235
|
+
}
|
|
236
|
+
// Re-resolve to get full details
|
|
237
|
+
site = await resolveSite(String(site.id));
|
|
238
|
+
}
|
|
239
|
+
catch (err) {
|
|
240
|
+
spin.fail('Failed to create cloud site');
|
|
241
|
+
error(err.response?.data?.message || err.message);
|
|
242
|
+
process.exit(1);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
else {
|
|
246
|
+
const spin = spinner('Resolving cloud site...');
|
|
247
|
+
spin.start();
|
|
248
|
+
try {
|
|
249
|
+
site = await resolveSite(cloudSiteArg);
|
|
250
|
+
spin.succeed(`Cloud site: ${site.name || site.sub_domain} (ID: ${site.id})`);
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
spin.fail('Site resolution failed');
|
|
254
|
+
process.exit(1);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
// Get SSH access
|
|
258
|
+
const conn = await ensureSshAccess(site.id);
|
|
259
|
+
const remotePath = `/home/${conn.username}/web/${conn.domain}/public_html/wp-content/`;
|
|
260
|
+
const extraArgs = [
|
|
261
|
+
'--exclude=database', // Don't push SQLite database to cloud (cloud uses MySQL)
|
|
262
|
+
'--exclude=db.php',
|
|
263
|
+
'--exclude=mu-plugins', // Playground mu-plugins are local-only
|
|
264
|
+
];
|
|
265
|
+
if (opts.exclude) {
|
|
266
|
+
for (const pattern of opts.exclude) {
|
|
267
|
+
extraArgs.push(`--exclude=${pattern}`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
const remoteTarget = `${conn.username}@${conn.host}:${remotePath}`;
|
|
271
|
+
info(`Pushing ${chalk.dim(localWpContent)} -> ${chalk.dim(conn.host + ':' + remotePath)}`);
|
|
272
|
+
if (opts.dryRun)
|
|
273
|
+
info('(dry run)');
|
|
274
|
+
const exitCode = await syncFiles(conn, localWpContent, remoteTarget, extraArgs, !!opts.dryRun, true);
|
|
275
|
+
if (exitCode === 0) {
|
|
276
|
+
success('Push complete!');
|
|
277
|
+
if (site.url) {
|
|
278
|
+
console.log(`\n ${chalk.dim('Cloud site:')} ${chalk.cyan.underline(site.url)}`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
error(`rsync exited with code ${exitCode}`);
|
|
283
|
+
process.exit(exitCode);
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
// local pull <local-name> <cloud-site>
|
|
287
|
+
local
|
|
288
|
+
.command('pull <local-name> <cloud-site>')
|
|
289
|
+
.description('Pull wp-content from an InstaWP cloud site to local')
|
|
290
|
+
.option('--include <pattern...>', 'Include patterns (e.g. .git)')
|
|
291
|
+
.option('--exclude <pattern...>', 'Additional exclude patterns')
|
|
292
|
+
.option('--dry-run', 'Show what would be transferred')
|
|
293
|
+
.action(async (localName, cloudSiteArg, opts) => {
|
|
294
|
+
requireAuth();
|
|
295
|
+
const instance = getLocalInstance(localName);
|
|
296
|
+
if (!instance) {
|
|
297
|
+
error(`Local instance "${localName}" not found.`);
|
|
298
|
+
process.exit(1);
|
|
299
|
+
}
|
|
300
|
+
if (!checkRsync()) {
|
|
301
|
+
error('rsync is required for sync on macOS/Linux. Install: brew install rsync (macOS) or your distro package.');
|
|
302
|
+
process.exit(1);
|
|
303
|
+
}
|
|
304
|
+
const localWpContent = join(instance.path, 'wp-content') + '/';
|
|
305
|
+
const spin = spinner('Resolving cloud site...');
|
|
306
|
+
spin.start();
|
|
307
|
+
let site;
|
|
308
|
+
try {
|
|
309
|
+
site = await resolveSite(cloudSiteArg);
|
|
310
|
+
spin.succeed(`Cloud site: ${site.name || site.sub_domain} (ID: ${site.id})`);
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
spin.fail('Site resolution failed');
|
|
314
|
+
process.exit(1);
|
|
315
|
+
}
|
|
316
|
+
const conn = await ensureSshAccess(site.id);
|
|
317
|
+
const remotePath = `/home/${conn.username}/web/${conn.domain}/public_html/wp-content/`;
|
|
318
|
+
const extraArgs = [];
|
|
319
|
+
if (opts.include) {
|
|
320
|
+
for (const pattern of opts.include) {
|
|
321
|
+
extraArgs.push(`--include=${pattern}`);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
extraArgs.push('--exclude=database', // Don't overwrite local SQLite database
|
|
325
|
+
'--exclude=db.php', '--exclude=mu-plugins');
|
|
326
|
+
if (opts.exclude) {
|
|
327
|
+
for (const pattern of opts.exclude) {
|
|
328
|
+
extraArgs.push(`--exclude=${pattern}`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
const remoteSource = `${conn.username}@${conn.host}:${remotePath}`;
|
|
332
|
+
info(`Pulling ${chalk.dim(conn.host + ':' + remotePath)} -> ${chalk.dim(localWpContent)}`);
|
|
333
|
+
if (opts.dryRun)
|
|
334
|
+
info('(dry run)');
|
|
335
|
+
const exitCode = await syncFiles(conn, remoteSource, localWpContent, extraArgs, !!opts.dryRun, true);
|
|
336
|
+
if (exitCode === 0) {
|
|
337
|
+
success('Pull complete! Restart the local site to see changes.');
|
|
338
|
+
}
|
|
339
|
+
else {
|
|
340
|
+
error(`rsync exited with code ${exitCode}`);
|
|
341
|
+
process.exit(exitCode);
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
// local clone <cloud-site>
|
|
345
|
+
local
|
|
346
|
+
.command('clone <cloud-site>')
|
|
347
|
+
.description('Clone a complete InstaWP cloud site to local')
|
|
348
|
+
.option('--name <name>', 'Local instance name (defaults to cloud site name)')
|
|
349
|
+
.option('--no-start', 'Do not start the local site after cloning')
|
|
350
|
+
.option('--force', 'Overwrite existing local instance')
|
|
351
|
+
.option('--include <pattern...>', 'Include patterns for rsync (e.g. .git)')
|
|
352
|
+
.action(async (cloudSiteArg, opts) => {
|
|
353
|
+
requireAuth();
|
|
354
|
+
if (!checkRsync()) {
|
|
355
|
+
error('rsync is required for sync on macOS/Linux. Install: brew install rsync (macOS) or your distro package.');
|
|
356
|
+
process.exit(1);
|
|
357
|
+
}
|
|
358
|
+
// 1. Resolve cloud site
|
|
359
|
+
const spin = spinner('Resolving cloud site...');
|
|
360
|
+
spin.start();
|
|
361
|
+
let site;
|
|
362
|
+
try {
|
|
363
|
+
site = await resolveSite(cloudSiteArg);
|
|
364
|
+
spin.succeed(`Cloud site: ${site.name || site.sub_domain} (ID: ${site.id})`);
|
|
365
|
+
}
|
|
366
|
+
catch {
|
|
367
|
+
spin.fail('Site resolution failed');
|
|
368
|
+
process.exit(1);
|
|
369
|
+
}
|
|
370
|
+
// 2. Create local instance
|
|
371
|
+
const instances = getLocalInstances();
|
|
372
|
+
const name = sanitizeName(opts.name || site.name || site.sub_domain || `site-${site.id}`);
|
|
373
|
+
if (instances[name]) {
|
|
374
|
+
if (!opts.force) {
|
|
375
|
+
error(`Local instance "${name}" already exists. Use --force to overwrite or --name to pick a different name.`);
|
|
376
|
+
process.exit(1);
|
|
377
|
+
}
|
|
378
|
+
// Force: delete existing instance first
|
|
379
|
+
stopServerProcess(instances[name]);
|
|
380
|
+
deleteInstanceDir(name);
|
|
381
|
+
removeLocalInstance(name);
|
|
382
|
+
info(`Existing instance "${name}" removed.`);
|
|
383
|
+
}
|
|
384
|
+
const port = await getNextPort(instances);
|
|
385
|
+
const dir = createInstanceDir(name);
|
|
386
|
+
const instance = {
|
|
387
|
+
name,
|
|
388
|
+
port,
|
|
389
|
+
php: normalizePhpVersion(site.php_version) || '8.3',
|
|
390
|
+
wp: site.wp_version || 'latest',
|
|
391
|
+
path: dir,
|
|
392
|
+
createdAt: new Date().toISOString(),
|
|
393
|
+
};
|
|
394
|
+
setLocalInstance(instance);
|
|
395
|
+
success(`Local instance "${name}" created`);
|
|
396
|
+
// 3. Get SSH access
|
|
397
|
+
const conn = await ensureSshAccess(site.id);
|
|
398
|
+
// 4. Export database from cloud
|
|
399
|
+
const dumpPath = join(dir, 'database.sql');
|
|
400
|
+
const dbSpin = spinner('Exporting database...');
|
|
401
|
+
dbSpin.start();
|
|
402
|
+
try {
|
|
403
|
+
const wpPath = `/home/${conn.username}/web/${conn.domain}/public_html`;
|
|
404
|
+
const { exitCode, stderr } = execViaSshToFile(conn, `cd ${wpPath} && wp db export --single-transaction -`, dumpPath);
|
|
405
|
+
if (exitCode !== 0) {
|
|
406
|
+
dbSpin.fail('Database export failed (will start with fresh DB)');
|
|
407
|
+
if (stderr)
|
|
408
|
+
info(stderr.trim());
|
|
409
|
+
}
|
|
410
|
+
else {
|
|
411
|
+
const size = statSync(dumpPath).size;
|
|
412
|
+
dbSpin.succeed(`Database exported (${(size / 1024 / 1024).toFixed(1)} MB)`);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
catch (err) {
|
|
416
|
+
dbSpin.fail('Database export failed: ' + err.message);
|
|
417
|
+
}
|
|
418
|
+
// 5. Pull wp-content via rsync
|
|
419
|
+
const localWpContent = join(dir, 'wp-content') + '/';
|
|
420
|
+
const remotePath = `/home/${conn.username}/web/${conn.domain}/public_html/wp-content/`;
|
|
421
|
+
const remoteSource = `${conn.username}@${conn.host}:${remotePath}`;
|
|
422
|
+
info(`Pulling wp-content from ${chalk.dim(conn.domain)}...`);
|
|
423
|
+
const includeArgs = [];
|
|
424
|
+
if (opts.include) {
|
|
425
|
+
for (const pattern of opts.include) {
|
|
426
|
+
includeArgs.push(`--include=${pattern}`);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
const rsyncExit = await syncFiles(conn, remoteSource, localWpContent, [
|
|
430
|
+
...includeArgs,
|
|
431
|
+
'--exclude=cache',
|
|
432
|
+
'--exclude=upgrade',
|
|
433
|
+
'--exclude=wflogs',
|
|
434
|
+
'--exclude=backup*',
|
|
435
|
+
], false, true);
|
|
436
|
+
if (rsyncExit !== 0) {
|
|
437
|
+
error(`wp-content sync failed (exit code ${rsyncExit})`);
|
|
438
|
+
}
|
|
439
|
+
// 5b. Pull non-core root files (CLAUDE.md, .htaccess, wp-cli.yml, etc.)
|
|
440
|
+
const remoteRoot = `/home/${conn.username}/web/${conn.domain}/public_html/`;
|
|
441
|
+
const rootRemote = `${conn.username}@${conn.host}:${remoteRoot}`;
|
|
442
|
+
await syncFiles(conn, rootRemote, dir + '/', [
|
|
443
|
+
'--exclude=wp-admin/',
|
|
444
|
+
'--exclude=wp-includes/',
|
|
445
|
+
'--exclude=wp-content/',
|
|
446
|
+
'--exclude=wp-*.php',
|
|
447
|
+
'--exclude=index.php',
|
|
448
|
+
'--exclude=xmlrpc.php',
|
|
449
|
+
'--exclude=license.txt',
|
|
450
|
+
'--exclude=readme.html',
|
|
451
|
+
], false, false);
|
|
452
|
+
// 6. Ensure auto-login mu-plugin
|
|
453
|
+
ensureAutoLogin(instance);
|
|
454
|
+
// 7. Convert MySQL dump → SQLite, import directly, fix URLs and table prefix
|
|
455
|
+
const hasDump = existsSync(dumpPath) && statSync(dumpPath).size > 0;
|
|
456
|
+
let adminUsername = 'admin';
|
|
457
|
+
if (hasDump) {
|
|
458
|
+
const dbSpin2 = spinner('Importing database...');
|
|
459
|
+
dbSpin2.start();
|
|
460
|
+
try {
|
|
461
|
+
const mysql2sqlitePath = resolveFromModule(import.meta.url, '..', '..', 'scripts', 'mysql2sqlite');
|
|
462
|
+
const dbDir = join(dir, 'wp-content', 'database');
|
|
463
|
+
const sqliteDbPath = join(dbDir, '.ht.sqlite');
|
|
464
|
+
// Clean slate for database dir
|
|
465
|
+
if (existsSync(dbDir))
|
|
466
|
+
rmSync(dbDir, { recursive: true, force: true });
|
|
467
|
+
mkdirSync(dbDir, { recursive: true });
|
|
468
|
+
// Strip SSH MOTD from dump
|
|
469
|
+
const rawDump = readFileSync(dumpPath, 'utf-8');
|
|
470
|
+
const sqlStart = rawDump.search(/^(\/\*|--|CREATE |DROP |SET |INSERT )/m);
|
|
471
|
+
if (sqlStart > 0) {
|
|
472
|
+
writeFileSync(dumpPath, rawDump.substring(sqlStart));
|
|
473
|
+
}
|
|
474
|
+
// Convert MySQL → SQLite via awk (mysql2sqlite is an awk script).
|
|
475
|
+
// Windows doesn't honor shebangs, so invoke awk explicitly.
|
|
476
|
+
const awk = findAwk();
|
|
477
|
+
if (!awk) {
|
|
478
|
+
throw new Error('awk not found. ' + (process.platform === 'win32'
|
|
479
|
+
? 'Reinstall the CLI — the bundled busybox.exe is missing.'
|
|
480
|
+
: 'Install awk/gawk.'));
|
|
481
|
+
}
|
|
482
|
+
const convertResult = spawnSync(awk.cmd, [...awk.prefixArgs, '-f', mysql2sqlitePath, dumpPath], {
|
|
483
|
+
encoding: 'utf-8',
|
|
484
|
+
maxBuffer: 500 * 1024 * 1024,
|
|
485
|
+
timeout: 120000,
|
|
486
|
+
});
|
|
487
|
+
if (convertResult.status !== 0) {
|
|
488
|
+
throw new Error(convertResult.stderr || 'mysql2sqlite conversion failed');
|
|
489
|
+
}
|
|
490
|
+
// Add DROP TABLE before each CREATE TABLE
|
|
491
|
+
let sqliteSql = convertResult.stdout;
|
|
492
|
+
sqliteSql = sqliteSql.replace(/^(CREATE TABLE `([^`]+)`)/gm, 'DROP TABLE IF EXISTS `$2`;\n$1');
|
|
493
|
+
// Import directly via better-sqlite3 (no external sqlite3 CLI needed)
|
|
494
|
+
const db = new Database(sqliteDbPath);
|
|
495
|
+
try {
|
|
496
|
+
db.exec(sqliteSql);
|
|
497
|
+
// Find the table prefix and rename to wp_
|
|
498
|
+
const tableRows = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").all();
|
|
499
|
+
const allTables = tableRows.map(r => r.name);
|
|
500
|
+
const optionsTable = allTables.find(t => t.endsWith('_options'));
|
|
501
|
+
const oldPrefix = optionsTable ? optionsTable.replace('options', '') : 'wp_';
|
|
502
|
+
if (oldPrefix !== 'wp_') {
|
|
503
|
+
const renames = allTables
|
|
504
|
+
.filter(t => t.startsWith(oldPrefix))
|
|
505
|
+
.map(t => `ALTER TABLE \`${t}\` RENAME TO \`wp_${t.substring(oldPrefix.length)}\``);
|
|
506
|
+
db.exec(renames.join(';\n') + ';');
|
|
507
|
+
// Rename meta keys and option names that contain the old prefix
|
|
508
|
+
db.prepare('UPDATE wp_usermeta SET meta_key = REPLACE(meta_key, ?, ?) WHERE meta_key LIKE ?').run(oldPrefix, 'wp_', oldPrefix + '%');
|
|
509
|
+
db.prepare('UPDATE wp_options SET option_name = REPLACE(option_name, ?, ?) WHERE option_name LIKE ?').run(oldPrefix, 'wp_', oldPrefix + '%');
|
|
510
|
+
}
|
|
511
|
+
// Search-replace old cloud URL → localhost (bound params: no SQL injection)
|
|
512
|
+
const localUrl = `http://127.0.0.1:${instance.port}`;
|
|
513
|
+
const oldDomain = site.url || site.sub_domain || '';
|
|
514
|
+
const oldUrls = [
|
|
515
|
+
oldDomain,
|
|
516
|
+
oldDomain.replace('https://', 'http://'),
|
|
517
|
+
].filter(Boolean);
|
|
518
|
+
const replaceStmts = [
|
|
519
|
+
'UPDATE wp_options SET option_value = REPLACE(option_value, ?, ?) WHERE option_value LIKE ?',
|
|
520
|
+
'UPDATE wp_posts SET post_content = REPLACE(post_content, ?, ?) WHERE post_content LIKE ?',
|
|
521
|
+
'UPDATE wp_posts SET guid = REPLACE(guid, ?, ?) WHERE guid LIKE ?',
|
|
522
|
+
'UPDATE wp_postmeta SET meta_value = REPLACE(meta_value, ?, ?) WHERE meta_value LIKE ?',
|
|
523
|
+
'UPDATE wp_comments SET comment_content = REPLACE(comment_content, ?, ?) WHERE comment_content LIKE ?',
|
|
524
|
+
].map(s => db.prepare(s));
|
|
525
|
+
for (const oldUrl of oldUrls) {
|
|
526
|
+
const likePattern = '%' + oldUrl + '%';
|
|
527
|
+
for (const stmt of replaceStmts) {
|
|
528
|
+
stmt.run(oldUrl, localUrl, likePattern);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
db.prepare("UPDATE wp_options SET option_value = ? WHERE option_name IN ('siteurl','home')").run(localUrl);
|
|
532
|
+
// Get admin username for blueprint login step
|
|
533
|
+
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();
|
|
534
|
+
adminUsername = adminRow?.user_login || 'admin';
|
|
535
|
+
const tableCount = db.prepare("SELECT COUNT(*) AS c FROM sqlite_master WHERE type='table'").get().c;
|
|
536
|
+
dbSpin2.succeed(`Database imported (${tableCount} tables, admin: ${adminUsername})`);
|
|
537
|
+
}
|
|
538
|
+
finally {
|
|
539
|
+
db.close();
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
catch (err) {
|
|
543
|
+
dbSpin2.fail('Database import failed: ' + err.message);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
// 8. Write clone blueprint with AST driver + login as actual admin user
|
|
547
|
+
const cloneBlueprintPath = join(dir, 'clone-blueprint.json');
|
|
548
|
+
const cloneBlueprint = {
|
|
549
|
+
steps: [
|
|
550
|
+
{
|
|
551
|
+
step: 'defineWpConfigConsts',
|
|
552
|
+
consts: {
|
|
553
|
+
WP_SQLITE_AST_DRIVER: true,
|
|
554
|
+
WP_DEBUG: false,
|
|
555
|
+
WP_DEBUG_DISPLAY: false,
|
|
556
|
+
},
|
|
557
|
+
},
|
|
558
|
+
{
|
|
559
|
+
step: 'login',
|
|
560
|
+
username: adminUsername,
|
|
561
|
+
},
|
|
562
|
+
],
|
|
563
|
+
};
|
|
564
|
+
writeFileSync(cloneBlueprintPath, JSON.stringify(cloneBlueprint));
|
|
565
|
+
// 9. Write error suppression mu-plugin
|
|
566
|
+
const muDir = join(dir, 'wp-content', 'mu-plugins');
|
|
567
|
+
mkdirSync(muDir, { recursive: true });
|
|
568
|
+
writeFileSync(join(muDir, '0-suppress-errors.php'), "<?php\nerror_reporting(E_ERROR | E_PARSE);\n@ini_set('display_errors', '0');\n");
|
|
569
|
+
console.log(`
|
|
570
|
+
${chalk.bold.green('Clone complete!')}
|
|
571
|
+
|
|
572
|
+
${chalk.dim('Name:')} ${name}
|
|
573
|
+
${chalk.dim('PHP:')} ${instance.php}
|
|
574
|
+
${chalk.dim('WordPress:')} ${instance.wp}
|
|
575
|
+
${chalk.dim('Port:')} ${port}
|
|
576
|
+
${chalk.dim('Data:')} ${chalk.dim(dir)}
|
|
577
|
+
${chalk.dim('Admin:')} ${adminUsername}
|
|
578
|
+
`);
|
|
579
|
+
if (opts.start !== false) {
|
|
580
|
+
printUrls(port);
|
|
581
|
+
console.log(chalk.dim('\nPress Ctrl+C to stop.\n'));
|
|
582
|
+
try {
|
|
583
|
+
await startServer(instance, {
|
|
584
|
+
blueprint: cloneBlueprintPath,
|
|
585
|
+
onReady: (url) => openWpAdmin(url),
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
catch (err) {
|
|
589
|
+
error('Failed to start local site', err.message);
|
|
590
|
+
process.exit(1);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
else {
|
|
594
|
+
info(`Start with: instawp local start ${name}`);
|
|
595
|
+
}
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
function checkRsync() {
|
|
599
|
+
// Windows transfers go over pure-JS SFTP, so rsync isn't required there.
|
|
600
|
+
if (process.platform === 'win32')
|
|
601
|
+
return true;
|
|
602
|
+
const result = spawnSync('which', ['rsync'], { stdio: 'ignore' });
|
|
603
|
+
return result.status === 0;
|
|
604
|
+
}
|
|
605
|
+
/**
|
|
606
|
+
* Locate an awk-compatible interpreter. Resolution order:
|
|
607
|
+
* 1. Bundled BusyBox-w64 in bin/win32/ (Windows only — invoked as `busybox awk`)
|
|
608
|
+
* 2. `awk` or `gawk` in PATH
|
|
609
|
+
* 3. Common Git-for-Windows install dirs (Windows only)
|
|
610
|
+
*
|
|
611
|
+
* Returns the command path plus any arg-prefix that must precede the awk
|
|
612
|
+
* arguments (busybox uses `busybox awk -f script input`).
|
|
613
|
+
*/
|
|
614
|
+
function findAwk() {
|
|
615
|
+
const bb = bundledBusybox();
|
|
616
|
+
if (bb)
|
|
617
|
+
return { cmd: bb, prefixArgs: ['awk'] };
|
|
618
|
+
const cmd = process.platform === 'win32' ? 'where' : 'which';
|
|
619
|
+
for (const name of ['awk', 'gawk']) {
|
|
620
|
+
const r = spawnSync(cmd, [name], { stdio: 'pipe' });
|
|
621
|
+
if (r.status === 0)
|
|
622
|
+
return { cmd: name, prefixArgs: [] };
|
|
623
|
+
}
|
|
624
|
+
if (process.platform === 'win32') {
|
|
625
|
+
const candidates = [
|
|
626
|
+
'C:\\Program Files\\Git\\usr\\bin\\awk.exe',
|
|
627
|
+
'C:\\Program Files (x86)\\Git\\usr\\bin\\awk.exe',
|
|
628
|
+
];
|
|
629
|
+
if (process.env.PROGRAMFILES) {
|
|
630
|
+
candidates.push(process.env.PROGRAMFILES + '\\Git\\usr\\bin\\awk.exe');
|
|
631
|
+
}
|
|
632
|
+
for (const c of candidates) {
|
|
633
|
+
if (existsSync(c))
|
|
634
|
+
return { cmd: c, prefixArgs: [] };
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
return null;
|
|
638
|
+
}
|
|
639
|
+
function sanitizeName(name) {
|
|
640
|
+
return name.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase();
|
|
641
|
+
}
|
|
642
|
+
// Playground supports: 7.4, 8.0, 8.1, 8.2, 8.3, 8.4, 8.5
|
|
643
|
+
const PLAYGROUND_PHP_VERSIONS = ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5'];
|
|
644
|
+
function normalizePhpVersion(version) {
|
|
645
|
+
if (!version)
|
|
646
|
+
return '8.3';
|
|
647
|
+
// Extract major.minor (e.g., "8.2.15" → "8.2")
|
|
648
|
+
const match = version.match(/^(\d+\.\d+)/);
|
|
649
|
+
const majorMinor = match ? match[1] : version;
|
|
650
|
+
if (PLAYGROUND_PHP_VERSIONS.includes(majorMinor))
|
|
651
|
+
return majorMinor;
|
|
652
|
+
// Fall back to closest supported version, prefer not going higher
|
|
653
|
+
return '8.3';
|
|
654
|
+
}
|
|
655
|
+
function nextAutoName(instances) {
|
|
656
|
+
let i = 1;
|
|
657
|
+
while (instances[`insta-local-site-${i}`])
|
|
658
|
+
i++;
|
|
659
|
+
return `insta-local-site-${i}`;
|
|
660
|
+
}
|
|
661
|
+
function printUrls(port) {
|
|
662
|
+
const url = `http://127.0.0.1:${port}`;
|
|
663
|
+
console.log(` ${chalk.dim('Site:')} ${chalk.cyan.underline(url)}`);
|
|
664
|
+
console.log(` ${chalk.dim('WP Admin:')} ${chalk.cyan.underline(`${url}/?instawp-login`)}`);
|
|
665
|
+
}
|
|
666
|
+
async function launchServer(instance, opts) {
|
|
667
|
+
const shouldOpen = opts.open !== false;
|
|
668
|
+
const json = isJsonMode();
|
|
669
|
+
if (opts.background) {
|
|
670
|
+
const spin = json ? null : spinner(`Starting "${instance.name}" in background...`);
|
|
671
|
+
spin?.start();
|
|
672
|
+
try {
|
|
673
|
+
const { pid, url } = await startServerBackground(instance, opts.blueprint);
|
|
674
|
+
if (json) {
|
|
675
|
+
console.log(JSON.stringify({
|
|
676
|
+
success: true,
|
|
677
|
+
data: {
|
|
678
|
+
name: instance.name,
|
|
679
|
+
url,
|
|
680
|
+
port: instance.port,
|
|
681
|
+
pid,
|
|
682
|
+
wp: instance.wp,
|
|
683
|
+
php: instance.php,
|
|
684
|
+
path: instance.path,
|
|
685
|
+
},
|
|
686
|
+
}));
|
|
687
|
+
}
|
|
688
|
+
else {
|
|
689
|
+
spin?.succeed(`Running in background (PID: ${pid})`);
|
|
690
|
+
printUrls(instance.port);
|
|
691
|
+
info(`Stop with: instawp local stop ${instance.name}`);
|
|
692
|
+
info(`Logs: ${instance.path}/server.log`);
|
|
693
|
+
}
|
|
694
|
+
if (shouldOpen)
|
|
695
|
+
await openWpAdmin(url);
|
|
696
|
+
}
|
|
697
|
+
catch (err) {
|
|
698
|
+
if (json) {
|
|
699
|
+
console.log(JSON.stringify({ success: false, error: err.message }));
|
|
700
|
+
}
|
|
701
|
+
else {
|
|
702
|
+
spin?.fail('Failed to start');
|
|
703
|
+
error(err.message);
|
|
704
|
+
}
|
|
705
|
+
process.exit(1);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
else {
|
|
709
|
+
if (!json) {
|
|
710
|
+
printUrls(instance.port);
|
|
711
|
+
console.log(chalk.dim('\nPress Ctrl+C to stop.\n'));
|
|
712
|
+
}
|
|
713
|
+
try {
|
|
714
|
+
await startServer(instance, {
|
|
715
|
+
blueprint: opts.blueprint,
|
|
716
|
+
onReady: shouldOpen ? (url) => openWpAdmin(url) : undefined,
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
catch (err) {
|
|
720
|
+
error('Failed to start local site', err.message);
|
|
721
|
+
process.exit(1);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
async function openWpAdmin(serverUrl) {
|
|
726
|
+
// Use the magic login URL — hits frontend (no auth wall),
|
|
727
|
+
// sets cookie via mu-plugin, then redirects to wp-admin
|
|
728
|
+
const loginUrl = `${serverUrl}/?instawp-login`;
|
|
729
|
+
// Wait for WordPress to be fully ready
|
|
730
|
+
for (let i = 0; i < 30; i++) {
|
|
731
|
+
try {
|
|
732
|
+
const controller = new AbortController();
|
|
733
|
+
const timer = setTimeout(() => controller.abort(), 2000);
|
|
734
|
+
const res = await fetch(serverUrl, {
|
|
735
|
+
signal: controller.signal,
|
|
736
|
+
redirect: 'manual',
|
|
737
|
+
});
|
|
738
|
+
clearTimeout(timer);
|
|
739
|
+
if (res.status === 200 || res.status === 302) {
|
|
740
|
+
break;
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
catch {
|
|
744
|
+
// Server not ready yet
|
|
745
|
+
}
|
|
746
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
747
|
+
}
|
|
748
|
+
open(loginUrl).catch(() => { });
|
|
749
|
+
}
|
|
750
|
+
//# sourceMappingURL=local.js.map
|