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 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);
@@ -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
@@ -0,0 +1,5 @@
1
+ // src/index.ts
2
+ var defineConfig = (config) => config;
3
+ export {
4
+ defineConfig
5
+ };
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,6 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { runMain } from 'citty'
4
+ import { main } from './commands/main'
5
+
6
+ runMain(main)
@@ -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
@@ -0,0 +1,3 @@
1
+ import { Config } from './types'
2
+
3
+ export const defineConfig = (config: Config) => config
@@ -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
+ }