evolved-monkey 0.1.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/README.md +22 -0
- package/dist/src/cli.d.ts +15 -0
- package/dist/src/cli.d.ts.map +1 -0
- package/dist/src/cli.js +63 -0
- package/dist/src/cli.js.map +1 -0
- package/dist/src/commands/challenge-init.d.ts +15 -0
- package/dist/src/commands/challenge-init.d.ts.map +1 -0
- package/dist/src/commands/challenge-init.js +98 -0
- package/dist/src/commands/challenge-init.js.map +1 -0
- package/dist/src/commands/challenge-submit.d.ts +21 -0
- package/dist/src/commands/challenge-submit.d.ts.map +1 -0
- package/dist/src/commands/challenge-submit.js +52 -0
- package/dist/src/commands/challenge-submit.js.map +1 -0
- package/dist/src/commands/challenge-validate.d.ts +13 -0
- package/dist/src/commands/challenge-validate.d.ts.map +1 -0
- package/dist/src/commands/challenge-validate.js +28 -0
- package/dist/src/commands/challenge-validate.js.map +1 -0
- package/dist/src/constants.d.ts +6 -0
- package/dist/src/constants.d.ts.map +1 -0
- package/dist/src/constants.js +12 -0
- package/dist/src/constants.js.map +1 -0
- package/dist/src/index.d.ts +3 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +20 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/lib/args.d.ts +3 -0
- package/dist/src/lib/args.d.ts.map +1 -0
- package/dist/src/lib/args.js +24 -0
- package/dist/src/lib/args.js.map +1 -0
- package/dist/src/lib/backend-client.d.ts +10 -0
- package/dist/src/lib/backend-client.d.ts.map +1 -0
- package/dist/src/lib/backend-client.js +86 -0
- package/dist/src/lib/backend-client.js.map +1 -0
- package/dist/src/lib/cli-error.d.ts +12 -0
- package/dist/src/lib/cli-error.d.ts.map +1 -0
- package/dist/src/lib/cli-error.js +13 -0
- package/dist/src/lib/cli-error.js.map +1 -0
- package/dist/src/lib/fs-utils.d.ts +6 -0
- package/dist/src/lib/fs-utils.d.ts.map +1 -0
- package/dist/src/lib/fs-utils.js +39 -0
- package/dist/src/lib/fs-utils.js.map +1 -0
- package/dist/src/lib/tarball.d.ts +7 -0
- package/dist/src/lib/tarball.d.ts.map +1 -0
- package/dist/src/lib/tarball.js +23 -0
- package/dist/src/lib/tarball.js.map +1 -0
- package/dist/src/lib/workspace.d.ts +21 -0
- package/dist/src/lib/workspace.d.ts.map +1 -0
- package/dist/src/lib/workspace.js +107 -0
- package/dist/src/lib/workspace.js.map +1 -0
- package/dist/src/types.d.ts +39 -0
- package/dist/src/types.d.ts.map +1 -0
- package/dist/src/types.js +2 -0
- package/dist/src/types.js.map +1 -0
- package/dist/tests/helpers/test-utils.d.ts +5 -0
- package/dist/tests/helpers/test-utils.d.ts.map +1 -0
- package/dist/tests/helpers/test-utils.js +26 -0
- package/dist/tests/helpers/test-utils.js.map +1 -0
- package/dist/tests/integration/submit-flow.spec.d.ts +2 -0
- package/dist/tests/integration/submit-flow.spec.d.ts.map +1 -0
- package/dist/tests/integration/submit-flow.spec.js +172 -0
- package/dist/tests/integration/submit-flow.spec.js.map +1 -0
- package/dist/tests/run-tests.d.ts +2 -0
- package/dist/tests/run-tests.d.ts.map +1 -0
- package/dist/tests/run-tests.js +33 -0
- package/dist/tests/run-tests.js.map +1 -0
- package/dist/tests/unit/cli-commands.spec.d.ts +2 -0
- package/dist/tests/unit/cli-commands.spec.d.ts.map +1 -0
- package/dist/tests/unit/cli-commands.spec.js +73 -0
- package/dist/tests/unit/cli-commands.spec.js.map +1 -0
- package/docs/01.md +39 -0
- package/package.json +22 -0
- package/src/cli.ts +86 -0
- package/src/commands/challenge-init.ts +162 -0
- package/src/commands/challenge-submit.ts +101 -0
- package/src/commands/challenge-validate.ts +48 -0
- package/src/constants.ts +13 -0
- package/src/index.ts +21 -0
- package/src/lib/args.ts +31 -0
- package/src/lib/backend-client.ts +129 -0
- package/src/lib/cli-error.ts +19 -0
- package/src/lib/fs-utils.ts +47 -0
- package/src/lib/tarball.ts +42 -0
- package/src/lib/workspace.ts +168 -0
- package/src/types.ts +45 -0
- package/tests/fixtures/invalid-workspace/challenge.config.json +11 -0
- package/tests/fixtures/invalid-workspace/starter/package.json +5 -0
- package/tests/fixtures/invalid-workspace/starter/src/index.js +3 -0
- package/tests/fixtures/valid-workspace/challenge.config.json +11 -0
- package/tests/fixtures/valid-workspace/starter/package.json +9 -0
- package/tests/fixtures/valid-workspace/starter/src/index.js +3 -0
- package/tests/fixtures/valid-workspace/tests/package.json +8 -0
- package/tests/fixtures/valid-workspace/tests/spec/basic.test.js +6 -0
- package/tests/helpers/test-utils.ts +32 -0
- package/tests/integration/submit-flow.spec.ts +207 -0
- package/tests/run-tests.ts +42 -0
- package/tests/snapshots/init-result.json +5 -0
- package/tests/unit/cli-commands.spec.ts +105 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { CliError } from "./cli-error.js";
|
|
2
|
+
import type {
|
|
3
|
+
ArtifactUploadResponse,
|
|
4
|
+
ArtifactUploadTarget,
|
|
5
|
+
BackendClientContract,
|
|
6
|
+
ChallengeDraftPayload,
|
|
7
|
+
ChallengeDraftResponse,
|
|
8
|
+
} from "../types.js";
|
|
9
|
+
|
|
10
|
+
type ApiPayload = {
|
|
11
|
+
data?: {
|
|
12
|
+
challenge?: ChallengeDraftResponse;
|
|
13
|
+
} & Partial<ArtifactUploadResponse>;
|
|
14
|
+
error?: {
|
|
15
|
+
code?: string;
|
|
16
|
+
message?: string;
|
|
17
|
+
};
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
async function parseJsonSafely(response: Response): Promise<ApiPayload | null> {
|
|
21
|
+
const text = await response.text();
|
|
22
|
+
|
|
23
|
+
if (!text) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(text) as ApiPayload;
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function requestJson(url: string, options: RequestInit = {}): Promise<ApiPayload> {
|
|
35
|
+
const response = await fetch(url, options);
|
|
36
|
+
const payload = await parseJsonSafely(response);
|
|
37
|
+
|
|
38
|
+
if (!response.ok) {
|
|
39
|
+
const apiMessage = payload?.error?.message;
|
|
40
|
+
throw new CliError(apiMessage ?? `Backend request failed (${response.status})`, {
|
|
41
|
+
code: payload?.error?.code ?? "BACKEND_REQUEST_FAILED",
|
|
42
|
+
details: payload,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return payload ?? {};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class BackendClient implements BackendClientContract {
|
|
50
|
+
private readonly baseUrl: string;
|
|
51
|
+
|
|
52
|
+
public constructor(baseUrl: string) {
|
|
53
|
+
this.baseUrl = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
public async createChallengeDraft(input: ChallengeDraftPayload): Promise<ChallengeDraftResponse> {
|
|
57
|
+
const payload = await requestJson(`${this.baseUrl}/v1/creator/challenges/init`, {
|
|
58
|
+
body: JSON.stringify(input),
|
|
59
|
+
headers: {
|
|
60
|
+
"content-type": "application/json",
|
|
61
|
+
},
|
|
62
|
+
method: "POST",
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const challenge = payload?.data?.challenge;
|
|
66
|
+
|
|
67
|
+
if (!challenge) {
|
|
68
|
+
throw new CliError("Backend did not return created challenge data.", {
|
|
69
|
+
code: "INVALID_BACKEND_RESPONSE",
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return challenge;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
public async requestStarterUpload(slug: string): Promise<ArtifactUploadResponse> {
|
|
77
|
+
const payload = await requestJson(
|
|
78
|
+
`${this.baseUrl}/v1/creator/challenges/${encodeURIComponent(slug)}/artifacts/starter/upload-url`,
|
|
79
|
+
{
|
|
80
|
+
method: "POST",
|
|
81
|
+
},
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
if (!payload.data || !("upload" in payload.data)) {
|
|
85
|
+
throw new CliError("Backend did not return starter upload target.", {
|
|
86
|
+
code: "INVALID_BACKEND_RESPONSE",
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return payload.data as ArtifactUploadResponse;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
public async requestTestsUpload(slug: string): Promise<ArtifactUploadResponse> {
|
|
94
|
+
const payload = await requestJson(
|
|
95
|
+
`${this.baseUrl}/v1/creator/challenges/${encodeURIComponent(slug)}/artifacts/tests/upload-url`,
|
|
96
|
+
{
|
|
97
|
+
method: "POST",
|
|
98
|
+
},
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
if (!payload.data || !("upload" in payload.data)) {
|
|
102
|
+
throw new CliError("Backend did not return tests upload target.", {
|
|
103
|
+
code: "INVALID_BACKEND_RESPONSE",
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return payload.data as ArtifactUploadResponse;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
public async uploadArtifact(upload: ArtifactUploadTarget, fileBuffer: Buffer): Promise<void> {
|
|
111
|
+
const headers = new Headers(upload.headers ?? {});
|
|
112
|
+
|
|
113
|
+
if (!headers.has("content-type")) {
|
|
114
|
+
headers.set("content-type", "application/gzip");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const response = await fetch(upload.url, {
|
|
118
|
+
body: new Uint8Array(fileBuffer),
|
|
119
|
+
headers,
|
|
120
|
+
method: upload.method ?? "PUT",
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
if (!response.ok) {
|
|
124
|
+
throw new CliError(`Artifact upload failed (${response.status})`, {
|
|
125
|
+
code: "ARTIFACT_UPLOAD_FAILED",
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export type CliErrorOptions = {
|
|
2
|
+
code?: string;
|
|
3
|
+
details?: unknown;
|
|
4
|
+
exitCode?: number;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export class CliError extends Error {
|
|
8
|
+
public readonly code: string;
|
|
9
|
+
public readonly details?: unknown;
|
|
10
|
+
public readonly exitCode: number;
|
|
11
|
+
|
|
12
|
+
public constructor(message: string, options: CliErrorOptions = {}) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = "CliError";
|
|
15
|
+
this.code = options.code ?? "CLI_ERROR";
|
|
16
|
+
this.details = options.details;
|
|
17
|
+
this.exitCode = options.exitCode ?? 1;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export async function ensureDir(dirPath: string): Promise<void> {
|
|
5
|
+
await fs.mkdir(dirPath, { recursive: true });
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export async function pathExists(targetPath: string): Promise<boolean> {
|
|
9
|
+
try {
|
|
10
|
+
await fs.access(targetPath);
|
|
11
|
+
return true;
|
|
12
|
+
} catch {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function readJsonFile<T>(filePath: string): Promise<T> {
|
|
18
|
+
const content = await fs.readFile(filePath, "utf8");
|
|
19
|
+
return JSON.parse(content) as T;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function writeJsonFile(filePath: string, value: unknown): Promise<void> {
|
|
23
|
+
const content = JSON.stringify(value, null, 2);
|
|
24
|
+
await fs.writeFile(filePath, `${content}\n`, "utf8");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function walkFilesRecursively(rootDir: string): Promise<string[]> {
|
|
28
|
+
const files: string[] = [];
|
|
29
|
+
|
|
30
|
+
async function visit(currentDir: string): Promise<void> {
|
|
31
|
+
const entries = await fs.readdir(currentDir, { withFileTypes: true });
|
|
32
|
+
|
|
33
|
+
for (const entry of entries) {
|
|
34
|
+
const absolutePath = path.join(currentDir, entry.name);
|
|
35
|
+
|
|
36
|
+
if (entry.isDirectory()) {
|
|
37
|
+
await visit(absolutePath);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
files.push(absolutePath);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
await visit(rootDir);
|
|
46
|
+
return files;
|
|
47
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
import { CliError } from "./cli-error.js";
|
|
4
|
+
|
|
5
|
+
export type CreateTarballInput = {
|
|
6
|
+
excludePatterns?: readonly string[];
|
|
7
|
+
outputFile: string;
|
|
8
|
+
sourceDir: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function createTarGzFromDirectory({
|
|
12
|
+
outputFile,
|
|
13
|
+
sourceDir,
|
|
14
|
+
excludePatterns = [],
|
|
15
|
+
}: CreateTarballInput): void {
|
|
16
|
+
const args = ["-czf", outputFile];
|
|
17
|
+
|
|
18
|
+
for (const pattern of excludePatterns) {
|
|
19
|
+
args.push("--exclude", pattern);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
args.push("-C", sourceDir, ".");
|
|
23
|
+
|
|
24
|
+
const result = spawnSync("tar", args, { encoding: "utf8" });
|
|
25
|
+
|
|
26
|
+
if (result.error) {
|
|
27
|
+
throw new CliError(
|
|
28
|
+
"Unable to create tar.gz archive. Ensure `tar` is installed and available in PATH.",
|
|
29
|
+
{
|
|
30
|
+
code: "TAR_COMMAND_MISSING",
|
|
31
|
+
details: result.error.message,
|
|
32
|
+
},
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (result.status !== 0) {
|
|
37
|
+
throw new CliError("Failed to create tar.gz archive.", {
|
|
38
|
+
code: "TAR_ARCHIVE_FAILED",
|
|
39
|
+
details: result.stderr || result.stdout,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
CHALLENGE_CONFIG_FILE,
|
|
6
|
+
CONSTRAINTS_PLACEHOLDER,
|
|
7
|
+
DESCRIPTION_PLACEHOLDER,
|
|
8
|
+
} from "../constants.js";
|
|
9
|
+
import { CliError } from "./cli-error.js";
|
|
10
|
+
import { pathExists, readJsonFile, walkFilesRecursively } from "./fs-utils.js";
|
|
11
|
+
|
|
12
|
+
const challengeSlugRegex = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
13
|
+
|
|
14
|
+
export type ChallengeWorkspaceConfig = {
|
|
15
|
+
backendBaseUrl: string;
|
|
16
|
+
challengeId: string | null;
|
|
17
|
+
constraintsMd: string;
|
|
18
|
+
createdAt: string;
|
|
19
|
+
descriptionMd: string;
|
|
20
|
+
slug: string;
|
|
21
|
+
title: string;
|
|
22
|
+
type: "EXPRESS_NODE";
|
|
23
|
+
version: number;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type WorkspaceValidationResult = {
|
|
27
|
+
errors: string[];
|
|
28
|
+
ok: boolean;
|
|
29
|
+
warnings: string[];
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export function normalizeSlug(slug: unknown): string {
|
|
33
|
+
return String(slug ?? "")
|
|
34
|
+
.trim()
|
|
35
|
+
.toLowerCase();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function ensureValidSlug(slug: unknown): string {
|
|
39
|
+
const value = normalizeSlug(slug);
|
|
40
|
+
|
|
41
|
+
if (!challengeSlugRegex.test(value)) {
|
|
42
|
+
throw new CliError(
|
|
43
|
+
"Slug must be lowercase kebab-case (letters, numbers, and hyphens only).",
|
|
44
|
+
{
|
|
45
|
+
code: "INVALID_SLUG",
|
|
46
|
+
},
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return value;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function loadChallengeConfig(workspaceDir: string): Promise<ChallengeWorkspaceConfig> {
|
|
54
|
+
const configPath = path.join(workspaceDir, CHALLENGE_CONFIG_FILE);
|
|
55
|
+
|
|
56
|
+
if (!(await pathExists(configPath))) {
|
|
57
|
+
throw new CliError(`Missing ${CHALLENGE_CONFIG_FILE} in ${workspaceDir}`, {
|
|
58
|
+
code: "MISSING_CONFIG_FILE",
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return readJsonFile<ChallengeWorkspaceConfig>(configPath);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function validateWorkspace(workspaceDir: string): Promise<WorkspaceValidationResult> {
|
|
66
|
+
const errors: string[] = [];
|
|
67
|
+
const warnings: string[] = [];
|
|
68
|
+
const configPath = path.join(workspaceDir, CHALLENGE_CONFIG_FILE);
|
|
69
|
+
|
|
70
|
+
if (!(await pathExists(configPath))) {
|
|
71
|
+
errors.push(`Missing ${CHALLENGE_CONFIG_FILE}.`);
|
|
72
|
+
return { errors, ok: false, warnings };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const config = await readJsonFile<ChallengeWorkspaceConfig>(configPath);
|
|
76
|
+
const slug = normalizeSlug(config.slug);
|
|
77
|
+
|
|
78
|
+
if (!challengeSlugRegex.test(slug)) {
|
|
79
|
+
errors.push("Config slug is invalid. Use lowercase kebab-case.");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (!config.title || String(config.title).trim().length < 3) {
|
|
83
|
+
errors.push("Config title must be at least 3 characters.");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (!config.descriptionMd || String(config.descriptionMd).trim().length < 20) {
|
|
87
|
+
errors.push("Config descriptionMd must be at least 20 characters.");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (String(config.descriptionMd).includes(DESCRIPTION_PLACEHOLDER)) {
|
|
91
|
+
errors.push("Replace description placeholder in challenge.config.json.");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!config.constraintsMd || String(config.constraintsMd).trim().length < 20) {
|
|
95
|
+
errors.push("Config constraintsMd must be at least 20 characters.");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (String(config.constraintsMd).includes(CONSTRAINTS_PLACEHOLDER)) {
|
|
99
|
+
errors.push("Replace constraints placeholder in challenge.config.json.");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (config.type !== "EXPRESS_NODE") {
|
|
103
|
+
errors.push("Only EXPRESS_NODE challenges are supported in V0.");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const starterDir = path.join(workspaceDir, "starter");
|
|
107
|
+
const testsDir = path.join(workspaceDir, "tests");
|
|
108
|
+
|
|
109
|
+
if (!(await pathExists(path.join(starterDir, "package.json")))) {
|
|
110
|
+
errors.push("starter/package.json is required.");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (!(await pathExists(path.join(testsDir, "package.json")))) {
|
|
114
|
+
errors.push("tests/package.json is required.");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const disallowedPaths = [
|
|
118
|
+
path.join(starterDir, "node_modules"),
|
|
119
|
+
path.join(testsDir, "node_modules"),
|
|
120
|
+
path.join(starterDir, ".env"),
|
|
121
|
+
path.join(testsDir, ".env"),
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
for (const targetPath of disallowedPaths) {
|
|
125
|
+
if (await pathExists(targetPath)) {
|
|
126
|
+
errors.push(`Disallowed path present: ${path.relative(workspaceDir, targetPath)}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (await pathExists(starterDir)) {
|
|
131
|
+
const starterFiles = await walkFilesRecursively(starterDir);
|
|
132
|
+
const hiddenTestsInStarter = starterFiles.filter((filePath) =>
|
|
133
|
+
path.basename(filePath).match(/\.test\.[jt]sx?$/),
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
if (hiddenTestsInStarter.length > 0) {
|
|
137
|
+
errors.push("Starter must not include hidden test files.");
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (await pathExists(testsDir)) {
|
|
142
|
+
const testFiles = await walkFilesRecursively(testsDir);
|
|
143
|
+
const matchingTestFiles = testFiles.filter((filePath) =>
|
|
144
|
+
path.basename(filePath).match(/\.test\.[jt]sx?$/),
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
if (matchingTestFiles.length === 0) {
|
|
148
|
+
errors.push("Hidden tests package must include at least one *.test.js or *.test.ts file.");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const externalUrlRegex = /https?:\/\/(?!localhost|127\.0\.0\.1)/i;
|
|
152
|
+
|
|
153
|
+
for (const filePath of matchingTestFiles) {
|
|
154
|
+
const content = await fs.readFile(filePath, "utf8");
|
|
155
|
+
if (externalUrlRegex.test(content)) {
|
|
156
|
+
warnings.push(
|
|
157
|
+
`Potential external network usage found in tests file: ${path.relative(workspaceDir, filePath)}`,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
errors,
|
|
165
|
+
ok: errors.length === 0,
|
|
166
|
+
warnings,
|
|
167
|
+
};
|
|
168
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export type FlagValue = string | boolean;
|
|
2
|
+
export type CliFlags = Record<string, FlagValue | undefined>;
|
|
3
|
+
|
|
4
|
+
export type ParsedArgs = {
|
|
5
|
+
flags: CliFlags;
|
|
6
|
+
positionals: string[];
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type Logger = (...args: string[]) => void;
|
|
10
|
+
|
|
11
|
+
export type ChallengeDraftPayload = {
|
|
12
|
+
constraintsMd: string;
|
|
13
|
+
descriptionMd: string;
|
|
14
|
+
slug: string;
|
|
15
|
+
title: string;
|
|
16
|
+
type: "EXPRESS_NODE";
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type ChallengeDraftResponse = {
|
|
20
|
+
id: string;
|
|
21
|
+
slug: string;
|
|
22
|
+
title: string;
|
|
23
|
+
type: "EXPRESS_NODE";
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type ArtifactUploadTarget = {
|
|
27
|
+
expiresInSeconds: number;
|
|
28
|
+
headers: Record<string, string>;
|
|
29
|
+
key: string;
|
|
30
|
+
method: "PUT";
|
|
31
|
+
url: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type ArtifactUploadResponse = {
|
|
35
|
+
challengeId: string;
|
|
36
|
+
challengeSlug: string;
|
|
37
|
+
upload: ArtifactUploadTarget;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export interface BackendClientContract {
|
|
41
|
+
createChallengeDraft(input: ChallengeDraftPayload): Promise<ChallengeDraftResponse>;
|
|
42
|
+
requestStarterUpload(slug: string): Promise<ArtifactUploadResponse>;
|
|
43
|
+
requestTestsUpload(slug: string): Promise<ArtifactUploadResponse>;
|
|
44
|
+
uploadArtifact(upload: ArtifactUploadTarget, fileBuffer: Buffer): Promise<void>;
|
|
45
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"challengeId": "challenge-invalid",
|
|
4
|
+
"slug": "invalid_workspace",
|
|
5
|
+
"title": "No",
|
|
6
|
+
"descriptionMd": "REPLACE_WITH_CHALLENGE_DESCRIPTION",
|
|
7
|
+
"constraintsMd": "REPLACE_WITH_CHALLENGE_CONSTRAINTS",
|
|
8
|
+
"type": "EXPRESS_NODE",
|
|
9
|
+
"backendBaseUrl": "http://localhost:3001",
|
|
10
|
+
"createdAt": "2026-02-15T00:00:00.000Z"
|
|
11
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"challengeId": "challenge-valid",
|
|
4
|
+
"slug": "valid-workspace-challenge",
|
|
5
|
+
"title": "Valid Workspace Challenge",
|
|
6
|
+
"descriptionMd": "Build an Express middleware that validates a custom header and forwards valid requests.",
|
|
7
|
+
"constraintsMd": "Use only express and built-in Node APIs. Do not use external services. Keep implementation deterministic.",
|
|
8
|
+
"type": "EXPRESS_NODE",
|
|
9
|
+
"backendBaseUrl": "http://localhost:3001",
|
|
10
|
+
"createdAt": "2026-02-15T00:00:00.000Z"
|
|
11
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
export async function createTempDir(prefix: string): Promise<string> {
|
|
6
|
+
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function copyDir(sourceDir: string, destinationDir: string): Promise<void> {
|
|
10
|
+
await fs.mkdir(destinationDir, { recursive: true });
|
|
11
|
+
const entries = await fs.readdir(sourceDir, { withFileTypes: true });
|
|
12
|
+
|
|
13
|
+
for (const entry of entries) {
|
|
14
|
+
const sourcePath = path.join(sourceDir, entry.name);
|
|
15
|
+
const destinationPath = path.join(destinationDir, entry.name);
|
|
16
|
+
|
|
17
|
+
if (entry.isDirectory()) {
|
|
18
|
+
await copyDir(sourcePath, destinationPath);
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
await fs.copyFile(sourcePath, destinationPath);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function resolveFixturePath(relativePath: string): string {
|
|
27
|
+
return path.resolve(process.cwd(), "tests", "fixtures", relativePath);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function resolveSnapshotPath(relativePath: string): string {
|
|
31
|
+
return path.resolve(process.cwd(), "tests", "snapshots", relativePath);
|
|
32
|
+
}
|