@kradle/cli 0.2.4 → 0.2.6

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/README.md CHANGED
@@ -111,6 +111,19 @@ List all challenges (local and cloud):
111
111
  kradle challenge list
112
112
  ```
113
113
 
114
+ ### Pull Challenge
115
+
116
+ Download a challenge from the cloud and extract source files locally:
117
+
118
+ ```bash
119
+ kradle challenge pull # Interactive selection
120
+ kradle challenge pull <challenge-name> # Pull your own challenge
121
+ kradle challenge pull <team-name>:<challenge-name> # Pull a public challenge from another team
122
+ kradle challenge pull <challenge-name> --yes # Skip confirmation when overwriting
123
+ ```
124
+
125
+ This downloads the challenge tarball, extracts `challenge.ts` and `config.ts`, and builds the datapack locally.
126
+
114
127
  ### Watch Challenge
115
128
 
116
129
  Watch a challenge for changes and auto-rebuild/upload:
@@ -0,0 +1,15 @@
1
+ import { Command } from "@oclif/core";
2
+ export default class Pull extends Command {
3
+ static description: string;
4
+ static examples: string[];
5
+ static args: {
6
+ challengeSlug: import("@oclif/core/interfaces").Arg<string | undefined>;
7
+ };
8
+ static flags: {
9
+ "api-key": import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
10
+ "api-url": import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
11
+ "challenges-path": import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
12
+ yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
13
+ };
14
+ run(): Promise<void>;
15
+ }
@@ -0,0 +1,182 @@
1
+ import { existsSync } from "node:fs";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { Command, Flags } from "@oclif/core";
5
+ import enquirer from "enquirer";
6
+ import { Listr } from "listr2";
7
+ import pc from "picocolors";
8
+ import * as tar from "tar";
9
+ import { ApiClient } from "../../lib/api-client.js";
10
+ import { extractShortSlug, getChallengeSlugArgument } from "../../lib/arguments.js";
11
+ import { Challenge, SOURCE_FOLDER } from "../../lib/challenge.js";
12
+ import { getConfigFlags } from "../../lib/flags.js";
13
+ export default class Pull extends Command {
14
+ static description = "Pull a challenge from the cloud and extract source files locally";
15
+ static examples = [
16
+ "<%= config.bin %> <%= command.id %>",
17
+ "<%= config.bin %> <%= command.id %> my-challenge",
18
+ "<%= config.bin %> <%= command.id %> username:my-challenge",
19
+ "<%= config.bin %> <%= command.id %> my-challenge --yes",
20
+ ];
21
+ static args = {
22
+ challengeSlug: getChallengeSlugArgument({
23
+ description: "Challenge slug to pull (interactive selection if omitted)",
24
+ required: false,
25
+ allowTeam: true,
26
+ }),
27
+ };
28
+ static flags = {
29
+ yes: Flags.boolean({ char: "y", description: "Skip confirmation prompts", default: false }),
30
+ ...getConfigFlags("api-key", "api-url", "challenges-path"),
31
+ };
32
+ async run() {
33
+ const { args, flags } = await this.parse(Pull);
34
+ const api = new ApiClient(flags["api-url"], flags["api-key"]);
35
+ let challengeSlug = args.challengeSlug;
36
+ if (!challengeSlug) {
37
+ const [kradleChallenges, cloudChallenges, localChallenges] = await Promise.all([
38
+ api.listKradleChallenges(),
39
+ api.listChallenges(),
40
+ Challenge.getLocalChallenges(),
41
+ ]);
42
+ const allChallenges = [...kradleChallenges, ...cloudChallenges];
43
+ if (allChallenges.length === 0) {
44
+ this.error(pc.red("No challenges found in the cloud."));
45
+ }
46
+ const localSet = new Set(localChallenges);
47
+ const slugs = allChallenges.map((c) => c.slug).sort();
48
+ const choices = slugs.map((slug) => {
49
+ const shortSlug = extractShortSlug(slug);
50
+ const isLocal = localSet.has(shortSlug);
51
+ const status = isLocal ? pc.yellow(" (local)") : "";
52
+ return {
53
+ name: slug,
54
+ message: `${slug}${status}`,
55
+ };
56
+ });
57
+ const response = await enquirer.prompt({
58
+ type: "select",
59
+ name: "challenge",
60
+ message: "Select a challenge to pull",
61
+ choices,
62
+ });
63
+ challengeSlug = response.challenge;
64
+ }
65
+ const shortSlug = extractShortSlug(challengeSlug);
66
+ const challenge = new Challenge(shortSlug, flags["challenges-path"]);
67
+ const existsInCloud = await api.challengeExists(challengeSlug);
68
+ if (!existsInCloud) {
69
+ this.error(pc.red(`Challenge "${challengeSlug}" does not exist in the cloud.`));
70
+ }
71
+ const existsLocally = existsSync(challenge.challengeDir);
72
+ const hasLocalFiles = existsLocally && (existsSync(challenge.challengePath) || existsSync(challenge.configPath));
73
+ if (hasLocalFiles && !flags.yes) {
74
+ this.log(pc.bold(`\nChallenge: ${pc.cyan(challenge.shortSlug)}`));
75
+ this.log(` Local folder exists: ${pc.yellow(challenge.challengeDir)}`);
76
+ if (existsSync(challenge.challengePath)) {
77
+ this.log(` challenge.ts: ${pc.yellow("exists (will be overwritten)")}`);
78
+ }
79
+ if (existsSync(challenge.configPath)) {
80
+ this.log(` config.ts: ${pc.yellow("exists (will be overwritten)")}`);
81
+ }
82
+ this.log("");
83
+ try {
84
+ const response = await enquirer.prompt({
85
+ type: "confirm",
86
+ name: "confirm",
87
+ message: `Overwrite local challenge files? ${pc.red("This cannot be undone.")}`,
88
+ initial: false,
89
+ });
90
+ if (!response.confirm) {
91
+ this.log(pc.yellow("Pull cancelled"));
92
+ return;
93
+ }
94
+ }
95
+ catch {
96
+ this.log(pc.yellow("\nPull cancelled"));
97
+ return;
98
+ }
99
+ }
100
+ const tempTarballPath = path.join(flags["challenges-path"], `${challenge.shortSlug}-pull-temp.tar.gz`);
101
+ const tasks = new Listr([
102
+ {
103
+ title: "Downloading challenge",
104
+ task: async (_, task) => {
105
+ const { downloadUrl } = await api.getChallengeDownloadUrl(challengeSlug);
106
+ const response = await fetch(downloadUrl);
107
+ if (!response.ok) {
108
+ throw new Error(`Failed to download: ${response.status} ${response.statusText}`);
109
+ }
110
+ const buffer = await response.arrayBuffer();
111
+ await fs.mkdir(path.dirname(tempTarballPath), { recursive: true });
112
+ await fs.writeFile(tempTarballPath, Buffer.from(buffer));
113
+ task.title = "Downloaded challenge";
114
+ },
115
+ },
116
+ {
117
+ title: "Creating challenge directory",
118
+ task: async (_, task) => {
119
+ await fs.mkdir(challenge.challengeDir, { recursive: true });
120
+ task.title = `Created directory: ${challenge.challengeDir}`;
121
+ },
122
+ },
123
+ {
124
+ title: "Extracting source files",
125
+ task: async (_, task) => {
126
+ const filesToExtract = [`${SOURCE_FOLDER}/challenge.ts`, `${SOURCE_FOLDER}/config.ts`];
127
+ const tempExtractDir = path.join(flags["challenges-path"], `${challenge.shortSlug}-extract-temp`);
128
+ await fs.mkdir(tempExtractDir, { recursive: true });
129
+ try {
130
+ await tar.extract({
131
+ file: tempTarballPath,
132
+ cwd: tempExtractDir,
133
+ filter: (entryPath) => filesToExtract.some((f) => entryPath === f),
134
+ });
135
+ const srcChallengeTs = path.join(tempExtractDir, SOURCE_FOLDER, "challenge.ts");
136
+ const srcConfigTs = path.join(tempExtractDir, SOURCE_FOLDER, "config.ts");
137
+ let extractedCount = 0;
138
+ if (existsSync(srcChallengeTs)) {
139
+ await fs.copyFile(srcChallengeTs, challenge.challengePath);
140
+ extractedCount++;
141
+ }
142
+ if (existsSync(srcConfigTs)) {
143
+ await fs.copyFile(srcConfigTs, challenge.configPath);
144
+ extractedCount++;
145
+ }
146
+ if (extractedCount === 0) {
147
+ throw new Error(`No source files found in tarball. The challenge may not have been built with source files.`);
148
+ }
149
+ task.title = `Extracted ${extractedCount} source file(s)`;
150
+ }
151
+ finally {
152
+ await fs.rm(tempExtractDir, { recursive: true, force: true });
153
+ }
154
+ },
155
+ },
156
+ {
157
+ title: "Cleaning up",
158
+ task: async (_, task) => {
159
+ await fs.rm(tempTarballPath, { force: true });
160
+ task.title = "Cleaned up temporary files";
161
+ },
162
+ },
163
+ {
164
+ title: "Building datapack",
165
+ task: async (_, task) => {
166
+ await challenge.build(true);
167
+ task.title = "Built datapack";
168
+ },
169
+ },
170
+ ]);
171
+ try {
172
+ await tasks.run();
173
+ this.log(pc.green(`\nāœ“ Challenge pulled: ${challenge.shortSlug}`));
174
+ this.log(pc.dim(` → challenge.ts: ${challenge.challengePath}`));
175
+ this.log(pc.dim(` → config.ts: ${challenge.configPath}`));
176
+ }
177
+ catch (error) {
178
+ await fs.rm(tempTarballPath, { force: true }).catch(() => { });
179
+ this.error(pc.red(`Pull failed: ${error instanceof Error ? error.message : String(error)}`));
180
+ }
181
+ }
182
+ }
@@ -0,0 +1,14 @@
1
+ import { Command } from "@oclif/core";
2
+ export default class Delete extends Command {
3
+ static description: string;
4
+ static examples: string[];
5
+ static args: {
6
+ worldSlug: import("@oclif/core/interfaces").Arg<string>;
7
+ };
8
+ static flags: {
9
+ "api-key": import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
10
+ "api-url": import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
11
+ yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
12
+ };
13
+ run(): Promise<void>;
14
+ }
@@ -0,0 +1,80 @@
1
+ import fs from "node:fs/promises";
2
+ import { Command, Flags } from "@oclif/core";
3
+ import enquirer from "enquirer";
4
+ import pc from "picocolors";
5
+ import { ApiClient } from "../../lib/api-client.js";
6
+ import { getWorldSlugArgument } from "../../lib/arguments.js";
7
+ import { getConfigFlags } from "../../lib/flags.js";
8
+ import { World } from "../../lib/world.js";
9
+ export default class Delete extends Command {
10
+ static description = "Delete a world locally, from the cloud, or both";
11
+ static examples = [
12
+ "<%= config.bin %> <%= command.id %> my-world",
13
+ "<%= config.bin %> <%= command.id %> my-world --yes",
14
+ ];
15
+ static args = {
16
+ worldSlug: getWorldSlugArgument({ description: "World slug to delete" }),
17
+ };
18
+ static flags = {
19
+ yes: Flags.boolean({ char: "y", description: "Skip confirmation prompts", default: false }),
20
+ ...getConfigFlags("api-key", "api-url"),
21
+ };
22
+ async run() {
23
+ const { args, flags } = await this.parse(Delete);
24
+ const api = new ApiClient(flags["api-url"], flags["api-key"]);
25
+ const world = new World(args.worldSlug);
26
+ const existsLocally = world.exists();
27
+ const existsInCloud = await api.worldExists(args.worldSlug);
28
+ if (!existsLocally && !existsInCloud) {
29
+ this.error(pc.red(`World "${args.worldSlug}" does not exist locally or in the cloud.`));
30
+ }
31
+ this.log(pc.bold(`\nWorld: ${pc.cyan(args.worldSlug)}`));
32
+ if (existsLocally) {
33
+ this.log(` šŸ“ Local: ${pc.green("exists")} at ${world.worldDir}`);
34
+ }
35
+ else {
36
+ this.log(` šŸ“ Local: ${pc.dim("not found")}`);
37
+ }
38
+ if (existsInCloud) {
39
+ this.log(` ☁ Cloud: ${pc.green("exists")}`);
40
+ }
41
+ else {
42
+ this.log(` ☁ Cloud: ${pc.dim("not found")}`);
43
+ }
44
+ this.log("");
45
+ let deleteLocal = existsLocally;
46
+ let deleteCloud = existsInCloud;
47
+ if (!flags.yes) {
48
+ if (existsLocally) {
49
+ const response = await enquirer.prompt({
50
+ type: "confirm",
51
+ name: "confirm",
52
+ message: `Delete local world files? ${pc.red("This cannot be undone.")}`,
53
+ initial: true,
54
+ });
55
+ deleteLocal = response.confirm;
56
+ }
57
+ if (existsInCloud) {
58
+ const response = await enquirer.prompt({
59
+ type: "confirm",
60
+ name: "confirm",
61
+ message: `Delete world from cloud? ${pc.red("This cannot be undone.")}`,
62
+ initial: true,
63
+ });
64
+ deleteCloud = response.confirm;
65
+ }
66
+ }
67
+ if (!deleteLocal && !deleteCloud) {
68
+ this.log(pc.yellow("āœ— Nothing deleted"));
69
+ return;
70
+ }
71
+ if (deleteLocal) {
72
+ await fs.rm(world.worldDir, { recursive: true, force: true });
73
+ this.log(pc.green(`āœ“ Deleted local: ${world.worldDir}`));
74
+ }
75
+ if (deleteCloud) {
76
+ await api.deleteWorld(args.worldSlug);
77
+ this.log(pc.green(`āœ“ Deleted from cloud: ${args.worldSlug}`));
78
+ }
79
+ }
80
+ }
@@ -0,0 +1,14 @@
1
+ import { Command } from "@oclif/core";
2
+ export default class Import extends Command {
3
+ static description: string;
4
+ static examples: string[];
5
+ static args: {
6
+ path: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
7
+ };
8
+ static flags: {
9
+ "api-key": import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
10
+ "api-url": import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
11
+ as: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
12
+ };
13
+ run(): Promise<void>;
14
+ }
@@ -0,0 +1,116 @@
1
+ import { existsSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { Args, Command, Flags } from "@oclif/core";
4
+ import enquirer from "enquirer";
5
+ import { Listr } from "listr2";
6
+ import pc from "picocolors";
7
+ import { ApiClient } from "../../lib/api-client.js";
8
+ import { getConfigFlags } from "../../lib/flags.js";
9
+ import { World } from "../../lib/world.js";
10
+ export default class Import extends Command {
11
+ static description = "Import a Minecraft world folder and package it as a tarball";
12
+ static examples = [
13
+ "<%= config.bin %> <%= command.id %> ~/minecraft/saves/MyWorld",
14
+ "<%= config.bin %> <%= command.id %> ~/minecraft/saves/MyWorld --as my-world",
15
+ ];
16
+ static args = {
17
+ path: Args.string({
18
+ description: "Path to the Minecraft world folder",
19
+ required: true,
20
+ }),
21
+ };
22
+ static flags = {
23
+ as: Flags.string({
24
+ description: "Slug for the world (defaults to folder name converted to slug)",
25
+ }),
26
+ ...getConfigFlags("api-key", "api-url"),
27
+ };
28
+ async run() {
29
+ const { args, flags } = await this.parse(Import);
30
+ const sourcePath = path.resolve(args.path);
31
+ if (!existsSync(sourcePath)) {
32
+ this.error(pc.red(`Source path does not exist: ${sourcePath}`));
33
+ }
34
+ const levelDatPath = path.join(sourcePath, "level.dat");
35
+ if (!existsSync(levelDatPath)) {
36
+ this.error(pc.red(`Invalid Minecraft world: level.dat not found in ${sourcePath}`));
37
+ }
38
+ let slug = flags.as;
39
+ if (!slug) {
40
+ const folderName = path.basename(sourcePath);
41
+ const suggestedSlug = World.toSlug(folderName);
42
+ const response = await enquirer.prompt({
43
+ type: "input",
44
+ name: "slug",
45
+ message: "World slug:",
46
+ initial: suggestedSlug,
47
+ validate: (input) => {
48
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(input)) {
49
+ return "Slug must be lowercase alphanumeric with hyphens, not starting/ending with hyphen";
50
+ }
51
+ return true;
52
+ },
53
+ });
54
+ slug = response.slug;
55
+ }
56
+ const world = new World(slug);
57
+ const isReimport = world.exists();
58
+ if (isReimport) {
59
+ this.log(`World "${slug}" already exists. Re-import will update the tarball but preserve config.ts.`);
60
+ }
61
+ const api = new ApiClient(flags["api-url"], flags["api-key"]);
62
+ const tasks = new Listr([
63
+ {
64
+ title: "Creating world directory",
65
+ enabled: () => !isReimport,
66
+ task: async (_ctx, task) => {
67
+ const config = {
68
+ name: slug,
69
+ description: "no description",
70
+ visibility: "private",
71
+ domain: "minecraft",
72
+ };
73
+ await World.createLocal(slug, config);
74
+ task.title = `Created worlds/${slug}/`;
75
+ },
76
+ },
77
+ {
78
+ title: isReimport ? "Updating tarball" : "Packaging world as tarball",
79
+ task: async (_ctx, task) => {
80
+ await World.createTarballFrom(sourcePath, slug);
81
+ task.title = isReimport ? `Updated worlds/${slug}/world.tar.gz` : `Created worlds/${slug}/world.tar.gz`;
82
+ },
83
+ },
84
+ {
85
+ title: "Creating world in cloud",
86
+ enabled: () => !isReimport,
87
+ task: async (_ctx, task) => {
88
+ const exists = await api.worldExists(slug);
89
+ if (exists) {
90
+ task.title = "World already exists in cloud";
91
+ return;
92
+ }
93
+ const config = {
94
+ name: slug,
95
+ visibility: "private",
96
+ domain: "minecraft",
97
+ };
98
+ await api.createWorld(slug, config);
99
+ task.title = "Created world in cloud";
100
+ },
101
+ },
102
+ ]);
103
+ await tasks.run();
104
+ if (isReimport) {
105
+ this.log(pc.green(`\nāœ“ World re-imported: ${slug}`));
106
+ this.log(pc.dim(` → world.tar.gz updated (config.ts preserved)`));
107
+ this.log(pc.dim(`\nRun ${pc.cyan(`kradle world push ${slug}`)} to upload to cloud.`));
108
+ }
109
+ else {
110
+ this.log(pc.green(`\nāœ“ World imported: ${slug}`));
111
+ this.log(pc.dim(` → config.ts: ${world.configPath}`));
112
+ this.log(pc.dim(` → world.tar.gz: ${world.tarballPath}`));
113
+ this.log(pc.dim(`\nRun ${pc.cyan(`kradle world push ${slug}`)} to upload to cloud.`));
114
+ }
115
+ }
116
+ }
@@ -0,0 +1,10 @@
1
+ import { Command } from "@oclif/core";
2
+ export default class List extends Command {
3
+ static description: string;
4
+ static examples: string[];
5
+ static flags: {
6
+ "api-key": import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
7
+ "api-url": import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
8
+ };
9
+ run(): Promise<void>;
10
+ }
@@ -0,0 +1,46 @@
1
+ import { Command } from "@oclif/core";
2
+ import pc from "picocolors";
3
+ import { ApiClient } from "../../lib/api-client.js";
4
+ import { getConfigFlags } from "../../lib/flags.js";
5
+ import { World } from "../../lib/world.js";
6
+ export default class List extends Command {
7
+ static description = "List all worlds (local and cloud)";
8
+ static examples = ["<%= config.bin %> <%= command.id %>"];
9
+ static flags = {
10
+ ...getConfigFlags("api-key", "api-url"),
11
+ };
12
+ async run() {
13
+ const { flags } = await this.parse(List);
14
+ const api = new ApiClient(flags["api-url"], flags["api-key"]);
15
+ this.log(pc.blue(">> Loading worlds..."));
16
+ const [cloudWorlds, localWorlds, human] = await Promise.all([
17
+ api.listWorlds(),
18
+ World.getLocalWorlds(),
19
+ api.getHuman(),
20
+ ]);
21
+ const cloudMap = new Map(cloudWorlds.map((w) => [w.slug, w]));
22
+ const allSlugs = new Set([...cloudMap.keys(), ...localWorlds.map((slug) => `${human.username}:${slug}`)]);
23
+ this.log(pc.bold("\nWorlds:\n"));
24
+ this.log(`${"Status".padEnd(15)} ${"Slug".padEnd(40)} ${"Name".padEnd(30)}`);
25
+ this.log("-".repeat(90));
26
+ for (const slug of Array.from(allSlugs).sort()) {
27
+ const inCloud = cloudMap.has(slug);
28
+ const shortSlug = slug.includes(":") ? slug.split(":")[1] : slug;
29
+ const inLocal = localWorlds.includes(shortSlug);
30
+ let status;
31
+ if (inCloud && inLocal) {
32
+ status = pc.green("āœ“ synced");
33
+ }
34
+ else if (inCloud) {
35
+ status = pc.yellow("☁ cloud only");
36
+ }
37
+ else {
38
+ status = pc.blue("⊔ local only");
39
+ }
40
+ const cloudWorld = cloudMap.get(slug);
41
+ const name = cloudWorld?.name || "-";
42
+ this.log(`${status.padEnd(24)} ${slug.padEnd(40)} ${name.padEnd(30)}`);
43
+ }
44
+ this.log(pc.dim(`\nTotal: ${allSlugs.size} worlds`));
45
+ }
46
+ }
@@ -0,0 +1,14 @@
1
+ import { Command } from "@oclif/core";
2
+ export default class Pull extends Command {
3
+ static description: string;
4
+ static examples: string[];
5
+ static args: {
6
+ worldSlug: import("@oclif/core/interfaces").Arg<string | undefined>;
7
+ };
8
+ static flags: {
9
+ "api-key": import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
10
+ "api-url": import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
11
+ yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
12
+ };
13
+ run(): Promise<void>;
14
+ }
@@ -0,0 +1,135 @@
1
+ import { existsSync } from "node:fs";
2
+ import fs from "node:fs/promises";
3
+ import { Command, Flags } from "@oclif/core";
4
+ import enquirer from "enquirer";
5
+ import { Listr } from "listr2";
6
+ import pc from "picocolors";
7
+ import { ApiClient } from "../../lib/api-client.js";
8
+ import { extractShortSlug, getWorldSlugArgument } from "../../lib/arguments.js";
9
+ import { getConfigFlags } from "../../lib/flags.js";
10
+ import { World } from "../../lib/world.js";
11
+ export default class Pull extends Command {
12
+ static description = "Download a world from the cloud";
13
+ static examples = [
14
+ "<%= config.bin %> <%= command.id %>",
15
+ "<%= config.bin %> <%= command.id %> my-world",
16
+ "<%= config.bin %> <%= command.id %> username:my-world",
17
+ "<%= config.bin %> <%= command.id %> my-world --yes",
18
+ ];
19
+ static args = {
20
+ worldSlug: getWorldSlugArgument({
21
+ description: "World slug to pull (interactive selection if omitted)",
22
+ required: false,
23
+ allowTeam: true,
24
+ }),
25
+ };
26
+ static flags = {
27
+ yes: Flags.boolean({ char: "y", description: "Skip confirmation prompts", default: false }),
28
+ ...getConfigFlags("api-key", "api-url"),
29
+ };
30
+ async run() {
31
+ const { args, flags } = await this.parse(Pull);
32
+ const api = new ApiClient(flags["api-url"], flags["api-key"]);
33
+ let worldSlug = args.worldSlug;
34
+ if (!worldSlug) {
35
+ const [kradleWorlds, cloudWorlds, localWorlds] = await Promise.all([
36
+ api.listKradleWorlds(),
37
+ api.listWorlds(),
38
+ World.getLocalWorlds(),
39
+ ]);
40
+ const allWorlds = [...kradleWorlds, ...cloudWorlds];
41
+ if (allWorlds.length === 0) {
42
+ this.error(pc.red("No worlds found in the cloud."));
43
+ }
44
+ const localSet = new Set(localWorlds);
45
+ const choices = allWorlds
46
+ .map((w) => w.slug)
47
+ .toSorted()
48
+ .map((slug) => {
49
+ const shortSlug = extractShortSlug(slug);
50
+ const isLocal = localSet.has(shortSlug);
51
+ const status = isLocal ? pc.yellow(" (local)") : "";
52
+ return {
53
+ name: slug,
54
+ message: `${slug}${status}`,
55
+ };
56
+ });
57
+ const response = await enquirer.prompt({
58
+ type: "select",
59
+ name: "world",
60
+ message: "Select a world to pull",
61
+ choices,
62
+ });
63
+ worldSlug = response.world;
64
+ }
65
+ const shortSlug = extractShortSlug(worldSlug);
66
+ const world = new World(shortSlug);
67
+ const existsInCloud = await api.worldExists(worldSlug);
68
+ if (!existsInCloud) {
69
+ this.error(pc.red(`World "${worldSlug}" does not exist in the cloud.`));
70
+ }
71
+ const existsLocally = world.exists();
72
+ if (existsLocally && !flags.yes) {
73
+ this.log(pc.bold(`\nWorld: ${pc.cyan(worldSlug)}`));
74
+ this.log(` šŸ“ Local folder exists: ${pc.yellow(world.worldDir)}`);
75
+ if (world.tarballExists()) {
76
+ this.log(` šŸ“¦ world.tar.gz: ${pc.yellow("exists (will be overwritten)")}`);
77
+ }
78
+ if (existsSync(world.configPath)) {
79
+ this.log(` šŸ“„ config.ts: ${pc.yellow("exists (will be overwritten)")}`);
80
+ }
81
+ this.log("");
82
+ const confirmResponse = await enquirer.prompt({
83
+ type: "confirm",
84
+ name: "confirm",
85
+ message: `Overwrite local world files? ${pc.red("This cannot be undone.")}`,
86
+ initial: false,
87
+ });
88
+ if (!confirmResponse.confirm) {
89
+ this.log(pc.yellow("āœ— Pull cancelled"));
90
+ return;
91
+ }
92
+ }
93
+ const cloudWorld = await api.getWorld(worldSlug);
94
+ const tasks = new Listr([
95
+ {
96
+ title: "Creating world directory",
97
+ task: async () => {
98
+ await fs.mkdir(world.worldDir, { recursive: true });
99
+ },
100
+ },
101
+ {
102
+ title: "Downloading world",
103
+ task: async (_ctx, task) => {
104
+ const { downloadUrl } = await api.getWorldDownloadUrl(worldSlug);
105
+ const response = await fetch(downloadUrl);
106
+ if (!response.ok) {
107
+ throw new Error(`Failed to download: ${response.status} ${response.statusText}`);
108
+ }
109
+ const buffer = await response.arrayBuffer();
110
+ await fs.writeFile(world.tarballPath, Buffer.from(buffer));
111
+ task.title = "Downloaded world";
112
+ },
113
+ },
114
+ {
115
+ title: "Creating config.ts",
116
+ task: async (_ctx, task) => {
117
+ const config = {
118
+ name: cloudWorld.name,
119
+ description: cloudWorld.description ?? "no description",
120
+ visibility: cloudWorld.visibility,
121
+ domain: "minecraft",
122
+ };
123
+ const configContent = `export const config = ${JSON.stringify(config, null, "\t").replace(/"([a-zA-Z_][a-zA-Z0-9_]*)"\s*:/g, "$1:")};
124
+ `;
125
+ await fs.writeFile(world.configPath, configContent);
126
+ task.title = "Created config.ts";
127
+ },
128
+ },
129
+ ]);
130
+ await tasks.run();
131
+ this.log(pc.green(`\nāœ“ World pulled: ${worldSlug}`));
132
+ this.log(pc.dim(` → config.ts: ${world.configPath}`));
133
+ this.log(pc.dim(` → world.tar.gz: ${world.tarballPath}`));
134
+ }
135
+ }
@@ -0,0 +1,13 @@
1
+ import { Command } from "@oclif/core";
2
+ export default class Push extends Command {
3
+ static description: string;
4
+ static examples: string[];
5
+ static args: {
6
+ worldSlug: import("@oclif/core/interfaces").Arg<string>;
7
+ };
8
+ static flags: {
9
+ "api-key": import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
10
+ "api-url": import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
11
+ };
12
+ run(): Promise<void>;
13
+ }