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.
Files changed (98) hide show
  1. package/README.md +22 -0
  2. package/dist/src/cli.d.ts +15 -0
  3. package/dist/src/cli.d.ts.map +1 -0
  4. package/dist/src/cli.js +63 -0
  5. package/dist/src/cli.js.map +1 -0
  6. package/dist/src/commands/challenge-init.d.ts +15 -0
  7. package/dist/src/commands/challenge-init.d.ts.map +1 -0
  8. package/dist/src/commands/challenge-init.js +98 -0
  9. package/dist/src/commands/challenge-init.js.map +1 -0
  10. package/dist/src/commands/challenge-submit.d.ts +21 -0
  11. package/dist/src/commands/challenge-submit.d.ts.map +1 -0
  12. package/dist/src/commands/challenge-submit.js +52 -0
  13. package/dist/src/commands/challenge-submit.js.map +1 -0
  14. package/dist/src/commands/challenge-validate.d.ts +13 -0
  15. package/dist/src/commands/challenge-validate.d.ts.map +1 -0
  16. package/dist/src/commands/challenge-validate.js +28 -0
  17. package/dist/src/commands/challenge-validate.js.map +1 -0
  18. package/dist/src/constants.d.ts +6 -0
  19. package/dist/src/constants.d.ts.map +1 -0
  20. package/dist/src/constants.js +12 -0
  21. package/dist/src/constants.js.map +1 -0
  22. package/dist/src/index.d.ts +3 -0
  23. package/dist/src/index.d.ts.map +1 -0
  24. package/dist/src/index.js +20 -0
  25. package/dist/src/index.js.map +1 -0
  26. package/dist/src/lib/args.d.ts +3 -0
  27. package/dist/src/lib/args.d.ts.map +1 -0
  28. package/dist/src/lib/args.js +24 -0
  29. package/dist/src/lib/args.js.map +1 -0
  30. package/dist/src/lib/backend-client.d.ts +10 -0
  31. package/dist/src/lib/backend-client.d.ts.map +1 -0
  32. package/dist/src/lib/backend-client.js +86 -0
  33. package/dist/src/lib/backend-client.js.map +1 -0
  34. package/dist/src/lib/cli-error.d.ts +12 -0
  35. package/dist/src/lib/cli-error.d.ts.map +1 -0
  36. package/dist/src/lib/cli-error.js +13 -0
  37. package/dist/src/lib/cli-error.js.map +1 -0
  38. package/dist/src/lib/fs-utils.d.ts +6 -0
  39. package/dist/src/lib/fs-utils.d.ts.map +1 -0
  40. package/dist/src/lib/fs-utils.js +39 -0
  41. package/dist/src/lib/fs-utils.js.map +1 -0
  42. package/dist/src/lib/tarball.d.ts +7 -0
  43. package/dist/src/lib/tarball.d.ts.map +1 -0
  44. package/dist/src/lib/tarball.js +23 -0
  45. package/dist/src/lib/tarball.js.map +1 -0
  46. package/dist/src/lib/workspace.d.ts +21 -0
  47. package/dist/src/lib/workspace.d.ts.map +1 -0
  48. package/dist/src/lib/workspace.js +107 -0
  49. package/dist/src/lib/workspace.js.map +1 -0
  50. package/dist/src/types.d.ts +39 -0
  51. package/dist/src/types.d.ts.map +1 -0
  52. package/dist/src/types.js +2 -0
  53. package/dist/src/types.js.map +1 -0
  54. package/dist/tests/helpers/test-utils.d.ts +5 -0
  55. package/dist/tests/helpers/test-utils.d.ts.map +1 -0
  56. package/dist/tests/helpers/test-utils.js +26 -0
  57. package/dist/tests/helpers/test-utils.js.map +1 -0
  58. package/dist/tests/integration/submit-flow.spec.d.ts +2 -0
  59. package/dist/tests/integration/submit-flow.spec.d.ts.map +1 -0
  60. package/dist/tests/integration/submit-flow.spec.js +172 -0
  61. package/dist/tests/integration/submit-flow.spec.js.map +1 -0
  62. package/dist/tests/run-tests.d.ts +2 -0
  63. package/dist/tests/run-tests.d.ts.map +1 -0
  64. package/dist/tests/run-tests.js +33 -0
  65. package/dist/tests/run-tests.js.map +1 -0
  66. package/dist/tests/unit/cli-commands.spec.d.ts +2 -0
  67. package/dist/tests/unit/cli-commands.spec.d.ts.map +1 -0
  68. package/dist/tests/unit/cli-commands.spec.js +73 -0
  69. package/dist/tests/unit/cli-commands.spec.js.map +1 -0
  70. package/docs/01.md +39 -0
  71. package/package.json +22 -0
  72. package/src/cli.ts +86 -0
  73. package/src/commands/challenge-init.ts +162 -0
  74. package/src/commands/challenge-submit.ts +101 -0
  75. package/src/commands/challenge-validate.ts +48 -0
  76. package/src/constants.ts +13 -0
  77. package/src/index.ts +21 -0
  78. package/src/lib/args.ts +31 -0
  79. package/src/lib/backend-client.ts +129 -0
  80. package/src/lib/cli-error.ts +19 -0
  81. package/src/lib/fs-utils.ts +47 -0
  82. package/src/lib/tarball.ts +42 -0
  83. package/src/lib/workspace.ts +168 -0
  84. package/src/types.ts +45 -0
  85. package/tests/fixtures/invalid-workspace/challenge.config.json +11 -0
  86. package/tests/fixtures/invalid-workspace/starter/package.json +5 -0
  87. package/tests/fixtures/invalid-workspace/starter/src/index.js +3 -0
  88. package/tests/fixtures/valid-workspace/challenge.config.json +11 -0
  89. package/tests/fixtures/valid-workspace/starter/package.json +9 -0
  90. package/tests/fixtures/valid-workspace/starter/src/index.js +3 -0
  91. package/tests/fixtures/valid-workspace/tests/package.json +8 -0
  92. package/tests/fixtures/valid-workspace/tests/spec/basic.test.js +6 -0
  93. package/tests/helpers/test-utils.ts +32 -0
  94. package/tests/integration/submit-flow.spec.ts +207 -0
  95. package/tests/run-tests.ts +42 -0
  96. package/tests/snapshots/init-result.json +5 -0
  97. package/tests/unit/cli-commands.spec.ts +105 -0
  98. package/tsconfig.json +20 -0
@@ -0,0 +1,73 @@
1
+ import assert from "node:assert/strict";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { runChallengeInit } from "../../src/commands/challenge-init.js";
5
+ import { runChallengeValidate } from "../../src/commands/challenge-validate.js";
6
+ import { readJsonFile } from "../../src/lib/fs-utils.js";
7
+ import { copyDir, createTempDir, resolveFixturePath, resolveSnapshotPath, } from "../helpers/test-utils.js";
8
+ class FakeBackendClient {
9
+ async createChallengeDraft(input) {
10
+ return {
11
+ id: "challenge-snapshot-id",
12
+ slug: input.slug,
13
+ title: input.title,
14
+ type: "EXPRESS_NODE",
15
+ };
16
+ }
17
+ async requestStarterUpload(_slug) {
18
+ throw new Error("Not used in this unit test.");
19
+ }
20
+ async requestTestsUpload(_slug) {
21
+ throw new Error("Not used in this unit test.");
22
+ }
23
+ async uploadArtifact(_upload, _fileBuffer) {
24
+ throw new Error("Not used in this unit test.");
25
+ }
26
+ }
27
+ export async function runCliCommandsUnitTests() {
28
+ const tempRoot = await createTempDir("em-cli-unit-");
29
+ try {
30
+ const initResult = await runChallengeInit({
31
+ backendClient: new FakeBackendClient(),
32
+ cwd: tempRoot,
33
+ flags: {
34
+ constraints: "Use only express and built-in APIs. Keep runtime deterministic and avoid external services.",
35
+ description: "Build a middleware that validates incoming headers and returns clear JSON error responses.",
36
+ slug: "snapshot-challenge",
37
+ title: "Snapshot Challenge",
38
+ },
39
+ log: () => { },
40
+ });
41
+ const normalizedInitResult = {
42
+ ...initResult,
43
+ workspaceDir: "<workspace>",
44
+ };
45
+ const snapshotPath = resolveSnapshotPath("init-result.json");
46
+ const expectedSnapshot = JSON.parse(await fs.readFile(snapshotPath, "utf8"));
47
+ assert.deepEqual(normalizedInitResult, expectedSnapshot);
48
+ const configPath = path.join(initResult.workspaceDir, "challenge.config.json");
49
+ const config = await readJsonFile(configPath);
50
+ assert.equal(config.slug, "snapshot-challenge");
51
+ assert.equal(config.type, "EXPRESS_NODE");
52
+ const validResult = await runChallengeValidate({
53
+ cwd: initResult.workspaceDir,
54
+ flags: {},
55
+ log: () => { },
56
+ });
57
+ assert.equal(validResult.ok, true);
58
+ const invalidFixtureSource = resolveFixturePath("invalid-workspace");
59
+ const invalidFixtureTarget = path.join(tempRoot, "invalid-workspace");
60
+ await copyDir(invalidFixtureSource, invalidFixtureTarget);
61
+ const invalidResult = await runChallengeValidate({
62
+ cwd: invalidFixtureTarget,
63
+ flags: {},
64
+ log: () => { },
65
+ });
66
+ assert.equal(invalidResult.ok, false);
67
+ assert.equal(invalidResult.errors.length > 0, true);
68
+ }
69
+ finally {
70
+ await fs.rm(tempRoot, { force: true, recursive: true });
71
+ }
72
+ }
73
+ //# sourceMappingURL=cli-commands.spec.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli-commands.spec.js","sourceRoot":"","sources":["../../../tests/unit/cli-commands.spec.ts"],"names":[],"mappings":"AAAA,OAAO,MAAM,MAAM,oBAAoB,CAAC;AACxC,OAAO,EAAE,MAAM,kBAAkB,CAAC;AAClC,OAAO,IAAI,MAAM,WAAW,CAAC;AAE7B,OAAO,EAAE,gBAAgB,EAAE,MAAM,sCAAsC,CAAC;AACxE,OAAO,EAAE,oBAAoB,EAAE,MAAM,0CAA0C,CAAC;AAChF,OAAO,EAAE,YAAY,EAAE,MAAM,2BAA2B,CAAC;AAMzD,OAAO,EACL,OAAO,EACP,aAAa,EACb,kBAAkB,EAClB,mBAAmB,GACpB,MAAM,0BAA0B,CAAC;AAElC,MAAM,iBAAiB;IACd,KAAK,CAAC,oBAAoB,CAAC,KAA4B;QAC5D,OAAO;YACL,EAAE,EAAE,uBAAuB;YAC3B,IAAI,EAAE,KAAK,CAAC,IAAI;YAChB,KAAK,EAAE,KAAK,CAAC,KAAK;YAClB,IAAI,EAAE,cAAuB;SAC9B,CAAC;IACJ,CAAC;IAEM,KAAK,CAAC,oBAAoB,CAAC,KAAa;QAC7C,MAAM,IAAI,KAAK,CAAC,6BAA6B,CAAC,CAAC;IACjD,CAAC;IAEM,KAAK,CAAC,kBAAkB,CAAC,KAAa;QAC3C,MAAM,IAAI,KAAK,CAAC,6BAA6B,CAAC,CAAC;IACjD,CAAC;IAEM,KAAK,CAAC,cAAc,CAAC,OAAyC,EAAE,WAAmB;QACxF,MAAM,IAAI,KAAK,CAAC,6BAA6B,CAAC,CAAC;IACjD,CAAC;CACF;AAED,MAAM,CAAC,KAAK,UAAU,uBAAuB;IAC3C,MAAM,QAAQ,GAAG,MAAM,aAAa,CAAC,cAAc,CAAC,CAAC;IAErD,IAAI,CAAC;QACH,MAAM,UAAU,GAAG,MAAM,gBAAgB,CAAC;YACxC,aAAa,EAAE,IAAI,iBAAiB,EAAE;YACtC,GAAG,EAAE,QAAQ;YACb,KAAK,EAAE;gBACL,WAAW,EACT,6FAA6F;gBAC/F,WAAW,EACT,4FAA4F;gBAC9F,IAAI,EAAE,oBAAoB;gBAC1B,KAAK,EAAE,oBAAoB;aAC5B;YACD,GAAG,EAAE,GAAG,EAAE,GAAE,CAAC;SACd,CAAC,CAAC;QAEH,MAAM,oBAAoB,GAAG;YAC3B,GAAG,UAAU;YACb,YAAY,EAAE,aAAa;SAC5B,CAAC;QACF,MAAM,YAAY,GAAG,mBAAmB,CAAC,kBAAkB,CAAC,CAAC;QAC7D,MAAM,gBAAgB,GAAG,IAAI,CAAC,KAAK,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC,CAI1E,CAAC;QAEF,MAAM,CAAC,SAAS,CAAC,oBAAoB,EAAE,gBAAgB,CAAC,CAAC;QAEzD,MAAM,UAAU,GAAG,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,YAAY,EAAE,uBAAuB,CAAC,CAAC;QAC/E,MAAM,MAAM,GAAG,MAAM,YAAY,CAG9B,UAAU,CAAC,CAAC;QACf,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,IAAI,EAAE,oBAAoB,CAAC,CAAC;QAChD,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,IAAI,EAAE,cAAc,CAAC,CAAC;QAE1C,MAAM,WAAW,GAAG,MAAM,oBAAoB,CAAC;YAC7C,GAAG,EAAE,UAAU,CAAC,YAAY;YAC5B,KAAK,EAAE,EAAE;YACT,GAAG,EAAE,GAAG,EAAE,GAAE,CAAC;SACd,CAAC,CAAC;QAEH,MAAM,CAAC,KAAK,CAAC,WAAW,CAAC,EAAE,EAAE,IAAI,CAAC,CAAC;QAEnC,MAAM,oBAAoB,GAAG,kBAAkB,CAAC,mBAAmB,CAAC,CAAC;QACrE,MAAM,oBAAoB,GAAG,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,mBAAmB,CAAC,CAAC;QACtE,MAAM,OAAO,CAAC,oBAAoB,EAAE,oBAAoB,CAAC,CAAC;QAE1D,MAAM,aAAa,GAAG,MAAM,oBAAoB,CAAC;YAC/C,GAAG,EAAE,oBAAoB;YACzB,KAAK,EAAE,EAAE;YACT,GAAG,EAAE,GAAG,EAAE,GAAE,CAAC;SACd,CAAC,CAAC;QAEH,MAAM,CAAC,KAAK,CAAC,aAAa,CAAC,EAAE,EAAE,KAAK,CAAC,CAAC;QACtC,MAAM,CAAC,KAAK,CAAC,aAAa,CAAC,MAAM,CAAC,MAAM,GAAG,CAAC,EAAE,IAAI,CAAC,CAAC;IACtD,CAAC;YAAS,CAAC;QACT,MAAM,EAAE,CAAC,EAAE,CAAC,QAAQ,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IAC1D,CAAC;AACH,CAAC"}
package/docs/01.md ADDED
@@ -0,0 +1,39 @@
1
+ 1.
2
+ - challenge init
3
+ - Creates draft challenge in backend
4
+ - Scaffolds local creator workspace with:
5
+ - challenge.config.json
6
+ - starter/
7
+ - tests/
8
+ - README.md
9
+ - challenge validate
10
+ - Enforces creator rules from docs/challenge-creator-guidelines.md:
11
+ - valid slug/title/description/constraints
12
+ - required files present
13
+ - disallowed files (node_modules, .env) not present
14
+ - hidden tests exist
15
+ - starter doesn’t contain hidden test files
16
+ - challenge submit
17
+ - Validates workspace first
18
+ - Requests signed upload URLs from backend
19
+ - Packs starter/ and tests/ as tar.gz
20
+ - Uploads both artifacts to R2 via signed URLs
21
+
22
+ Files added/updated:
23
+
24
+ - cli/package.json
25
+ - cli/src/index.js
26
+ - cli/src/cli.js
27
+ - cli/src/constants.js
28
+ - cli/src/commands/challenge-init.js
29
+ - cli/src/commands/challenge-validate.js
30
+ - cli/src/commands/challenge-submit.js
31
+ - cli/src/lib/* helpers (args, backend-client, workspace, tarball, etc.)
32
+ - cli/tests/* (fixtures, snapshots, unit + integration tests)
33
+ - cli/README.md
34
+ - docs/v0-tasklist.md (Task 4 marked complete)
35
+
36
+ Tests:
37
+
38
+ - Ran cd cli && npm test
39
+ - Result: all tests passed (unit + backend test server integration).
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "evolved-monkey",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "type": "module",
6
+ "bin": {
7
+ "em-challenge": "dist/src/index.js"
8
+ },
9
+ "scripts": {
10
+ "build": "tsc -p tsconfig.json",
11
+ "typecheck": "tsc --noEmit",
12
+ "pretest": "npm run build",
13
+ "test": "node dist/tests/run-tests.js"
14
+ },
15
+ "engines": {
16
+ "node": ">=20.0.0"
17
+ },
18
+ "devDependencies": {
19
+ "@types/node": "^20",
20
+ "typescript": "^5.9.3"
21
+ }
22
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,86 @@
1
+ import { DEFAULT_BACKEND_BASE_URL } from "./constants.js";
2
+ import { runChallengeInit } from "./commands/challenge-init.js";
3
+ import { runChallengeSubmit } from "./commands/challenge-submit.js";
4
+ import { runChallengeValidate } from "./commands/challenge-validate.js";
5
+ import { BackendClient } from "./lib/backend-client.js";
6
+ import { parseArgs } from "./lib/args.js";
7
+ import { CliError } from "./lib/cli-error.js";
8
+ import type { PackageDirectoryInput } from "./commands/challenge-submit.js";
9
+ import type { BackendClientContract, CliFlags, Logger } from "./types.js";
10
+
11
+ type CliContext = {
12
+ backendClient?: BackendClientContract;
13
+ cwd?: string;
14
+ log?: Logger;
15
+ packageDirectory?: (input: PackageDirectoryInput) => Promise<Buffer>;
16
+ };
17
+
18
+ function resolveBackendBaseUrl(flags: CliFlags, command: string): string {
19
+ if (flags.backend && flags.backend !== true) {
20
+ return String(flags.backend);
21
+ }
22
+
23
+ if (command === "challenge" && (flags.dir || flags.dir === "")) {
24
+ return process.env.BACKEND_BASE_URL ?? DEFAULT_BACKEND_BASE_URL;
25
+ }
26
+
27
+ return process.env.BACKEND_BASE_URL ?? DEFAULT_BACKEND_BASE_URL;
28
+ }
29
+
30
+ function usageText(): string {
31
+ return [
32
+ "Usage:",
33
+ " em-challenge challenge init --slug <slug> --title <title> [--description <text>] [--constraints <text>] [--backend <url>] [--dir <path>]",
34
+ " em-challenge challenge validate [--dir <path>]",
35
+ " em-challenge challenge submit [--backend <url>] [--dir <path>]",
36
+ ].join("\n");
37
+ }
38
+
39
+ export async function runCli(rawArgv: string[], context: CliContext = {}) {
40
+ const argv = rawArgv.slice();
41
+ const { flags, positionals } = parseArgs(argv);
42
+ const log = context.log ?? console.log;
43
+ const cwd = context.cwd ?? process.cwd();
44
+
45
+ const [domain, command] = positionals;
46
+
47
+ if (!domain || !command) {
48
+ throw new CliError(usageText(), { code: "USAGE_ERROR" });
49
+ }
50
+
51
+ if (domain !== "challenge") {
52
+ throw new CliError(`Unsupported domain: ${domain}\n\n${usageText()}`, { code: "USAGE_ERROR" });
53
+ }
54
+
55
+ const backendBaseUrl = resolveBackendBaseUrl(flags, domain);
56
+ const backendClient =
57
+ context.backendClient ?? new BackendClient(backendBaseUrl);
58
+
59
+ if (command === "init") {
60
+ return runChallengeInit({ backendClient, cwd, flags, log });
61
+ }
62
+
63
+ if (command === "validate") {
64
+ const result = await runChallengeValidate({ cwd, flags, log });
65
+ if (!result.ok) {
66
+ throw new CliError("Validation failed.", {
67
+ code: "VALIDATION_FAILED",
68
+ details: result.errors,
69
+ });
70
+ }
71
+
72
+ return result;
73
+ }
74
+
75
+ if (command === "submit") {
76
+ return runChallengeSubmit({
77
+ backendClient,
78
+ cwd,
79
+ flags,
80
+ log,
81
+ packageDirectory: context.packageDirectory,
82
+ });
83
+ }
84
+
85
+ throw new CliError(`Unsupported command: ${command}\n\n${usageText()}`, { code: "USAGE_ERROR" });
86
+ }
@@ -0,0 +1,162 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import {
5
+ CHALLENGE_CONFIG_FILE,
6
+ CONSTRAINTS_PLACEHOLDER,
7
+ DEFAULT_BACKEND_BASE_URL,
8
+ DESCRIPTION_PLACEHOLDER,
9
+ } from "../constants.js";
10
+ import { CliError } from "../lib/cli-error.js";
11
+ import { ensureDir, pathExists, writeJsonFile } from "../lib/fs-utils.js";
12
+ import { ensureValidSlug } from "../lib/workspace.js";
13
+ import type {
14
+ BackendClientContract,
15
+ ChallengeDraftPayload,
16
+ CliFlags,
17
+ Logger,
18
+ } from "../types.js";
19
+
20
+ type ChallengeInitInput = {
21
+ backendClient: BackendClientContract;
22
+ cwd?: string;
23
+ flags: CliFlags;
24
+ log?: Logger;
25
+ };
26
+
27
+ type ChallengeInitResult = {
28
+ challengeId: string | null;
29
+ slug: string;
30
+ workspaceDir: string;
31
+ };
32
+
33
+ function resolveOption(flags: CliFlags, key: string, fallback: string): string {
34
+ const value = flags[key];
35
+ if (value === undefined || value === true || value === "") {
36
+ return fallback;
37
+ }
38
+
39
+ return String(value);
40
+ }
41
+
42
+ async function createStarterTemplate(workspaceDir: string): Promise<void> {
43
+ const starterDir = path.join(workspaceDir, "starter");
44
+ await ensureDir(path.join(starterDir, "src"));
45
+
46
+ const starterPackage = {
47
+ name: "challenge-starter",
48
+ private: true,
49
+ scripts: {
50
+ start: "node src/index.js",
51
+ test: "node --test",
52
+ },
53
+ type: "module",
54
+ version: "1.0.0",
55
+ };
56
+
57
+ await writeJsonFile(path.join(starterDir, "package.json"), starterPackage);
58
+ await fs.writeFile(
59
+ path.join(starterDir, "src", "index.js"),
60
+ `export function createApp() {\n return "replace-with-solution";\n}\n`,
61
+ "utf8",
62
+ );
63
+ await fs.writeFile(path.join(starterDir, ".env.example"), "PORT=3000\n", "utf8");
64
+ }
65
+
66
+ async function createHiddenTestsTemplate(workspaceDir: string): Promise<void> {
67
+ const testsDir = path.join(workspaceDir, "tests");
68
+ await ensureDir(path.join(testsDir, "spec"));
69
+
70
+ const testsPackage = {
71
+ name: "challenge-hidden-tests",
72
+ private: true,
73
+ scripts: {
74
+ test: "node --test \"spec/**/*.test.js\"",
75
+ },
76
+ type: "module",
77
+ version: "1.0.0",
78
+ };
79
+
80
+ await writeJsonFile(path.join(testsDir, "package.json"), testsPackage);
81
+ await fs.writeFile(
82
+ path.join(testsDir, "spec", "placeholder.test.js"),
83
+ `import test from "node:test";\nimport assert from "node:assert/strict";\n\ntest("replace with real hidden tests", () => {\n assert.equal(true, true);\n});\n`,
84
+ "utf8",
85
+ );
86
+ }
87
+
88
+ export async function runChallengeInit({
89
+ backendClient,
90
+ cwd = process.cwd(),
91
+ flags,
92
+ log = console.log,
93
+ }: ChallengeInitInput): Promise<ChallengeInitResult> {
94
+ const slug = ensureValidSlug(flags.slug);
95
+ const title = resolveOption(flags, "title", "");
96
+
97
+ if (!title || title.length < 3) {
98
+ throw new CliError("`--title` is required and must be at least 3 characters.", {
99
+ code: "INVALID_TITLE",
100
+ });
101
+ }
102
+
103
+ const descriptionMd = resolveOption(flags, "description", DESCRIPTION_PLACEHOLDER);
104
+ const constraintsMd = resolveOption(flags, "constraints", CONSTRAINTS_PLACEHOLDER);
105
+ const backendBaseUrl = resolveOption(
106
+ flags,
107
+ "backend",
108
+ process.env.BACKEND_BASE_URL ?? DEFAULT_BACKEND_BASE_URL,
109
+ );
110
+ const workspaceDir = path.resolve(cwd, resolveOption(flags, "dir", slug));
111
+
112
+ if (await pathExists(workspaceDir)) {
113
+ throw new CliError(`Target directory already exists: ${workspaceDir}`, {
114
+ code: "DIRECTORY_EXISTS",
115
+ });
116
+ }
117
+
118
+ const payload: ChallengeDraftPayload = {
119
+ constraintsMd,
120
+ descriptionMd,
121
+ slug,
122
+ title,
123
+ type: "EXPRESS_NODE",
124
+ };
125
+
126
+ const createdChallenge = await backendClient.createChallengeDraft(payload);
127
+
128
+ await ensureDir(workspaceDir);
129
+ await createStarterTemplate(workspaceDir);
130
+ await createHiddenTestsTemplate(workspaceDir);
131
+
132
+ const config = {
133
+ backendBaseUrl,
134
+ challengeId: createdChallenge?.id ?? null,
135
+ constraintsMd,
136
+ createdAt: new Date().toISOString(),
137
+ descriptionMd,
138
+ slug,
139
+ title,
140
+ type: "EXPRESS_NODE",
141
+ version: 1,
142
+ };
143
+
144
+ await writeJsonFile(path.join(workspaceDir, CHALLENGE_CONFIG_FILE), config);
145
+ await fs.writeFile(
146
+ path.join(workspaceDir, "README.md"),
147
+ `# ${title}\n\n## Description\n${descriptionMd}\n\n## Constraints\n${constraintsMd}\n`,
148
+ "utf8",
149
+ );
150
+
151
+ log(`Challenge workspace created: ${workspaceDir}`);
152
+ log(`Next steps:`);
153
+ log(`1) cd ${workspaceDir}`);
154
+ log(`2) em-challenge challenge validate`);
155
+ log(`3) em-challenge challenge submit`);
156
+
157
+ return {
158
+ challengeId: createdChallenge?.id ?? null,
159
+ slug,
160
+ workspaceDir,
161
+ };
162
+ }
@@ -0,0 +1,101 @@
1
+ import fs from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ import { CHALLENGE_CONFIG_FILE, EXCLUDED_ARTIFACT_PATTERNS } from "../constants.js";
6
+ import { CliError } from "../lib/cli-error.js";
7
+ import { readJsonFile } from "../lib/fs-utils.js";
8
+ import { createTarGzFromDirectory } from "../lib/tarball.js";
9
+ import { runChallengeValidate } from "./challenge-validate.js";
10
+ import type { ChallengeWorkspaceConfig } from "../lib/workspace.js";
11
+ import type { BackendClientContract, CliFlags, Logger } from "../types.js";
12
+
13
+ export type PackageDirectoryInput = {
14
+ archiveNamePrefix: string;
15
+ sourceDir: string;
16
+ };
17
+
18
+ type ChallengeSubmitInput = {
19
+ backendClient: BackendClientContract;
20
+ cwd?: string;
21
+ flags: CliFlags;
22
+ log?: Logger;
23
+ packageDirectory?: (input: PackageDirectoryInput) => Promise<Buffer>;
24
+ };
25
+
26
+ export type ChallengeSubmitResult = {
27
+ slug: string;
28
+ starterArtifactKey: string;
29
+ testsArtifactKey: string;
30
+ };
31
+
32
+ export async function packageDirectoryToTarGz({
33
+ archiveNamePrefix,
34
+ sourceDir,
35
+ }: PackageDirectoryInput): Promise<Buffer> {
36
+ const archivePath = path.join(
37
+ os.tmpdir(),
38
+ `${archiveNamePrefix}-${Date.now()}-${Math.random().toString(36).slice(2)}.tar.gz`,
39
+ );
40
+
41
+ createTarGzFromDirectory({
42
+ excludePatterns: EXCLUDED_ARTIFACT_PATTERNS,
43
+ outputFile: archivePath,
44
+ sourceDir,
45
+ });
46
+
47
+ const fileBuffer = await fs.readFile(archivePath);
48
+ await fs.rm(archivePath, { force: true });
49
+ return fileBuffer;
50
+ }
51
+
52
+ export async function runChallengeSubmit({
53
+ backendClient,
54
+ cwd = process.cwd(),
55
+ flags,
56
+ log = console.log,
57
+ packageDirectory = packageDirectoryToTarGz,
58
+ }: ChallengeSubmitInput): Promise<ChallengeSubmitResult> {
59
+ const validation = await runChallengeValidate({ cwd, flags, log: () => {} });
60
+
61
+ if (!validation.ok) {
62
+ throw new CliError(
63
+ `Cannot submit challenge. Validation failed. Fix ${CHALLENGE_CONFIG_FILE} and workspace files first.`,
64
+ {
65
+ code: "VALIDATION_FAILED",
66
+ details: validation.errors,
67
+ },
68
+ );
69
+ }
70
+
71
+ const workspaceDir = validation.workspaceDir;
72
+ const config = await readJsonFile<ChallengeWorkspaceConfig>(
73
+ path.join(workspaceDir, CHALLENGE_CONFIG_FILE),
74
+ );
75
+ const slug = config.slug;
76
+
77
+ const starterUploadData = await backendClient.requestStarterUpload(slug);
78
+ const testsUploadData = await backendClient.requestTestsUpload(slug);
79
+
80
+ const starterBuffer = await packageDirectory({
81
+ archiveNamePrefix: `${slug}-starter`,
82
+ sourceDir: path.join(workspaceDir, "starter"),
83
+ });
84
+ const testsBuffer = await packageDirectory({
85
+ archiveNamePrefix: `${slug}-tests`,
86
+ sourceDir: path.join(workspaceDir, "tests"),
87
+ });
88
+
89
+ await backendClient.uploadArtifact(starterUploadData.upload, starterBuffer);
90
+ await backendClient.uploadArtifact(testsUploadData.upload, testsBuffer);
91
+
92
+ log(`Submitted challenge artifacts for ${slug}.`);
93
+ log(`Starter artifact key: ${starterUploadData.upload.key}`);
94
+ log(`Tests artifact key: ${testsUploadData.upload.key}`);
95
+
96
+ return {
97
+ slug,
98
+ starterArtifactKey: starterUploadData.upload.key,
99
+ testsArtifactKey: testsUploadData.upload.key,
100
+ };
101
+ }
@@ -0,0 +1,48 @@
1
+ import path from "node:path";
2
+
3
+ import { validateWorkspace } from "../lib/workspace.js";
4
+ import type { CliFlags, Logger } from "../types.js";
5
+ import type { WorkspaceValidationResult } from "../lib/workspace.js";
6
+
7
+ type ChallengeValidateInput = {
8
+ cwd?: string;
9
+ flags: CliFlags;
10
+ log?: Logger;
11
+ };
12
+
13
+ export type ChallengeValidateResult = WorkspaceValidationResult & {
14
+ workspaceDir: string;
15
+ };
16
+
17
+ export async function runChallengeValidate({
18
+ cwd = process.cwd(),
19
+ flags,
20
+ log = console.log,
21
+ }: ChallengeValidateInput): Promise<ChallengeValidateResult> {
22
+ const workspaceDir = path.resolve(cwd, String(flags.dir || "."));
23
+ const result = await validateWorkspace(workspaceDir);
24
+
25
+ if (!result.ok) {
26
+ log(`Validation failed for workspace: ${workspaceDir}`);
27
+ for (const error of result.errors) {
28
+ log(`- ERROR: ${error}`);
29
+ }
30
+ for (const warning of result.warnings) {
31
+ log(`- WARNING: ${warning}`);
32
+ }
33
+ return {
34
+ ...result,
35
+ workspaceDir,
36
+ };
37
+ }
38
+
39
+ log(`Validation passed for workspace: ${workspaceDir}`);
40
+ for (const warning of result.warnings) {
41
+ log(`- WARNING: ${warning}`);
42
+ }
43
+
44
+ return {
45
+ ...result,
46
+ workspaceDir,
47
+ };
48
+ }
@@ -0,0 +1,13 @@
1
+ export const DEFAULT_BACKEND_BASE_URL = "http://localhost:3001";
2
+ export const CHALLENGE_CONFIG_FILE = "challenge.config.json";
3
+
4
+ export const DESCRIPTION_PLACEHOLDER = "REPLACE_WITH_CHALLENGE_DESCRIPTION";
5
+ export const CONSTRAINTS_PLACEHOLDER = "REPLACE_WITH_CHALLENGE_CONSTRAINTS";
6
+
7
+ export const EXCLUDED_ARTIFACT_PATTERNS = [
8
+ "node_modules",
9
+ ".git",
10
+ ".DS_Store",
11
+ "*.log",
12
+ ".env",
13
+ ] as const;
package/src/index.ts ADDED
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env node
2
+ import { CliError } from "./lib/cli-error.js";
3
+ import { runCli } from "./cli.js";
4
+
5
+ async function main() {
6
+ try {
7
+ await runCli(process.argv.slice(2));
8
+ } catch (error: unknown) {
9
+ if (error instanceof CliError) {
10
+ console.error(error.message);
11
+ process.exit(error.exitCode);
12
+ return;
13
+ }
14
+
15
+ console.error("Unexpected CLI error.");
16
+ console.error(error);
17
+ process.exit(1);
18
+ }
19
+ }
20
+
21
+ void main();
@@ -0,0 +1,31 @@
1
+ import type { ParsedArgs } from "../types.js";
2
+
3
+ export function parseArgs(argv: string[]): ParsedArgs {
4
+ const positionals: string[] = [];
5
+ const flags: ParsedArgs["flags"] = {};
6
+
7
+ for (let index = 0; index < argv.length; index += 1) {
8
+ const token = argv[index];
9
+ if (!token) {
10
+ continue;
11
+ }
12
+
13
+ if (!token.startsWith("--")) {
14
+ positionals.push(token);
15
+ continue;
16
+ }
17
+
18
+ const key = token.slice(2);
19
+ const next = argv[index + 1];
20
+
21
+ if (!next || next.startsWith("--")) {
22
+ flags[key] = true;
23
+ continue;
24
+ }
25
+
26
+ flags[key] = next;
27
+ index += 1;
28
+ }
29
+
30
+ return { flags, positionals };
31
+ }