kirby-deploy 0.0.0
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/cli.d.ts +1 -0
- package/dist/cli.js +457 -0
- package/dist/index.d.ts +110 -0
- package/dist/index.js +5 -0
- package/package.json +30 -0
- package/src/cli.ts +6 -0
- package/src/commands/accounts.ts +40 -0
- package/src/commands/content.ts +40 -0
- package/src/commands/main.ts +73 -0
- package/src/config.ts +68 -0
- package/src/index.ts +3 -0
- package/src/lftp/cat.ts +29 -0
- package/src/lftp/mirror.ts +82 -0
- package/src/sync.ts +89 -0
- package/src/types.ts +52 -0
- package/src/utils.ts +54 -0
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { runMain } from "citty";
|
|
5
|
+
|
|
6
|
+
// src/commands/main.ts
|
|
7
|
+
import { defineCommand as defineCommand3 } from "citty";
|
|
8
|
+
import consola8 from "consola";
|
|
9
|
+
import { colors as colors5 } from "consola/utils";
|
|
10
|
+
import { readFileSync } from "fs";
|
|
11
|
+
import { join as join5, relative } from "path/posix";
|
|
12
|
+
import { cwd as cwd2 } from "process";
|
|
13
|
+
|
|
14
|
+
// src/config.ts
|
|
15
|
+
import { loadConfig as load } from "c12";
|
|
16
|
+
import consola from "consola";
|
|
17
|
+
import { flatten, parse } from "valibot";
|
|
18
|
+
|
|
19
|
+
// src/types.ts
|
|
20
|
+
import {
|
|
21
|
+
any,
|
|
22
|
+
array,
|
|
23
|
+
boolean,
|
|
24
|
+
literal,
|
|
25
|
+
number,
|
|
26
|
+
object,
|
|
27
|
+
optional,
|
|
28
|
+
record,
|
|
29
|
+
string,
|
|
30
|
+
union
|
|
31
|
+
} from "valibot";
|
|
32
|
+
var FolderStructureSchema = object({
|
|
33
|
+
content: string(),
|
|
34
|
+
media: string(),
|
|
35
|
+
accounts: string(),
|
|
36
|
+
sessions: string(),
|
|
37
|
+
cache: string()
|
|
38
|
+
});
|
|
39
|
+
var ConfigSchema = object({
|
|
40
|
+
host: string(),
|
|
41
|
+
user: string(),
|
|
42
|
+
password: string(),
|
|
43
|
+
url: optional(string()),
|
|
44
|
+
token: optional(string()),
|
|
45
|
+
remoteDir: optional(string()),
|
|
46
|
+
folderStructure: optional(
|
|
47
|
+
union([literal("flat"), literal("public"), FolderStructureSchema])
|
|
48
|
+
),
|
|
49
|
+
checkComposerLock: optional(boolean()),
|
|
50
|
+
callWebhooks: optional(boolean()),
|
|
51
|
+
dryRun: optional(boolean()),
|
|
52
|
+
verbose: optional(boolean()),
|
|
53
|
+
parallel: optional(number()),
|
|
54
|
+
exclude: optional(array(string())),
|
|
55
|
+
excludeGlob: optional(array(string())),
|
|
56
|
+
include: optional(array(string())),
|
|
57
|
+
includeGlob: optional(array(string())),
|
|
58
|
+
lftpSettings: optional(record(string(), any())),
|
|
59
|
+
lftpFlags: optional(array(string()))
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// src/config.ts
|
|
63
|
+
var loadConfig = async () => {
|
|
64
|
+
let { config, configFile } = await load({
|
|
65
|
+
name: "kirby-deploy",
|
|
66
|
+
dotenv: true
|
|
67
|
+
});
|
|
68
|
+
if (!config) {
|
|
69
|
+
consola.error(new Error("no config file found"));
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
parse(ConfigSchema, config);
|
|
74
|
+
} catch (e) {
|
|
75
|
+
const issues = flatten(e).nested;
|
|
76
|
+
const info = Object.entries(issues).map(([key, messages]) => ` - ${key} (${messages.join(", ")})`).join("\n");
|
|
77
|
+
consola.error(`Invalid properties in ${configFile}
|
|
78
|
+
${info}`);
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
config.folderStructure ??= "flat";
|
|
82
|
+
if (config.folderStructure === "public") {
|
|
83
|
+
config.folderStructure = {
|
|
84
|
+
content: "content",
|
|
85
|
+
media: "public/media",
|
|
86
|
+
accounts: "storage/accounts",
|
|
87
|
+
sessions: "storage/sessions",
|
|
88
|
+
cache: "storage/cache"
|
|
89
|
+
};
|
|
90
|
+
} else if (config.folderStructure === "flat") {
|
|
91
|
+
config.folderStructure = {
|
|
92
|
+
content: "content",
|
|
93
|
+
media: "site/media",
|
|
94
|
+
accounts: "site/accounts",
|
|
95
|
+
sessions: "site/sessions",
|
|
96
|
+
cache: "site/cache"
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
config = {
|
|
100
|
+
remoteDir: "./",
|
|
101
|
+
dryRun: true,
|
|
102
|
+
parallel: 10,
|
|
103
|
+
checkComposerLock: true,
|
|
104
|
+
callWebhooks: true,
|
|
105
|
+
exclude: [],
|
|
106
|
+
excludeGlob: [],
|
|
107
|
+
include: [],
|
|
108
|
+
includeGlob: [],
|
|
109
|
+
...config,
|
|
110
|
+
lftpSettings: {
|
|
111
|
+
"ftp:ssl-force": true,
|
|
112
|
+
...config.lftpSettings
|
|
113
|
+
},
|
|
114
|
+
lftpFlags: ["--parallel=10", "--dereference", ...config.lftpFlags ?? []]
|
|
115
|
+
};
|
|
116
|
+
return config;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// src/lftp/cat.ts
|
|
120
|
+
import { spawnSync } from "child_process";
|
|
121
|
+
import consola2 from "consola";
|
|
122
|
+
import { platform } from "os";
|
|
123
|
+
var cat = (file, { host, user, password, lftpSettings }) => {
|
|
124
|
+
const commands = [
|
|
125
|
+
...Object.entries(lftpSettings).map(
|
|
126
|
+
([key, value]) => `set ${key} ${value}`
|
|
127
|
+
),
|
|
128
|
+
`open ${host}`,
|
|
129
|
+
`user ${user} ${password}`,
|
|
130
|
+
`cat ${file}`,
|
|
131
|
+
"bye"
|
|
132
|
+
];
|
|
133
|
+
const isWindows = platform() === "win32";
|
|
134
|
+
const child = isWindows ? spawnSync("wsl", ["lftp", "-c", commands.join("; ")], {
|
|
135
|
+
encoding: "utf-8"
|
|
136
|
+
}) : spawnSync("lftp", ["-c", commands.join("; ")], { encoding: "utf-8" });
|
|
137
|
+
if (child.stderr)
|
|
138
|
+
consola2.error(child.stderr);
|
|
139
|
+
return child.stdout;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
// src/sync.ts
|
|
143
|
+
import consola5 from "consola";
|
|
144
|
+
import { join as join2 } from "path/posix";
|
|
145
|
+
|
|
146
|
+
// src/lftp/mirror.ts
|
|
147
|
+
import consola3 from "consola";
|
|
148
|
+
import { colors } from "consola/utils";
|
|
149
|
+
import { spawn } from "node:child_process";
|
|
150
|
+
import { platform as platform2 } from "node:os";
|
|
151
|
+
var mirror = (source, destination, flags, { lftpSettings, host, user, password, verbose }) => {
|
|
152
|
+
const commands = [
|
|
153
|
+
...Object.entries(lftpSettings).map(
|
|
154
|
+
([key, value]) => `set ${key} ${value}`
|
|
155
|
+
),
|
|
156
|
+
`open ${host}`,
|
|
157
|
+
`user ${user} ${password}`,
|
|
158
|
+
// mask credentials
|
|
159
|
+
`mirror ${flags.join(" ")} ${source} ${destination}`,
|
|
160
|
+
"bye"
|
|
161
|
+
];
|
|
162
|
+
const isWindows = platform2() === "win32";
|
|
163
|
+
const child = isWindows ? spawn("wsl", ["lftp", "-c", commands.join("; ")]) : spawn("lftp", ["-c", commands.join("; ")]);
|
|
164
|
+
let hasErrors = false;
|
|
165
|
+
let hasChanges = false;
|
|
166
|
+
const handleData = (data) => {
|
|
167
|
+
if (verbose)
|
|
168
|
+
consola3.log(`${colors.bgBlue(" LFTP ")} ${data}
|
|
169
|
+
`);
|
|
170
|
+
data.toString().split("\n").forEach((line) => {
|
|
171
|
+
let match = null;
|
|
172
|
+
if (match = line.match(/Transferring file `(.*)'/)) {
|
|
173
|
+
hasChanges = true;
|
|
174
|
+
consola3.log(colors.blue(`\u2192 ${match[1]}`));
|
|
175
|
+
} else if (match = line.match(/Removing old (?:file|directory) `(.*)'/)) {
|
|
176
|
+
hasChanges = true;
|
|
177
|
+
consola3.log(colors.red(`\u2A2F ${match[1]}`));
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
};
|
|
181
|
+
const handleError = (data) => {
|
|
182
|
+
consola3.error(data.toString());
|
|
183
|
+
hasErrors = true;
|
|
184
|
+
};
|
|
185
|
+
return new Promise((resolve) => {
|
|
186
|
+
child.stdout.on("data", handleData);
|
|
187
|
+
child.stderr.on("data", handleError);
|
|
188
|
+
child.on("exit", () => resolve({ hasChanges, hasErrors }));
|
|
189
|
+
});
|
|
190
|
+
};
|
|
191
|
+
var logMirror = (source, destination, flags, { lftpSettings, host }) => {
|
|
192
|
+
const commands = [
|
|
193
|
+
...Object.entries(lftpSettings).map(
|
|
194
|
+
([key, value]) => `set ${key} ${value}`
|
|
195
|
+
),
|
|
196
|
+
`open ${host}`,
|
|
197
|
+
`user <user> <password>`,
|
|
198
|
+
// mask credentials
|
|
199
|
+
`mirror ${flags.join(" ")} ${source} ${destination}`,
|
|
200
|
+
"bye"
|
|
201
|
+
];
|
|
202
|
+
consola3.log(`
|
|
203
|
+
${colors.bgBlue(" LFTP ")} ${commands.join("; ")}
|
|
204
|
+
`);
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
// src/utils.ts
|
|
208
|
+
import consola4 from "consola";
|
|
209
|
+
import { colors as colors2 } from "consola/utils";
|
|
210
|
+
import { spawnSync as spawnSync2 } from "node:child_process";
|
|
211
|
+
import { existsSync } from "node:fs";
|
|
212
|
+
import { join } from "node:path";
|
|
213
|
+
import { cwd, stdin as input, stdout as output } from "node:process";
|
|
214
|
+
import * as readline from "node:readline";
|
|
215
|
+
var upperFirst = (string2) => string2.charAt(0).toUpperCase() + string2.slice(1);
|
|
216
|
+
var isGit = () => existsSync(join(cwd(), ".git"));
|
|
217
|
+
var getBranch = () => {
|
|
218
|
+
if (!isGit())
|
|
219
|
+
return;
|
|
220
|
+
const { stderr, stdout } = spawnSync2("git", ["branch", "--show-current"], {
|
|
221
|
+
encoding: "utf-8"
|
|
222
|
+
});
|
|
223
|
+
if (stderr) {
|
|
224
|
+
consola4.log(stderr);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
return stdout.trim();
|
|
228
|
+
};
|
|
229
|
+
var confirm = (question) => new Promise((resolve) => {
|
|
230
|
+
const rl = readline.createInterface({ input, output });
|
|
231
|
+
const formattedQuestion = `
|
|
232
|
+
${question} ${colors2.yellow("(y/n)")} `;
|
|
233
|
+
rl.question(formattedQuestion, (answer) => {
|
|
234
|
+
rl.close();
|
|
235
|
+
const hasAgreed = ["yes", "y"].includes(answer.toLowerCase());
|
|
236
|
+
resolve(hasAgreed);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
var callWebhook = async (url, token) => {
|
|
240
|
+
const result = await fetch(url, {
|
|
241
|
+
headers: {
|
|
242
|
+
"Content-Type": "application/json",
|
|
243
|
+
Authorization: `Bearer ${token}`
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
if (result.status < 200 || result.status >= 300) {
|
|
247
|
+
consola4.error(`Failed to call webhook ${url}, status: ${result.status}`);
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
return true;
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
// src/sync.ts
|
|
254
|
+
var sync = async (source, mode, config) => {
|
|
255
|
+
const reverse = mode === "push";
|
|
256
|
+
const targetName = mode === "push" ? "remote" : "local";
|
|
257
|
+
const webhook = `${config.url}/plugin-kirby-deploy`;
|
|
258
|
+
const destination = source === "./" ? config.remoteDir : `./${join2(config.remoteDir, source)}`;
|
|
259
|
+
const flags = [
|
|
260
|
+
"--continue",
|
|
261
|
+
"--only-newer",
|
|
262
|
+
"--overwrite",
|
|
263
|
+
"--use-cache",
|
|
264
|
+
"--delete",
|
|
265
|
+
"--verbose",
|
|
266
|
+
reverse && "--reverse",
|
|
267
|
+
...config.exclude.map((path) => `--exclude ${path}`),
|
|
268
|
+
...config.excludeGlob.map((path) => `--exclude-glob ${path}`),
|
|
269
|
+
...config.includeGlob.map((path) => `--include-glob ${path}`),
|
|
270
|
+
...config.include.map((path) => `--include ${path}`),
|
|
271
|
+
...config.lftpFlags
|
|
272
|
+
].filter(Boolean);
|
|
273
|
+
if (config.verbose) {
|
|
274
|
+
logMirror(source, destination, flags, config);
|
|
275
|
+
}
|
|
276
|
+
if (config.dryRun) {
|
|
277
|
+
consola5.log("Review changes...");
|
|
278
|
+
consola5.log("");
|
|
279
|
+
const { hasChanges: hasChanges2 } = await mirror(
|
|
280
|
+
source,
|
|
281
|
+
destination,
|
|
282
|
+
[...flags, "--dry-run"],
|
|
283
|
+
config
|
|
284
|
+
);
|
|
285
|
+
if (!hasChanges2) {
|
|
286
|
+
consola5.success(`${upperFirst(targetName)} already up to date`);
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
const shouldContinue = await confirm(`Apply changes to ${targetName}?`);
|
|
290
|
+
if (!shouldContinue)
|
|
291
|
+
return false;
|
|
292
|
+
consola5.log("");
|
|
293
|
+
}
|
|
294
|
+
consola5.log("Apply changes...\n");
|
|
295
|
+
if (config.callWebhooks)
|
|
296
|
+
await callWebhook(`${webhook}/start`, config.token);
|
|
297
|
+
let hasChanges, hasErrors;
|
|
298
|
+
try {
|
|
299
|
+
;
|
|
300
|
+
({ hasChanges, hasErrors } = await mirror(
|
|
301
|
+
source,
|
|
302
|
+
destination,
|
|
303
|
+
flags,
|
|
304
|
+
config
|
|
305
|
+
));
|
|
306
|
+
} catch (e) {
|
|
307
|
+
consola5.error(e);
|
|
308
|
+
return false;
|
|
309
|
+
} finally {
|
|
310
|
+
if (config.callWebhooks) {
|
|
311
|
+
await callWebhook(`${webhook}/finish`, config.token);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
if (!hasChanges) {
|
|
315
|
+
consola5.success(`${upperFirst(targetName)} already up to date`);
|
|
316
|
+
return false;
|
|
317
|
+
}
|
|
318
|
+
consola5.log("");
|
|
319
|
+
consola5.success(
|
|
320
|
+
hasErrors ? "All done (but with errors, see output above)!" : "All done!"
|
|
321
|
+
);
|
|
322
|
+
return true;
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
// src/commands/accounts.ts
|
|
326
|
+
import { defineCommand } from "citty";
|
|
327
|
+
import consola6 from "consola";
|
|
328
|
+
import { colors as colors3 } from "consola/utils";
|
|
329
|
+
import { join as join3 } from "path/posix";
|
|
330
|
+
var syncAccounts = async (mode) => {
|
|
331
|
+
const config = await loadConfig();
|
|
332
|
+
if (!config)
|
|
333
|
+
return;
|
|
334
|
+
const { accounts } = config.folderStructure;
|
|
335
|
+
const source = `./${accounts}/`;
|
|
336
|
+
const branch = getBranch();
|
|
337
|
+
const displaySource = colors3.magenta(
|
|
338
|
+
`${source}${branch ? colors3.cyan(` (${branch})`) : ""}`
|
|
339
|
+
);
|
|
340
|
+
const displayDestination = colors3.magenta(
|
|
341
|
+
join3(config.host, config.remoteDir, source)
|
|
342
|
+
);
|
|
343
|
+
const direction = mode === "pull" ? "from" : "to";
|
|
344
|
+
consola6.log(
|
|
345
|
+
`\u{1F511} ${upperFirst(mode)} ${displaySource} ${direction} ${displayDestination}
|
|
346
|
+
`
|
|
347
|
+
);
|
|
348
|
+
return sync(source, mode, {
|
|
349
|
+
...config,
|
|
350
|
+
// User provided includes/excludes can only be used in the main command
|
|
351
|
+
// because they are relative to the base directory, so we reset them.
|
|
352
|
+
exclude: [],
|
|
353
|
+
excludeGlob: [".*", ".*/"],
|
|
354
|
+
include: [".htpasswd"],
|
|
355
|
+
// Make sure account passwords are synced.
|
|
356
|
+
includeGlob: []
|
|
357
|
+
});
|
|
358
|
+
};
|
|
359
|
+
var accountsPush = defineCommand({ run: () => syncAccounts("push") });
|
|
360
|
+
var accountsPull = defineCommand({ run: () => syncAccounts("pull") });
|
|
361
|
+
|
|
362
|
+
// src/commands/content.ts
|
|
363
|
+
import { defineCommand as defineCommand2 } from "citty";
|
|
364
|
+
import consola7 from "consola";
|
|
365
|
+
import { colors as colors4 } from "consola/utils";
|
|
366
|
+
import { join as join4 } from "path/posix";
|
|
367
|
+
var syncContent = async (mode) => {
|
|
368
|
+
const config = await loadConfig();
|
|
369
|
+
if (!config)
|
|
370
|
+
return;
|
|
371
|
+
const { content } = config.folderStructure;
|
|
372
|
+
const source = `./${content}/`;
|
|
373
|
+
const branch = getBranch();
|
|
374
|
+
const displaySource = colors4.magenta(
|
|
375
|
+
`${source}${branch ? colors4.cyan(` (${branch})`) : ""}`
|
|
376
|
+
);
|
|
377
|
+
const displayDestination = colors4.magenta(
|
|
378
|
+
join4(config.host, config.remoteDir, source)
|
|
379
|
+
);
|
|
380
|
+
const direction = mode === "pull" ? "from" : "to";
|
|
381
|
+
consola7.log(
|
|
382
|
+
`\u{1F5C2}\uFE0F ${upperFirst(mode)} ${displaySource} ${direction} ${displayDestination}
|
|
383
|
+
`
|
|
384
|
+
);
|
|
385
|
+
return sync(source, mode, {
|
|
386
|
+
...config,
|
|
387
|
+
// User provided includes/excludes can only be used in the main command
|
|
388
|
+
// because they are relative to the base directory, so we reset them.
|
|
389
|
+
exclude: [],
|
|
390
|
+
excludeGlob: [".*", ".*/"],
|
|
391
|
+
include: [],
|
|
392
|
+
includeGlob: []
|
|
393
|
+
});
|
|
394
|
+
};
|
|
395
|
+
var contentPush = defineCommand2({ run: () => syncContent("push") });
|
|
396
|
+
var contentPull = defineCommand2({ run: () => syncContent("pull") });
|
|
397
|
+
|
|
398
|
+
// src/commands/main.ts
|
|
399
|
+
var main = defineCommand3({
|
|
400
|
+
run: async ({ rawArgs, cmd }) => {
|
|
401
|
+
const [firstArg] = rawArgs;
|
|
402
|
+
const subCommands = Object.keys(cmd.subCommands ?? {});
|
|
403
|
+
const isSubCommand = subCommands.includes(firstArg);
|
|
404
|
+
if (isSubCommand)
|
|
405
|
+
return;
|
|
406
|
+
const config = await loadConfig();
|
|
407
|
+
if (!config)
|
|
408
|
+
return;
|
|
409
|
+
const { folderStructure } = config;
|
|
410
|
+
const exclude = [
|
|
411
|
+
...config.exclude,
|
|
412
|
+
"^node_modules/",
|
|
413
|
+
`^${relative(cwd2(), folderStructure.content)}`,
|
|
414
|
+
`^${relative(cwd2(), folderStructure.media)}`,
|
|
415
|
+
`^${relative(cwd2(), folderStructure.accounts)}`,
|
|
416
|
+
`^${relative(cwd2(), folderStructure.sessions)}`,
|
|
417
|
+
`^${relative(cwd2(), folderStructure.cache)}`
|
|
418
|
+
];
|
|
419
|
+
const excludeGlob = [...config.excludeGlob, ".*", ".*/"];
|
|
420
|
+
const include = config.include;
|
|
421
|
+
const includeGlob = [...config.includeGlob, ".htaccess"];
|
|
422
|
+
const branch = getBranch();
|
|
423
|
+
const displaySource = branch ? colors5.cyan(` ${branch} `) : " ";
|
|
424
|
+
const displayDestination = colors5.magenta(
|
|
425
|
+
join5(config.host, config.remoteDir)
|
|
426
|
+
);
|
|
427
|
+
consola8.log(`\u{1F680} Deploy${displaySource}to ${displayDestination}
|
|
428
|
+
`);
|
|
429
|
+
if (config.checkComposerLock) {
|
|
430
|
+
const localComposerLock = readFileSync("./composer.lock", {
|
|
431
|
+
encoding: "utf-8"
|
|
432
|
+
});
|
|
433
|
+
const remoteComposerLock = cat("./composer.lock", config);
|
|
434
|
+
const skipVendor = localComposerLock === remoteComposerLock;
|
|
435
|
+
if (skipVendor) {
|
|
436
|
+
exclude.push("^vendor/", "^kirby/");
|
|
437
|
+
consola8.info("Skipping vendor\n");
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
await sync("./", "push", {
|
|
441
|
+
...config,
|
|
442
|
+
exclude,
|
|
443
|
+
excludeGlob,
|
|
444
|
+
include,
|
|
445
|
+
includeGlob
|
|
446
|
+
});
|
|
447
|
+
},
|
|
448
|
+
subCommands: {
|
|
449
|
+
["content-push"]: contentPush,
|
|
450
|
+
["content-pull"]: contentPull,
|
|
451
|
+
["accounts-push"]: accountsPush,
|
|
452
|
+
["accounts-pull"]: accountsPull
|
|
453
|
+
}
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
// src/cli.ts
|
|
457
|
+
runMain(main);
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import * as valibot from 'valibot';
|
|
2
|
+
import { Output } from 'valibot';
|
|
3
|
+
|
|
4
|
+
declare const ConfigSchema: valibot.ObjectSchema<{
|
|
5
|
+
host: valibot.StringSchema<string>;
|
|
6
|
+
user: valibot.StringSchema<string>;
|
|
7
|
+
password: valibot.StringSchema<string>;
|
|
8
|
+
url: valibot.OptionalSchema<valibot.StringSchema<string>, undefined, string | undefined>;
|
|
9
|
+
token: valibot.OptionalSchema<valibot.StringSchema<string>, undefined, string | undefined>;
|
|
10
|
+
remoteDir: valibot.OptionalSchema<valibot.StringSchema<string>, undefined, string | undefined>;
|
|
11
|
+
folderStructure: valibot.OptionalSchema<valibot.UnionSchema<(valibot.LiteralSchema<"flat", "flat"> | valibot.LiteralSchema<"public", "public"> | valibot.ObjectSchema<{
|
|
12
|
+
content: valibot.StringSchema<string>;
|
|
13
|
+
media: valibot.StringSchema<string>;
|
|
14
|
+
accounts: valibot.StringSchema<string>;
|
|
15
|
+
sessions: valibot.StringSchema<string>;
|
|
16
|
+
cache: valibot.StringSchema<string>;
|
|
17
|
+
}, undefined, {
|
|
18
|
+
content: string;
|
|
19
|
+
media: string;
|
|
20
|
+
accounts: string;
|
|
21
|
+
sessions: string;
|
|
22
|
+
cache: string;
|
|
23
|
+
}>)[], "flat" | "public" | {
|
|
24
|
+
content: string;
|
|
25
|
+
media: string;
|
|
26
|
+
accounts: string;
|
|
27
|
+
sessions: string;
|
|
28
|
+
cache: string;
|
|
29
|
+
}>, undefined, "flat" | "public" | {
|
|
30
|
+
content: string;
|
|
31
|
+
media: string;
|
|
32
|
+
accounts: string;
|
|
33
|
+
sessions: string;
|
|
34
|
+
cache: string;
|
|
35
|
+
} | undefined>;
|
|
36
|
+
checkComposerLock: valibot.OptionalSchema<valibot.BooleanSchema<boolean>, undefined, boolean | undefined>;
|
|
37
|
+
callWebhooks: valibot.OptionalSchema<valibot.BooleanSchema<boolean>, undefined, boolean | undefined>;
|
|
38
|
+
dryRun: valibot.OptionalSchema<valibot.BooleanSchema<boolean>, undefined, boolean | undefined>;
|
|
39
|
+
verbose: valibot.OptionalSchema<valibot.BooleanSchema<boolean>, undefined, boolean | undefined>;
|
|
40
|
+
parallel: valibot.OptionalSchema<valibot.NumberSchema<number>, undefined, number | undefined>;
|
|
41
|
+
exclude: valibot.OptionalSchema<valibot.ArraySchema<valibot.StringSchema<string>, string[]>, undefined, string[] | undefined>;
|
|
42
|
+
excludeGlob: valibot.OptionalSchema<valibot.ArraySchema<valibot.StringSchema<string>, string[]>, undefined, string[] | undefined>;
|
|
43
|
+
include: valibot.OptionalSchema<valibot.ArraySchema<valibot.StringSchema<string>, string[]>, undefined, string[] | undefined>;
|
|
44
|
+
includeGlob: valibot.OptionalSchema<valibot.ArraySchema<valibot.StringSchema<string>, string[]>, undefined, string[] | undefined>;
|
|
45
|
+
lftpSettings: valibot.OptionalSchema<valibot.RecordSchema<valibot.StringSchema<string>, valibot.AnySchema<any>, {
|
|
46
|
+
[x: string]: any;
|
|
47
|
+
}>, undefined, {
|
|
48
|
+
[x: string]: any;
|
|
49
|
+
} | undefined>;
|
|
50
|
+
lftpFlags: valibot.OptionalSchema<valibot.ArraySchema<valibot.StringSchema<string>, string[]>, undefined, string[] | undefined>;
|
|
51
|
+
}, undefined, {
|
|
52
|
+
host: string;
|
|
53
|
+
user: string;
|
|
54
|
+
password: string;
|
|
55
|
+
url?: string | undefined;
|
|
56
|
+
token?: string | undefined;
|
|
57
|
+
remoteDir?: string | undefined;
|
|
58
|
+
folderStructure?: "flat" | "public" | {
|
|
59
|
+
content: string;
|
|
60
|
+
media: string;
|
|
61
|
+
accounts: string;
|
|
62
|
+
sessions: string;
|
|
63
|
+
cache: string;
|
|
64
|
+
} | undefined;
|
|
65
|
+
checkComposerLock?: boolean | undefined;
|
|
66
|
+
callWebhooks?: boolean | undefined;
|
|
67
|
+
dryRun?: boolean | undefined;
|
|
68
|
+
verbose?: boolean | undefined;
|
|
69
|
+
parallel?: number | undefined;
|
|
70
|
+
exclude?: string[] | undefined;
|
|
71
|
+
excludeGlob?: string[] | undefined;
|
|
72
|
+
include?: string[] | undefined;
|
|
73
|
+
includeGlob?: string[] | undefined;
|
|
74
|
+
lftpSettings?: {
|
|
75
|
+
[x: string]: any;
|
|
76
|
+
} | undefined;
|
|
77
|
+
lftpFlags?: string[] | undefined;
|
|
78
|
+
}>;
|
|
79
|
+
type Config = Output<typeof ConfigSchema>;
|
|
80
|
+
|
|
81
|
+
declare const defineConfig: (config: Config) => {
|
|
82
|
+
host: string;
|
|
83
|
+
user: string;
|
|
84
|
+
password: string;
|
|
85
|
+
url?: string | undefined;
|
|
86
|
+
token?: string | undefined;
|
|
87
|
+
remoteDir?: string | undefined;
|
|
88
|
+
folderStructure?: "flat" | "public" | {
|
|
89
|
+
content: string;
|
|
90
|
+
media: string;
|
|
91
|
+
accounts: string;
|
|
92
|
+
sessions: string;
|
|
93
|
+
cache: string;
|
|
94
|
+
} | undefined;
|
|
95
|
+
checkComposerLock?: boolean | undefined;
|
|
96
|
+
callWebhooks?: boolean | undefined;
|
|
97
|
+
dryRun?: boolean | undefined;
|
|
98
|
+
verbose?: boolean | undefined;
|
|
99
|
+
parallel?: number | undefined;
|
|
100
|
+
exclude?: string[] | undefined;
|
|
101
|
+
excludeGlob?: string[] | undefined;
|
|
102
|
+
include?: string[] | undefined;
|
|
103
|
+
includeGlob?: string[] | undefined;
|
|
104
|
+
lftpSettings?: {
|
|
105
|
+
[x: string]: any;
|
|
106
|
+
} | undefined;
|
|
107
|
+
lftpFlags?: string[] | undefined;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export { defineConfig };
|
package/dist/index.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "kirby-deploy",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"main": "./dist/index.js",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"kirby-deploy": "./dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"keywords": [],
|
|
10
|
+
"author": "Arno Schlipf",
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"devDependencies": {
|
|
13
|
+
"@prettier/plugin-php": "^0.22.2",
|
|
14
|
+
"@types/node": "^20.11.19",
|
|
15
|
+
"prettier": "^3.2.5",
|
|
16
|
+
"tsup": "^8.0.2",
|
|
17
|
+
"typescript": "^5.3.3"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"c12": "^1.8.0",
|
|
21
|
+
"citty": "^0.1.6",
|
|
22
|
+
"consola": "^3.2.3",
|
|
23
|
+
"valibot": "^0.28.1"
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "tsup src/index.ts src/cli.ts --format esm --dts",
|
|
27
|
+
"dev": "tsup src/index.ts src/cli.ts --format esm --watch",
|
|
28
|
+
"type-check": "tsc --noEmit"
|
|
29
|
+
}
|
|
30
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { defineCommand } from 'citty'
|
|
2
|
+
import consola from 'consola'
|
|
3
|
+
import { colors } from 'consola/utils'
|
|
4
|
+
import { join } from 'path/posix'
|
|
5
|
+
import { loadConfig } from '../config'
|
|
6
|
+
import { sync } from '../sync'
|
|
7
|
+
import { getBranch, upperFirst } from '../utils'
|
|
8
|
+
|
|
9
|
+
const syncAccounts = async (mode: 'pull' | 'push') => {
|
|
10
|
+
const config = await loadConfig()
|
|
11
|
+
if (!config) return
|
|
12
|
+
|
|
13
|
+
const { accounts } = config.folderStructure
|
|
14
|
+
const source = `./${accounts}/`
|
|
15
|
+
|
|
16
|
+
const branch = getBranch()
|
|
17
|
+
const displaySource = colors.magenta(
|
|
18
|
+
`${source}${branch ? colors.cyan(` (${branch})`) : ''}`,
|
|
19
|
+
)
|
|
20
|
+
const displayDestination = colors.magenta(
|
|
21
|
+
join(config.host, config.remoteDir, source),
|
|
22
|
+
)
|
|
23
|
+
const direction = mode === 'pull' ? 'from' : 'to'
|
|
24
|
+
consola.log(
|
|
25
|
+
`🔑 ${upperFirst(mode)} ${displaySource} ${direction} ${displayDestination}\n`,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
return sync(source, mode, {
|
|
29
|
+
...config,
|
|
30
|
+
// User provided includes/excludes can only be used in the main command
|
|
31
|
+
// because they are relative to the base directory, so we reset them.
|
|
32
|
+
exclude: [],
|
|
33
|
+
excludeGlob: ['.*', '.*/'],
|
|
34
|
+
include: ['.htpasswd'], // Make sure account passwords are synced.
|
|
35
|
+
includeGlob: [],
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const accountsPush = defineCommand({ run: () => syncAccounts('push') })
|
|
40
|
+
export const accountsPull = defineCommand({ run: () => syncAccounts('pull') })
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { defineCommand } from 'citty'
|
|
2
|
+
import consola from 'consola'
|
|
3
|
+
import { colors } from 'consola/utils'
|
|
4
|
+
import { join } from 'path/posix'
|
|
5
|
+
import { loadConfig } from '../config'
|
|
6
|
+
import { sync } from '../sync'
|
|
7
|
+
import { getBranch, upperFirst } from '../utils'
|
|
8
|
+
|
|
9
|
+
const syncContent = async (mode: 'pull' | 'push') => {
|
|
10
|
+
const config = await loadConfig()
|
|
11
|
+
if (!config) return
|
|
12
|
+
|
|
13
|
+
const { content } = config.folderStructure
|
|
14
|
+
const source = `./${content}/`
|
|
15
|
+
|
|
16
|
+
const branch = getBranch()
|
|
17
|
+
const displaySource = colors.magenta(
|
|
18
|
+
`${source}${branch ? colors.cyan(` (${branch})`) : ''}`,
|
|
19
|
+
)
|
|
20
|
+
const displayDestination = colors.magenta(
|
|
21
|
+
join(config.host, config.remoteDir, source),
|
|
22
|
+
)
|
|
23
|
+
const direction = mode === 'pull' ? 'from' : 'to'
|
|
24
|
+
consola.log(
|
|
25
|
+
`🗂️ ${upperFirst(mode)} ${displaySource} ${direction} ${displayDestination}\n`,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
return sync(source, mode, {
|
|
29
|
+
...config,
|
|
30
|
+
// User provided includes/excludes can only be used in the main command
|
|
31
|
+
// because they are relative to the base directory, so we reset them.
|
|
32
|
+
exclude: [],
|
|
33
|
+
excludeGlob: ['.*', '.*/'],
|
|
34
|
+
include: [],
|
|
35
|
+
includeGlob: [],
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const contentPush = defineCommand({ run: () => syncContent('push') })
|
|
40
|
+
export const contentPull = defineCommand({ run: () => syncContent('pull') })
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { defineCommand } from 'citty'
|
|
2
|
+
import consola from 'consola'
|
|
3
|
+
import { colors } from 'consola/utils'
|
|
4
|
+
import { readFileSync } from 'fs'
|
|
5
|
+
import { join, relative } from 'path/posix'
|
|
6
|
+
import { cwd } from 'process'
|
|
7
|
+
import { loadConfig } from '../config'
|
|
8
|
+
import { cat } from '../lftp/cat'
|
|
9
|
+
import { sync } from '../sync'
|
|
10
|
+
import { getBranch } from '../utils'
|
|
11
|
+
import { accountsPull, accountsPush } from './accounts'
|
|
12
|
+
import { contentPull, contentPush } from './content'
|
|
13
|
+
|
|
14
|
+
export const main = defineCommand({
|
|
15
|
+
run: async ({ rawArgs, cmd }) => {
|
|
16
|
+
// Todo: find a cleaner way to prevent the main command from running when
|
|
17
|
+
// when a sub command is run.
|
|
18
|
+
const [firstArg] = rawArgs
|
|
19
|
+
const subCommands = Object.keys(cmd.subCommands ?? {})
|
|
20
|
+
const isSubCommand = subCommands.includes(firstArg)
|
|
21
|
+
if (isSubCommand) return
|
|
22
|
+
|
|
23
|
+
const config = await loadConfig()
|
|
24
|
+
if (!config) return
|
|
25
|
+
|
|
26
|
+
const { folderStructure } = config
|
|
27
|
+
const exclude = [
|
|
28
|
+
...config.exclude,
|
|
29
|
+
'^node_modules/',
|
|
30
|
+
`^${relative(cwd(), folderStructure.content)}`,
|
|
31
|
+
`^${relative(cwd(), folderStructure.media)}`,
|
|
32
|
+
`^${relative(cwd(), folderStructure.accounts)}`,
|
|
33
|
+
`^${relative(cwd(), folderStructure.sessions)}`,
|
|
34
|
+
`^${relative(cwd(), folderStructure.cache)}`,
|
|
35
|
+
]
|
|
36
|
+
const excludeGlob = [...config.excludeGlob, '.*', '.*/']
|
|
37
|
+
const include = config.include
|
|
38
|
+
const includeGlob = [...config.includeGlob, '.htaccess']
|
|
39
|
+
|
|
40
|
+
const branch = getBranch()
|
|
41
|
+
const displaySource = branch ? colors.cyan(` ${branch} `) : ' '
|
|
42
|
+
const displayDestination = colors.magenta(
|
|
43
|
+
join(config.host, config.remoteDir),
|
|
44
|
+
)
|
|
45
|
+
consola.log(`🚀 Deploy${displaySource}to ${displayDestination}\n`)
|
|
46
|
+
|
|
47
|
+
if (config.checkComposerLock) {
|
|
48
|
+
const localComposerLock = readFileSync('./composer.lock', {
|
|
49
|
+
encoding: 'utf-8',
|
|
50
|
+
})
|
|
51
|
+
const remoteComposerLock = cat('./composer.lock', config)
|
|
52
|
+
const skipVendor = localComposerLock === remoteComposerLock
|
|
53
|
+
if (skipVendor) {
|
|
54
|
+
exclude.push('^vendor/', '^kirby/')
|
|
55
|
+
consola.info('Skipping vendor\n')
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
await sync('./', 'push', {
|
|
60
|
+
...config,
|
|
61
|
+
exclude,
|
|
62
|
+
excludeGlob,
|
|
63
|
+
include,
|
|
64
|
+
includeGlob,
|
|
65
|
+
})
|
|
66
|
+
},
|
|
67
|
+
subCommands: {
|
|
68
|
+
['content-push']: contentPush,
|
|
69
|
+
['content-pull']: contentPull,
|
|
70
|
+
['accounts-push']: accountsPush,
|
|
71
|
+
['accounts-pull']: accountsPull,
|
|
72
|
+
},
|
|
73
|
+
})
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { loadConfig as load } from 'c12'
|
|
2
|
+
import consola from 'consola'
|
|
3
|
+
import { flatten, parse } from 'valibot'
|
|
4
|
+
import { Config, ConfigResolved, ConfigSchema } from './types'
|
|
5
|
+
|
|
6
|
+
export const loadConfig = async (): Promise<ConfigResolved | null> => {
|
|
7
|
+
let { config, configFile } = await load<Config>({
|
|
8
|
+
name: 'kirby-deploy',
|
|
9
|
+
dotenv: true,
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
if (!config) {
|
|
13
|
+
consola.error(new Error('no config file found'))
|
|
14
|
+
return null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Validate
|
|
18
|
+
try {
|
|
19
|
+
parse(ConfigSchema, config)
|
|
20
|
+
} catch (e: any) {
|
|
21
|
+
const issues = flatten<typeof ConfigSchema>(e).nested
|
|
22
|
+
const info = Object.entries(issues)
|
|
23
|
+
.map(([key, messages]) => ` - ${key} (${messages.join(', ')})`)
|
|
24
|
+
.join('\n')
|
|
25
|
+
consola.error(`Invalid properties in ${configFile}\n${info}`)
|
|
26
|
+
return null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Resolve shorthands
|
|
30
|
+
config.folderStructure ??= 'flat'
|
|
31
|
+
if (config.folderStructure === 'public') {
|
|
32
|
+
config.folderStructure = {
|
|
33
|
+
content: 'content',
|
|
34
|
+
media: 'public/media',
|
|
35
|
+
accounts: 'storage/accounts',
|
|
36
|
+
sessions: 'storage/sessions',
|
|
37
|
+
cache: 'storage/cache',
|
|
38
|
+
}
|
|
39
|
+
} else if (config.folderStructure === 'flat') {
|
|
40
|
+
config.folderStructure = {
|
|
41
|
+
content: 'content',
|
|
42
|
+
media: 'site/media',
|
|
43
|
+
accounts: 'site/accounts',
|
|
44
|
+
sessions: 'site/sessions',
|
|
45
|
+
cache: 'site/cache',
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
config = {
|
|
50
|
+
remoteDir: './',
|
|
51
|
+
dryRun: true,
|
|
52
|
+
parallel: 10,
|
|
53
|
+
checkComposerLock: true,
|
|
54
|
+
callWebhooks: true,
|
|
55
|
+
exclude: [],
|
|
56
|
+
excludeGlob: [],
|
|
57
|
+
include: [],
|
|
58
|
+
includeGlob: [],
|
|
59
|
+
...config,
|
|
60
|
+
lftpSettings: {
|
|
61
|
+
'ftp:ssl-force': true,
|
|
62
|
+
...config.lftpSettings,
|
|
63
|
+
},
|
|
64
|
+
lftpFlags: ['--parallel=10', '--dereference', ...(config.lftpFlags ?? [])],
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return config as ConfigResolved
|
|
68
|
+
}
|
package/src/index.ts
ADDED
package/src/lftp/cat.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { spawnSync } from 'child_process'
|
|
2
|
+
import consola from 'consola'
|
|
3
|
+
import { platform } from 'os'
|
|
4
|
+
import { ConfigResolved } from '../types'
|
|
5
|
+
|
|
6
|
+
export const cat = (
|
|
7
|
+
file: string,
|
|
8
|
+
{ host, user, password, lftpSettings }: ConfigResolved,
|
|
9
|
+
) => {
|
|
10
|
+
const commands = [
|
|
11
|
+
...Object.entries(lftpSettings).map(
|
|
12
|
+
([key, value]) => `set ${key} ${value}`,
|
|
13
|
+
),
|
|
14
|
+
`open ${host}`,
|
|
15
|
+
`user ${user} ${password}`,
|
|
16
|
+
`cat ${file}`,
|
|
17
|
+
'bye',
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
const isWindows = platform() === 'win32'
|
|
21
|
+
const child = isWindows
|
|
22
|
+
? spawnSync('wsl', ['lftp', '-c', commands.join('; ')], {
|
|
23
|
+
encoding: 'utf-8',
|
|
24
|
+
})
|
|
25
|
+
: spawnSync('lftp', ['-c', commands.join('; ')], { encoding: 'utf-8' })
|
|
26
|
+
|
|
27
|
+
if (child.stderr) consola.error(child.stderr)
|
|
28
|
+
return child.stdout
|
|
29
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import consola from 'consola'
|
|
2
|
+
import { colors } from 'consola/utils'
|
|
3
|
+
import { spawn } from 'node:child_process'
|
|
4
|
+
import { platform } from 'node:os'
|
|
5
|
+
import { ConfigResolved } from '../types'
|
|
6
|
+
|
|
7
|
+
interface Result {
|
|
8
|
+
hasChanges: boolean
|
|
9
|
+
hasErrors: boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const mirror = (
|
|
13
|
+
source: string,
|
|
14
|
+
destination: string,
|
|
15
|
+
flags: string[],
|
|
16
|
+
{ lftpSettings, host, user, password, verbose }: ConfigResolved,
|
|
17
|
+
): Promise<Result> => {
|
|
18
|
+
const commands = [
|
|
19
|
+
...Object.entries(lftpSettings).map(
|
|
20
|
+
([key, value]) => `set ${key} ${value}`,
|
|
21
|
+
),
|
|
22
|
+
`open ${host}`,
|
|
23
|
+
`user ${user} ${password}`, // mask credentials
|
|
24
|
+
`mirror ${flags.join(' ')} ${source} ${destination}`,
|
|
25
|
+
'bye',
|
|
26
|
+
]
|
|
27
|
+
const isWindows = platform() === 'win32'
|
|
28
|
+
const child = isWindows
|
|
29
|
+
? spawn('wsl', ['lftp', '-c', commands.join('; ')])
|
|
30
|
+
: spawn('lftp', ['-c', commands.join('; ')])
|
|
31
|
+
|
|
32
|
+
let hasErrors = false
|
|
33
|
+
let hasChanges = false
|
|
34
|
+
|
|
35
|
+
const handleData = (data: any) => {
|
|
36
|
+
if (verbose) consola.log(`${colors.bgBlue(' LFTP ')} ${data}\n`)
|
|
37
|
+
data
|
|
38
|
+
.toString()
|
|
39
|
+
.split('\n')
|
|
40
|
+
.forEach((line: string) => {
|
|
41
|
+
let match: RegExpMatchArray | null = null
|
|
42
|
+
if ((match = line.match(/Transferring file `(.*)'/))) {
|
|
43
|
+
hasChanges = true
|
|
44
|
+
consola.log(colors.blue(`→ ${match[1]}`))
|
|
45
|
+
} else if (
|
|
46
|
+
(match = line.match(/Removing old (?:file|directory) `(.*)'/))
|
|
47
|
+
) {
|
|
48
|
+
hasChanges = true
|
|
49
|
+
consola.log(colors.red(`⨯ ${match[1]}`))
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const handleError = (data: any) => {
|
|
55
|
+
consola.error(data.toString())
|
|
56
|
+
hasErrors = true
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return new Promise<Result>((resolve) => {
|
|
60
|
+
child.stdout.on('data', handleData)
|
|
61
|
+
child.stderr.on('data', handleError)
|
|
62
|
+
child.on('exit', () => resolve({ hasChanges, hasErrors }))
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const logMirror = (
|
|
67
|
+
source: string,
|
|
68
|
+
destination: string,
|
|
69
|
+
flags: string[],
|
|
70
|
+
{ lftpSettings, host }: ConfigResolved,
|
|
71
|
+
) => {
|
|
72
|
+
const commands = [
|
|
73
|
+
...Object.entries(lftpSettings).map(
|
|
74
|
+
([key, value]) => `set ${key} ${value}`,
|
|
75
|
+
),
|
|
76
|
+
`open ${host}`,
|
|
77
|
+
`user <user> <password>`, // mask credentials
|
|
78
|
+
`mirror ${flags.join(' ')} ${source} ${destination}`,
|
|
79
|
+
'bye',
|
|
80
|
+
]
|
|
81
|
+
consola.log(`\n${colors.bgBlue(' LFTP ')} ${commands.join('; ')}\n`)
|
|
82
|
+
}
|
package/src/sync.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import consola from 'consola'
|
|
2
|
+
import { join } from 'path/posix'
|
|
3
|
+
import { logMirror, mirror } from './lftp/mirror'
|
|
4
|
+
import { ConfigResolved } from './types'
|
|
5
|
+
import { callWebhook, confirm, upperFirst } from './utils'
|
|
6
|
+
|
|
7
|
+
export const sync = async (
|
|
8
|
+
source: string,
|
|
9
|
+
mode: 'pull' | 'push',
|
|
10
|
+
config: ConfigResolved,
|
|
11
|
+
): Promise<boolean> => {
|
|
12
|
+
const reverse = mode === 'push'
|
|
13
|
+
const targetName = mode === 'push' ? 'remote' : 'local'
|
|
14
|
+
const webhook = `${config.url}/plugin-kirby-deploy`
|
|
15
|
+
const destination =
|
|
16
|
+
source === './' ? config.remoteDir : `./${join(config.remoteDir, source)}`
|
|
17
|
+
|
|
18
|
+
const flags = [
|
|
19
|
+
'--continue',
|
|
20
|
+
'--only-newer',
|
|
21
|
+
'--overwrite',
|
|
22
|
+
'--use-cache',
|
|
23
|
+
'--delete',
|
|
24
|
+
'--verbose',
|
|
25
|
+
reverse && '--reverse',
|
|
26
|
+
...config.exclude.map((path: string) => `--exclude ${path}`),
|
|
27
|
+
...config.excludeGlob.map((path: string) => `--exclude-glob ${path}`),
|
|
28
|
+
...config.includeGlob.map((path: string) => `--include-glob ${path}`),
|
|
29
|
+
...config.include.map((path: string) => `--include ${path}`),
|
|
30
|
+
...config.lftpFlags,
|
|
31
|
+
].filter(Boolean) as string[]
|
|
32
|
+
|
|
33
|
+
if (config.verbose) {
|
|
34
|
+
logMirror(source, destination, flags, config)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (config.dryRun) {
|
|
38
|
+
consola.log('Review changes...')
|
|
39
|
+
consola.log('') // empty line
|
|
40
|
+
|
|
41
|
+
const { hasChanges } = await mirror(
|
|
42
|
+
source,
|
|
43
|
+
destination,
|
|
44
|
+
[...flags, '--dry-run'],
|
|
45
|
+
config,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
if (!hasChanges) {
|
|
49
|
+
consola.success(`${upperFirst(targetName)} already up to date`)
|
|
50
|
+
return false
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const shouldContinue = await confirm(`Apply changes to ${targetName}?`)
|
|
54
|
+
if (!shouldContinue) return false
|
|
55
|
+
consola.log('') // empty line
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
consola.log('Apply changes...\n')
|
|
59
|
+
|
|
60
|
+
// Make sure the finish hook is called even if an unexpected error occurs.
|
|
61
|
+
if (config.callWebhooks) await callWebhook(`${webhook}/start`, config.token)
|
|
62
|
+
let hasChanges, hasErrors
|
|
63
|
+
try {
|
|
64
|
+
;({ hasChanges, hasErrors } = await mirror(
|
|
65
|
+
source,
|
|
66
|
+
destination,
|
|
67
|
+
flags,
|
|
68
|
+
config,
|
|
69
|
+
))
|
|
70
|
+
} catch (e) {
|
|
71
|
+
consola.error(e)
|
|
72
|
+
return false
|
|
73
|
+
} finally {
|
|
74
|
+
if (config.callWebhooks) {
|
|
75
|
+
await callWebhook(`${webhook}/finish`, config.token)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (!hasChanges) {
|
|
80
|
+
consola.success(`${upperFirst(targetName)} already up to date`)
|
|
81
|
+
return false
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
consola.log('') // empty line
|
|
85
|
+
consola.success(
|
|
86
|
+
hasErrors ? 'All done (but with errors, see output above)!' : 'All done!',
|
|
87
|
+
)
|
|
88
|
+
return true
|
|
89
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Output,
|
|
3
|
+
any,
|
|
4
|
+
array,
|
|
5
|
+
boolean,
|
|
6
|
+
literal,
|
|
7
|
+
number,
|
|
8
|
+
object,
|
|
9
|
+
optional,
|
|
10
|
+
record,
|
|
11
|
+
string,
|
|
12
|
+
union,
|
|
13
|
+
} from 'valibot'
|
|
14
|
+
|
|
15
|
+
export const FolderStructureSchema = object({
|
|
16
|
+
content: string(),
|
|
17
|
+
media: string(),
|
|
18
|
+
accounts: string(),
|
|
19
|
+
sessions: string(),
|
|
20
|
+
cache: string(),
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
export type FolderStructure = Output<typeof FolderStructureSchema>
|
|
24
|
+
|
|
25
|
+
export const ConfigSchema = object({
|
|
26
|
+
host: string(),
|
|
27
|
+
user: string(),
|
|
28
|
+
password: string(),
|
|
29
|
+
url: optional(string()),
|
|
30
|
+
token: optional(string()),
|
|
31
|
+
remoteDir: optional(string()),
|
|
32
|
+
folderStructure: optional(
|
|
33
|
+
union([literal('flat'), literal('public'), FolderStructureSchema]),
|
|
34
|
+
),
|
|
35
|
+
checkComposerLock: optional(boolean()),
|
|
36
|
+
callWebhooks: optional(boolean()),
|
|
37
|
+
dryRun: optional(boolean()),
|
|
38
|
+
verbose: optional(boolean()),
|
|
39
|
+
parallel: optional(number()),
|
|
40
|
+
exclude: optional(array(string())),
|
|
41
|
+
excludeGlob: optional(array(string())),
|
|
42
|
+
include: optional(array(string())),
|
|
43
|
+
includeGlob: optional(array(string())),
|
|
44
|
+
lftpSettings: optional(record(string(), any())),
|
|
45
|
+
lftpFlags: optional(array(string())),
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
export type Config = Output<typeof ConfigSchema>
|
|
49
|
+
|
|
50
|
+
export type ConfigResolved = Required<Omit<Config, 'folderStructure'>> & {
|
|
51
|
+
folderStructure: FolderStructure
|
|
52
|
+
}
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import consola from 'consola'
|
|
2
|
+
import { colors } from 'consola/utils'
|
|
3
|
+
import { spawnSync } from 'node:child_process'
|
|
4
|
+
import { existsSync } from 'node:fs'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { cwd, stdin as input, stdout as output } from 'node:process'
|
|
7
|
+
import * as readline from 'node:readline'
|
|
8
|
+
|
|
9
|
+
export const upperFirst = (string: string) =>
|
|
10
|
+
string.charAt(0).toUpperCase() + string.slice(1)
|
|
11
|
+
|
|
12
|
+
export const isGit = () => existsSync(join(cwd(), '.git'))
|
|
13
|
+
|
|
14
|
+
export const getBranch = (): string | undefined => {
|
|
15
|
+
if (!isGit()) return
|
|
16
|
+
const { stderr, stdout } = spawnSync('git', ['branch', '--show-current'], {
|
|
17
|
+
encoding: 'utf-8',
|
|
18
|
+
})
|
|
19
|
+
if (stderr) {
|
|
20
|
+
consola.log(stderr)
|
|
21
|
+
return
|
|
22
|
+
}
|
|
23
|
+
return stdout.trim()
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const confirm = (question: string): Promise<boolean> =>
|
|
27
|
+
new Promise((resolve) => {
|
|
28
|
+
const rl = readline.createInterface({ input, output })
|
|
29
|
+
const formattedQuestion = `\n${question} ${colors.yellow('(y/n)')} `
|
|
30
|
+
rl.question(formattedQuestion, (answer) => {
|
|
31
|
+
rl.close()
|
|
32
|
+
const hasAgreed = ['yes', 'y'].includes(answer.toLowerCase())
|
|
33
|
+
resolve(hasAgreed)
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
export const callWebhook = async (
|
|
38
|
+
url: string,
|
|
39
|
+
token: string,
|
|
40
|
+
): Promise<boolean> => {
|
|
41
|
+
const result = await fetch(url, {
|
|
42
|
+
headers: {
|
|
43
|
+
'Content-Type': 'application/json',
|
|
44
|
+
Authorization: `Bearer ${token}`,
|
|
45
|
+
},
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
if (result.status < 200 || result.status >= 300) {
|
|
49
|
+
consola.error(`Failed to call webhook ${url}, status: ${result.status}`)
|
|
50
|
+
return false
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return true
|
|
54
|
+
}
|