@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.
@@ -0,0 +1,64 @@
1
+ import { Command } from "@oclif/core";
2
+ import { Listr } from "listr2";
3
+ import pc from "picocolors";
4
+ import { ApiClient } from "../../lib/api-client.js";
5
+ import { getWorldSlugArgument } from "../../lib/arguments.js";
6
+ import { getConfigFlags } from "../../lib/flags.js";
7
+ import { World } from "../../lib/world.js";
8
+ export default class Push extends Command {
9
+ static description = "Upload a world (config + tarball) to the cloud";
10
+ static examples = ["<%= config.bin %> <%= command.id %> my-world"];
11
+ static args = {
12
+ worldSlug: getWorldSlugArgument({ description: "World slug to push" }),
13
+ };
14
+ static flags = {
15
+ ...getConfigFlags("api-key", "api-url"),
16
+ };
17
+ async run() {
18
+ const { args, flags } = await this.parse(Push);
19
+ const world = new World(args.worldSlug);
20
+ if (!world.exists()) {
21
+ this.error(pc.red(`World "${args.worldSlug}" does not exist locally.`));
22
+ }
23
+ if (!world.tarballExists()) {
24
+ this.error(pc.red(`World tarball not found at ${world.tarballPath}`));
25
+ }
26
+ const isValid = await world.validateTarball();
27
+ if (!isValid) {
28
+ this.error(pc.red(`Invalid world tarball: level.dat not found in ${world.tarballPath}`));
29
+ }
30
+ const api = new ApiClient(flags["api-url"], flags["api-key"]);
31
+ const config = await world.loadConfig();
32
+ const tasks = new Listr([
33
+ {
34
+ title: "Ensuring world exists in cloud",
35
+ task: async (_ctx, task) => {
36
+ const exists = await api.worldExists(args.worldSlug);
37
+ if (!exists) {
38
+ await api.createWorld(args.worldSlug, config);
39
+ task.title = "Created world in cloud";
40
+ }
41
+ else {
42
+ task.title = "World exists in cloud";
43
+ }
44
+ },
45
+ },
46
+ {
47
+ title: "Uploading config",
48
+ task: async (_ctx, task) => {
49
+ await api.updateWorld(args.worldSlug, config);
50
+ task.title = "Config uploaded";
51
+ },
52
+ },
53
+ {
54
+ title: "Uploading world",
55
+ task: async (_ctx, task) => {
56
+ await api.uploadWorldFile(args.worldSlug, world.tarballPath);
57
+ task.title = "World uploaded";
58
+ },
59
+ },
60
+ ]);
61
+ await tasks.run();
62
+ this.log(pc.green(`\n✓ World pushed: ${args.worldSlug}`));
63
+ }
64
+ }
@@ -1,5 +1,5 @@
1
1
  import type z from "zod";
2
- import { type AgentSchemaType, type ChallengeConfigSchemaType, type ChallengeSchemaType, HumanSchema, type RecordingDownloadUrlResponse, type RecordingMetadata, type RunStatusSchemaType } from "./schemas.js";
2
+ import { type AgentSchemaType, type ChallengeConfigSchemaType, type ChallengeSchemaType, type DownloadUrlResponse, HumanSchema, type RecordingMetadata, type RunStatusSchemaType, type WorldConfigSchemaType, type WorldSchemaType } from "./schemas.js";
3
3
  export declare class ApiClient {
4
4
  private apiUrl;
5
5
  private kradleApiKey;
@@ -66,6 +66,7 @@ export declare class ApiClient {
66
66
  * @returns The upload URL.
67
67
  */
68
68
  getChallengeUploadUrl(slug: string): Promise<string>;
69
+ getChallengeDownloadUrl(slug: string): Promise<DownloadUrlResponse>;
69
70
  runChallenge(runData: {
70
71
  challenge: string;
71
72
  participants: unknown[];
@@ -106,5 +107,15 @@ export declare class ApiClient {
106
107
  * @param timestamp - The timestamp of the recording.
107
108
  * @returns Download URL and expiration time.
108
109
  */
109
- getRecordingDownloadUrl(runId: string, participantId: string, timestamp: string): Promise<RecordingDownloadUrlResponse>;
110
+ getRecordingDownloadUrl(runId: string, participantId: string, timestamp: string): Promise<DownloadUrlResponse>;
111
+ listWorlds(): Promise<WorldSchemaType[]>;
112
+ listKradleWorlds(): Promise<WorldSchemaType[]>;
113
+ getWorld(slug: string): Promise<WorldSchemaType>;
114
+ worldExists(slug: string): Promise<boolean>;
115
+ createWorld(slug: string, config: WorldConfigSchemaType): Promise<WorldSchemaType>;
116
+ updateWorld(slug: string, config: WorldConfigSchemaType): Promise<void>;
117
+ deleteWorld(slug: string): Promise<void>;
118
+ getWorldUploadUrl(slug: string): Promise<string>;
119
+ getWorldDownloadUrl(slug: string): Promise<DownloadUrlResponse>;
120
+ uploadWorldFile(slug: string, tarballPath: string): Promise<void>;
110
121
  }
@@ -1,6 +1,6 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import fs from "node:fs/promises";
3
- import { AgentsResponseSchema, ChallengeSchema, ChallengesResponseSchema, HumanSchema, JobResponseSchema, RecordingDownloadUrlResponseSchema, RecordingsListResponseSchema, RunStatusSchema, UploadUrlResponseSchema, } from "./schemas.js";
3
+ import { AgentsResponseSchema, ChallengeSchema, ChallengesResponseSchema, DownloadUrlResponseSchema, HumanSchema, JobResponseSchema, RecordingsListResponseSchema, RunStatusSchema, UploadUrlResponseSchema, WorldSchema, WorldsResponseSchema, } from "./schemas.js";
4
4
  const DEFAULT_PAGE_SIZE = 30;
5
5
  const DEFAULT_CHALLENGE_SCHEMA = {
6
6
  slug: "",
@@ -210,6 +210,9 @@ export class ApiClient {
210
210
  const response = await this.get(`challenges/${slug}/datapackUploadUrl`, {}, UploadUrlResponseSchema);
211
211
  return response.uploadUrl;
212
212
  }
213
+ async getChallengeDownloadUrl(slug) {
214
+ return this.get(`challenges/${slug}/datapackDownloadUrl`, {}, DownloadUrlResponseSchema);
215
+ }
213
216
  async runChallenge(runData) {
214
217
  const url = "jobs";
215
218
  const payload = this.isStudio ? runData : { ...runData, jobType: "background" };
@@ -262,6 +265,60 @@ export class ApiClient {
262
265
  */
263
266
  async getRecordingDownloadUrl(runId, participantId, timestamp) {
264
267
  const url = `runs/${runId}/recordings/${participantId}/downloadUrl?timestamp=${encodeURIComponent(timestamp)}`;
265
- return this.get(url, {}, RecordingDownloadUrlResponseSchema);
268
+ return this.get(url, {}, DownloadUrlResponseSchema);
269
+ }
270
+ async listWorlds() {
271
+ return this.listResource("worlds", "worlds", WorldsResponseSchema);
272
+ }
273
+ async listKradleWorlds() {
274
+ return this.listResource("humans/team-kradle/worlds", "worlds", WorldsResponseSchema);
275
+ }
276
+ async getWorld(slug) {
277
+ return this.get(`worlds/${slug}`, {}, WorldSchema);
278
+ }
279
+ async worldExists(slug) {
280
+ try {
281
+ const res = await this.get(`worlds/${slug}`, {});
282
+ return res !== null && res !== undefined;
283
+ }
284
+ catch {
285
+ return false;
286
+ }
287
+ }
288
+ async createWorld(slug, config) {
289
+ return this.post("worlds", { body: JSON.stringify({ slug, ...config }) }, WorldSchema);
290
+ }
291
+ async updateWorld(slug, config) {
292
+ await this.put(`worlds/${slug}`, {
293
+ body: JSON.stringify({ slug, ...config }),
294
+ });
295
+ }
296
+ async deleteWorld(slug) {
297
+ await this.delete(`worlds/${slug}`);
298
+ }
299
+ async getWorldUploadUrl(slug) {
300
+ const response = await this.get(`worlds/${slug}/uploadUrl`, {}, UploadUrlResponseSchema);
301
+ return response.uploadUrl;
302
+ }
303
+ async getWorldDownloadUrl(slug) {
304
+ return this.get(`worlds/${slug}/downloadUrl`, {}, DownloadUrlResponseSchema);
305
+ }
306
+ async uploadWorldFile(slug, tarballPath) {
307
+ const uploadUrl = await this.getWorldUploadUrl(slug);
308
+ if (!existsSync(tarballPath)) {
309
+ throw new Error(`World tarball not found at ${tarballPath}`);
310
+ }
311
+ const fileBuffer = await fs.readFile(tarballPath);
312
+ const response = await fetch(uploadUrl, {
313
+ method: "PUT",
314
+ headers: {
315
+ "Content-Type": "application/gzip",
316
+ "Content-Length": fileBuffer.length.toString(),
317
+ },
318
+ body: fileBuffer,
319
+ });
320
+ if (!response.ok) {
321
+ throw new Error(`Failed to upload world: ${response.statusText}`);
322
+ }
266
323
  }
267
324
  }
@@ -1,12 +1,12 @@
1
1
  import type { Arg } from "@oclif/core/interfaces";
2
- /**
3
- * Returns a "challenge slug" argument, validating it to be a valid challenge slug.
4
- * @param description - Description for the argument
5
- * @param required - Whether the argument is required (default: true)
6
- * @param allowTeam - Whether to allow namespaced slugs like "team-name:my-challenge" (default: false)
7
- */
2
+ export declare function extractShortSlug(slug: string): string;
8
3
  export declare function getChallengeSlugArgument<R extends boolean = true>({ description, required, allowTeam, }: {
9
4
  description: string;
10
5
  required?: R;
11
6
  allowTeam?: boolean;
12
7
  }): Arg<R extends true ? string : string | undefined>;
8
+ export declare function getWorldSlugArgument<R extends boolean = true>({ description, required, allowTeam, }: {
9
+ description: string;
10
+ required?: R;
11
+ allowTeam?: boolean;
12
+ }): Arg<R extends true ? string : string | undefined>;
@@ -1,16 +1,13 @@
1
1
  import { Args } from "@oclif/core";
2
- // Base pattern for a slug segment (lowercase alphanumeric with hyphens, no leading/trailing hyphens)
3
2
  const SLUG_SEGMENT = "[a-z0-9]+(?:-[a-z0-9]+)*";
4
- // Local challenge slug pattern: just the challenge name (no namespace)
5
3
  const LOCAL_SLUG_REGEX = new RegExp(`^${SLUG_SEGMENT}$`);
6
- // Full challenge slug pattern: optional namespace prefix (e.g., "team-name:") followed by the challenge slug
7
4
  const NAMESPACED_SLUG_REGEX = new RegExp(`^(?:${SLUG_SEGMENT}:)?${SLUG_SEGMENT}$`);
8
- /**
9
- * Returns a "challenge slug" argument, validating it to be a valid challenge slug.
10
- * @param description - Description for the argument
11
- * @param required - Whether the argument is required (default: true)
12
- * @param allowTeam - Whether to allow namespaced slugs like "team-name:my-challenge" (default: false)
13
- */
5
+ export function extractShortSlug(slug) {
6
+ if (slug.includes(":")) {
7
+ return slug.split(":")[1];
8
+ }
9
+ return slug;
10
+ }
14
11
  export function getChallengeSlugArgument({ description, required, allowTeam = false, }) {
15
12
  const regex = allowTeam ? NAMESPACED_SLUG_REGEX : LOCAL_SLUG_REGEX;
16
13
  const errorMessage = allowTeam
@@ -29,3 +26,21 @@ export function getChallengeSlugArgument({ description, required, allowTeam = fa
29
26
  // biome-ignore lint/suspicious/noExplicitAny: Typescript can't handle the conditional "required"
30
27
  return arg;
31
28
  }
29
+ export function getWorldSlugArgument({ description, required, allowTeam = false, }) {
30
+ const regex = allowTeam ? NAMESPACED_SLUG_REGEX : LOCAL_SLUG_REGEX;
31
+ const errorMessage = allowTeam
32
+ ? "World slugs must be lowercase alphanumeric characters and hyphens, and must not start or end with a hyphen. Optionally, a team prefix can be provided (e.g., 'team-name:my-world')."
33
+ : "World slugs must be lowercase alphanumeric characters and hyphens, and must not start or end with a hyphen.";
34
+ const arg = Args.string({
35
+ description,
36
+ required: required ?? true,
37
+ parse: async (input) => {
38
+ if (!regex.test(input)) {
39
+ throw new Error(`Invalid world slug: ${input}. ${errorMessage}`);
40
+ }
41
+ return input;
42
+ },
43
+ });
44
+ // biome-ignore lint/suspicious/noExplicitAny: Typescript can't handle the conditional "required"
45
+ return arg;
46
+ }
@@ -37,9 +37,6 @@ export declare class Challenge {
37
37
  * Build the challenge datapack
38
38
  */
39
39
  build(silent?: boolean): Promise<void>;
40
- /**
41
- * Load the challenge configuration from config.ts
42
- */
43
40
  loadConfig(): Promise<ChallengeConfigSchemaType>;
44
41
  /**
45
42
  * Build the challenge datapack and upload it to the cloud.
@@ -5,7 +5,7 @@ import path from "node:path";
5
5
  import pc from "picocolors";
6
6
  import * as tar from "tar";
7
7
  import { ChallengeConfigSchema } from "./schemas.js";
8
- import { executeNodeCommand, executeTypescriptFile, getStaticResourcePath, readDirSorted } from "./utils.js";
8
+ import { executeTypescriptFile, getStaticResourcePath, loadTypescriptExport, readDirSorted } from "./utils.js";
9
9
  export const SOURCE_FOLDER = ".src";
10
10
  export class Challenge {
11
11
  shortSlug;
@@ -106,25 +106,15 @@ export class Challenge {
106
106
  strict: true,
107
107
  }, ["datapack", SOURCE_FOLDER]);
108
108
  }
109
- /**
110
- * Load the challenge configuration from config.ts
111
- */
112
109
  async loadConfig() {
113
110
  if (!existsSync(this.configPath)) {
114
111
  throw new Error(`Config file not found at ${this.configPath}`);
115
112
  }
116
- // We spawn a new NodeJS process to execute & log the config file.
117
- // We can't directly import the file because it would be cached, and import cache can't be invalidated.
118
- const stdout = await executeNodeCommand([
119
- "--experimental-transform-types",
120
- "--no-warnings",
121
- "-e",
122
- `console.log(JSON.stringify(require("${this.configPath}").config));`,
123
- ], {
113
+ const config = await loadTypescriptExport(this.configPath, "config", {
124
114
  KRADLE_CHALLENGES_PATH: this.kradleChallengesPath,
125
115
  NAMESPACE: "kradle",
126
116
  });
127
- return ChallengeConfigSchema.parse(JSON.parse(stdout));
117
+ return ChallengeConfigSchema.parse(config);
128
118
  }
129
119
  /**
130
120
  * Build the challenge datapack and upload it to the cloud.
@@ -143,6 +143,10 @@ export declare const UploadUrlResponseSchema: z.ZodObject<{
143
143
  uploadUrl: z.ZodString;
144
144
  expiresAt: z.ZodString;
145
145
  }, z.core.$strip>;
146
+ export declare const DownloadUrlResponseSchema: z.ZodObject<{
147
+ downloadUrl: z.ZodString;
148
+ expiresAt: z.ZodString;
149
+ }, z.core.$strip>;
146
150
  export declare const AgentSchema: z.ZodObject<{
147
151
  username: z.ZodOptional<z.ZodString>;
148
152
  name: z.ZodOptional<z.ZodString>;
@@ -186,9 +190,44 @@ export declare const RecordingsListResponseSchema: z.ZodObject<{
186
190
  sizeBytes: z.ZodNumber;
187
191
  }, z.core.$strip>>;
188
192
  }, z.core.$strip>;
189
- export declare const RecordingDownloadUrlResponseSchema: z.ZodObject<{
190
- downloadUrl: z.ZodString;
191
- expiresAt: z.ZodString;
193
+ export declare const WorldSchema: z.ZodObject<{
194
+ id: z.ZodString;
195
+ slug: z.ZodString;
196
+ name: z.ZodString;
197
+ description: z.ZodOptional<z.ZodNullable<z.ZodString>>;
198
+ visibility: z.ZodEnum<{
199
+ private: "private";
200
+ public: "public";
201
+ }>;
202
+ creationTime: z.ZodString;
203
+ updateTime: z.ZodString;
204
+ creator: z.ZodString;
205
+ }, z.core.$strip>;
206
+ export declare const WorldConfigSchema: z.ZodObject<{
207
+ name: z.ZodString;
208
+ description: z.ZodOptional<z.ZodString>;
209
+ visibility: z.ZodEnum<{
210
+ private: "private";
211
+ public: "public";
212
+ }>;
213
+ domain: z.ZodDefault<z.ZodLiteral<"minecraft">>;
214
+ metadata: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
215
+ }, z.core.$strip>;
216
+ export declare const WorldsResponseSchema: z.ZodObject<{
217
+ worlds: z.ZodArray<z.ZodObject<{
218
+ id: z.ZodString;
219
+ slug: z.ZodString;
220
+ name: z.ZodString;
221
+ description: z.ZodOptional<z.ZodNullable<z.ZodString>>;
222
+ visibility: z.ZodEnum<{
223
+ private: "private";
224
+ public: "public";
225
+ }>;
226
+ creationTime: z.ZodString;
227
+ updateTime: z.ZodString;
228
+ creator: z.ZodString;
229
+ }, z.core.$strip>>;
230
+ nextPageToken: z.ZodOptional<z.ZodString>;
192
231
  }, z.core.$strip>;
193
232
  export type ChallengeSchemaType = z.infer<typeof ChallengeSchema>;
194
233
  export type ChallengeConfigSchemaType = z.infer<typeof ChallengeConfigSchema>;
@@ -200,5 +239,8 @@ export type AgentSchemaType = z.infer<typeof AgentSchema>;
200
239
  export type AgentsResponseType = z.infer<typeof AgentsResponseSchema>;
201
240
  export type RecordingMetadata = z.infer<typeof RecordingMetadataSchema>;
202
241
  export type RecordingsListResponse = z.infer<typeof RecordingsListResponseSchema>;
203
- export type RecordingDownloadUrlResponse = z.infer<typeof RecordingDownloadUrlResponseSchema>;
204
242
  export type RunParticipant = z.infer<typeof RunParticipantSchema>;
243
+ export type DownloadUrlResponse = z.infer<typeof DownloadUrlResponseSchema>;
244
+ export type WorldSchemaType = z.infer<typeof WorldSchema>;
245
+ export type WorldConfigSchemaType = z.infer<typeof WorldConfigSchema>;
246
+ export type WorldsResponseType = z.infer<typeof WorldsResponseSchema>;
@@ -64,6 +64,10 @@ export const UploadUrlResponseSchema = z.object({
64
64
  uploadUrl: z.string(),
65
65
  expiresAt: z.string(),
66
66
  });
67
+ export const DownloadUrlResponseSchema = z.object({
68
+ downloadUrl: z.string(),
69
+ expiresAt: z.string(),
70
+ });
67
71
  export const AgentSchema = z.object({
68
72
  username: z.string().optional(),
69
73
  name: z.string().optional(),
@@ -86,7 +90,24 @@ export const RecordingMetadataSchema = z.object({
86
90
  export const RecordingsListResponseSchema = z.object({
87
91
  recordings: z.array(RecordingMetadataSchema),
88
92
  });
89
- export const RecordingDownloadUrlResponseSchema = z.object({
90
- downloadUrl: z.string(),
91
- expiresAt: z.string(),
93
+ export const WorldSchema = z.object({
94
+ id: z.string(),
95
+ slug: z.string(),
96
+ name: z.string(),
97
+ description: z.string().nullish(),
98
+ visibility: z.enum(["private", "public"]),
99
+ creationTime: z.string(),
100
+ updateTime: z.string(),
101
+ creator: z.string(),
102
+ });
103
+ export const WorldConfigSchema = z.object({
104
+ name: z.string(),
105
+ description: z.string().optional(),
106
+ visibility: z.enum(["private", "public"]),
107
+ domain: z.literal("minecraft").default("minecraft"),
108
+ metadata: z.record(z.string(), z.string()).optional(),
109
+ });
110
+ export const WorldsResponseSchema = z.object({
111
+ worlds: z.array(WorldSchema),
112
+ nextPageToken: z.string().optional(),
92
113
  });
@@ -86,6 +86,7 @@ export declare function executeCommand(command: string, args: string[], options?
86
86
  * @returns A promise that resolves with the stdout of the command.
87
87
  */
88
88
  export declare function executeNodeCommand(args: string[], env: Record<string, string>): Promise<string>;
89
+ export declare function loadTypescriptExport(filePath: string, exportName: string, env?: Record<string, string>): Promise<unknown>;
89
90
  /**
90
91
  * Open a URL in the default browser.
91
92
  * This is fire-and-forget, so we don't wait for it to complete.
package/dist/lib/utils.js CHANGED
@@ -168,6 +168,15 @@ export async function executeCommand(command, args, options) {
168
168
  export async function executeNodeCommand(args, env) {
169
169
  return executeCommand(process.execPath, args, { env });
170
170
  }
171
+ export async function loadTypescriptExport(filePath, exportName, env = {}) {
172
+ const stdout = await executeNodeCommand([
173
+ "--experimental-transform-types",
174
+ "--no-warnings",
175
+ "-e",
176
+ `console.log(JSON.stringify(require("${filePath}").${exportName}));`,
177
+ ], env);
178
+ return JSON.parse(stdout);
179
+ }
171
180
  /**
172
181
  * Open a URL in the default browser.
173
182
  * This is fire-and-forget, so we don't wait for it to complete.
@@ -0,0 +1,21 @@
1
+ import { type WorldConfigSchemaType } from "./schemas.js";
2
+ /**
3
+ * Represents a Minecraft world stored locally as:
4
+ * worlds/<slug>/config.ts - metadata (name, visibility, etc.)
5
+ * worlds/<slug>/world.tar.gz - the packaged world files
6
+ */
7
+ export declare class World {
8
+ readonly shortSlug: string;
9
+ constructor(shortSlug: string);
10
+ get worldDir(): string;
11
+ get configPath(): string;
12
+ get tarballPath(): string;
13
+ exists(): boolean;
14
+ tarballExists(): boolean;
15
+ validateTarball(): Promise<boolean>;
16
+ loadConfig(): Promise<WorldConfigSchemaType>;
17
+ static getLocalWorlds(): Promise<string[]>;
18
+ static createLocal(slug: string, config: WorldConfigSchemaType): Promise<void>;
19
+ static createTarballFrom(sourcePath: string, slug: string): Promise<void>;
20
+ static toSlug(folderName: string): string;
21
+ }
@@ -0,0 +1,102 @@
1
+ import { existsSync } from "node:fs";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import * as tar from "tar";
5
+ import { WorldConfigSchema } from "./schemas.js";
6
+ import { loadTypescriptExport, readDirSorted } from "./utils.js";
7
+ /**
8
+ * Represents a Minecraft world stored locally as:
9
+ * worlds/<slug>/config.ts - metadata (name, visibility, etc.)
10
+ * worlds/<slug>/world.tar.gz - the packaged world files
11
+ */
12
+ export class World {
13
+ shortSlug;
14
+ constructor(shortSlug) {
15
+ this.shortSlug = shortSlug;
16
+ }
17
+ get worldDir() {
18
+ return path.resolve(process.cwd(), "worlds", this.shortSlug);
19
+ }
20
+ get configPath() {
21
+ return path.join(this.worldDir, "config.ts");
22
+ }
23
+ get tarballPath() {
24
+ return path.join(this.worldDir, "world.tar.gz");
25
+ }
26
+ exists() {
27
+ return existsSync(this.worldDir) && existsSync(this.configPath);
28
+ }
29
+ tarballExists() {
30
+ return existsSync(this.tarballPath);
31
+ }
32
+ // Validates that the tarball contains level.dat (required for valid Minecraft worlds)
33
+ async validateTarball() {
34
+ if (!this.tarballExists()) {
35
+ return false;
36
+ }
37
+ const entries = [];
38
+ await tar.list({
39
+ file: this.tarballPath,
40
+ onReadEntry: (entry) => {
41
+ entries.push(entry.path);
42
+ },
43
+ });
44
+ return entries.some((entry) => entry === "level.dat" || entry.endsWith("/level.dat"));
45
+ }
46
+ async loadConfig() {
47
+ if (!existsSync(this.configPath)) {
48
+ throw new Error(`Config file not found at ${this.configPath}`);
49
+ }
50
+ const config = await loadTypescriptExport(this.configPath, "config");
51
+ return WorldConfigSchema.parse(config);
52
+ }
53
+ static async getLocalWorlds() {
54
+ const worldsDir = path.resolve(process.cwd(), "worlds");
55
+ if (!existsSync(worldsDir)) {
56
+ return [];
57
+ }
58
+ const entries = await readDirSorted(worldsDir);
59
+ const worlds = [];
60
+ for (const entry of entries) {
61
+ if (entry.isDirectory() && entry.parentPath === worldsDir) {
62
+ const configPath = path.join(worldsDir, entry.name, "config.ts");
63
+ if (existsSync(configPath)) {
64
+ worlds.push(entry.name);
65
+ }
66
+ }
67
+ }
68
+ return worlds;
69
+ }
70
+ static async createLocal(slug, config) {
71
+ const world = new World(slug);
72
+ await fs.mkdir(world.worldDir, { recursive: true });
73
+ const configContent = `export const config = ${JSON.stringify(config, null, "\t").replace(/"([a-zA-Z_][a-zA-Z0-9_]*)"\s*:/g, "$1:")};
74
+ `;
75
+ await fs.writeFile(world.configPath, configContent);
76
+ }
77
+ static async createTarballFrom(sourcePath, slug) {
78
+ const resolvedSource = path.resolve(sourcePath);
79
+ if (!existsSync(resolvedSource)) {
80
+ throw new Error(`Source path does not exist: ${resolvedSource}`);
81
+ }
82
+ const levelDatPath = path.join(resolvedSource, "level.dat");
83
+ if (!existsSync(levelDatPath)) {
84
+ throw new Error(`Invalid Minecraft world: level.dat not found in ${resolvedSource}`);
85
+ }
86
+ const world = new World(slug);
87
+ await fs.mkdir(world.worldDir, { recursive: true });
88
+ await tar.create({
89
+ file: world.tarballPath,
90
+ gzip: true,
91
+ cwd: resolvedSource,
92
+ }, ["."]);
93
+ }
94
+ // Converts folder name to valid slug: "My World 2" -> "my-world-2"
95
+ static toSlug(folderName) {
96
+ return folderName
97
+ .toLowerCase()
98
+ .replace(/[^a-z0-9]+/g, "-")
99
+ .replace(/^-+|-+$/g, "")
100
+ .replace(/-+/g, "-");
101
+ }
102
+ }