litmus-cli 0.2.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 (54) hide show
  1. package/dist/commands/init.d.ts +2 -0
  2. package/dist/commands/init.d.ts.map +1 -0
  3. package/dist/commands/init.js +153 -0
  4. package/dist/commands/init.js.map +1 -0
  5. package/dist/commands/submit.d.ts +4 -0
  6. package/dist/commands/submit.d.ts.map +1 -0
  7. package/dist/commands/submit.js +124 -0
  8. package/dist/commands/submit.js.map +1 -0
  9. package/dist/index.d.ts +3 -0
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +36 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/lib/api.d.ts +30 -0
  14. package/dist/lib/api.d.ts.map +1 -0
  15. package/dist/lib/api.js +54 -0
  16. package/dist/lib/api.js.map +1 -0
  17. package/dist/lib/config.d.ts +19 -0
  18. package/dist/lib/config.d.ts.map +1 -0
  19. package/dist/lib/config.js +40 -0
  20. package/dist/lib/config.js.map +1 -0
  21. package/dist/lib/extract.d.ts +6 -0
  22. package/dist/lib/extract.d.ts.map +1 -0
  23. package/dist/lib/extract.js +12 -0
  24. package/dist/lib/extract.js.map +1 -0
  25. package/dist/lib/tracker.d.ts +7 -0
  26. package/dist/lib/tracker.d.ts.map +1 -0
  27. package/dist/lib/tracker.js +36 -0
  28. package/dist/lib/tracker.js.map +1 -0
  29. package/dist/lib/zip.d.ts +10 -0
  30. package/dist/lib/zip.d.ts.map +1 -0
  31. package/dist/lib/zip.js +96 -0
  32. package/dist/lib/zip.js.map +1 -0
  33. package/dist/utils/detect-project.d.ts +11 -0
  34. package/dist/utils/detect-project.d.ts.map +1 -0
  35. package/dist/utils/detect-project.js +55 -0
  36. package/dist/utils/detect-project.js.map +1 -0
  37. package/dist/utils/errors.d.ts +19 -0
  38. package/dist/utils/errors.d.ts.map +1 -0
  39. package/dist/utils/errors.js +35 -0
  40. package/dist/utils/errors.js.map +1 -0
  41. package/dist/watcher.cjs +58 -0
  42. package/package.json +37 -0
  43. package/src/commands/init.ts +171 -0
  44. package/src/commands/submit.ts +141 -0
  45. package/src/index.ts +38 -0
  46. package/src/lib/api.ts +93 -0
  47. package/src/lib/config.ts +59 -0
  48. package/src/lib/extract.ts +15 -0
  49. package/src/lib/tracker.ts +38 -0
  50. package/src/lib/watcher.cjs +58 -0
  51. package/src/lib/zip.ts +104 -0
  52. package/src/utils/detect-project.ts +83 -0
  53. package/src/utils/errors.ts +41 -0
  54. package/tsconfig.json +19 -0
@@ -0,0 +1,96 @@
1
+ import archiver from "archiver";
2
+ import { createWriteStream, readFileSync, existsSync } from "fs";
3
+ import path from "path";
4
+ import os from "os";
5
+ // Directories always excluded from submissions.
6
+ // NOTE: .git is intentionally NOT excluded — git history is included so the
7
+ // analysis backend can run parse_git_history() on the submission.
8
+ const ALWAYS_EXCLUDE = [
9
+ "node_modules",
10
+ "__pycache__",
11
+ "venv",
12
+ ".venv",
13
+ "env",
14
+ ".env",
15
+ "target", // Rust/Java build output
16
+ "build",
17
+ "dist",
18
+ ".gradle",
19
+ ".idea",
20
+ ".DS_Store",
21
+ "*.pyc",
22
+ "*.class",
23
+ // Legacy injected tracking files — exclude so they don't pollute submissions
24
+ // from assessments downloaded before the CLI-native tracking was deployed.
25
+ "setup.sh",
26
+ "tracker.py",
27
+ "tracker.js",
28
+ "heartbeat.py",
29
+ ];
30
+ /**
31
+ * Create a ZIP of the project directory for submission.
32
+ * Respects .gitignore patterns (basic support) and excludes common large dirs.
33
+ * Always includes .litmus/activity.jsonl (the tracking log).
34
+ *
35
+ * @param projectDir - Root of the project to zip
36
+ * @returns Path to the temporary ZIP file
37
+ */
38
+ export async function createSubmissionZip(projectDir) {
39
+ const tmpFile = path.join(os.tmpdir(), `litmus-submission-${Date.now()}.zip`);
40
+ const output = createWriteStream(tmpFile);
41
+ const archive = archiver("zip", { zlib: { level: 6 } });
42
+ return new Promise((resolve, reject) => {
43
+ output.on("close", () => resolve(tmpFile));
44
+ archive.on("error", reject);
45
+ archive.pipe(output);
46
+ // Compute glob patterns to ignore
47
+ const ignoredGlobs = buildIgnoreGlobs(projectDir);
48
+ // Add the project directory contents (not the directory itself)
49
+ archive.glob("**/*", {
50
+ cwd: projectDir,
51
+ dot: true, // Include dotfiles like .litmus/
52
+ ignore: ignoredGlobs,
53
+ });
54
+ archive.finalize();
55
+ });
56
+ }
57
+ function buildIgnoreGlobs(projectDir) {
58
+ const patterns = [];
59
+ // Always-excluded dirs/patterns
60
+ for (const excluded of ALWAYS_EXCLUDE) {
61
+ if (excluded.startsWith("*")) {
62
+ patterns.push(excluded);
63
+ }
64
+ else {
65
+ patterns.push(`${excluded}/**`);
66
+ patterns.push(`**/${excluded}/**`);
67
+ }
68
+ }
69
+ // Basic .gitignore support
70
+ const gitignorePath = path.join(projectDir, ".gitignore");
71
+ if (existsSync(gitignorePath)) {
72
+ try {
73
+ const lines = readFileSync(gitignorePath, "utf8")
74
+ .split("\n")
75
+ .map((l) => l.trim())
76
+ .filter((l) => l && !l.startsWith("#"));
77
+ for (const line of lines) {
78
+ if (line.endsWith("/")) {
79
+ // Directory pattern
80
+ const dir = line.slice(0, -1);
81
+ patterns.push(`${dir}/**`);
82
+ patterns.push(`**/${dir}/**`);
83
+ }
84
+ else {
85
+ patterns.push(line);
86
+ patterns.push(`**/${line}`);
87
+ }
88
+ }
89
+ }
90
+ catch {
91
+ // Ignore .gitignore parse errors
92
+ }
93
+ }
94
+ return patterns;
95
+ }
96
+ //# sourceMappingURL=zip.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"zip.js","sourceRoot":"","sources":["../../src/lib/zip.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,11 @@
1
+ export type ProjectType = "node" | "python" | "go" | "java" | "dotnet" | "rust" | "ruby" | "php" | "unknown";
2
+ export interface ProjectInfo {
3
+ type: ProjectType;
4
+ installCommand: string | null;
5
+ runHint: string | null;
6
+ }
7
+ /**
8
+ * Detect project type and suggest an install command.
9
+ */
10
+ export declare function detectProject(dir: string): ProjectInfo;
11
+ //# sourceMappingURL=detect-project.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"detect-project.d.ts","sourceRoot":"","sources":["../../src/utils/detect-project.ts"],"names":[],"mappings":"AAGA,MAAM,MAAM,WAAW,GACnB,MAAM,GACN,QAAQ,GACR,IAAI,GACJ,MAAM,GACN,QAAQ,GACR,MAAM,GACN,MAAM,GACN,KAAK,GACL,SAAS,CAAA;AAEb,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,WAAW,CAAA;IACjB,cAAc,EAAE,MAAM,GAAG,IAAI,CAAA;IAC7B,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;CACvB;AAED;;GAEG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,WAAW,CA2DtD"}
@@ -0,0 +1,55 @@
1
+ import { existsSync } from "fs";
2
+ import path from "path";
3
+ /**
4
+ * Detect project type and suggest an install command.
5
+ */
6
+ export function detectProject(dir) {
7
+ if (existsSync(path.join(dir, "package.json"))) {
8
+ const hasYarnLock = existsSync(path.join(dir, "yarn.lock"));
9
+ const hasPnpmLock = existsSync(path.join(dir, "pnpm-lock.yaml"));
10
+ const installCommand = hasPnpmLock
11
+ ? "pnpm install"
12
+ : hasYarnLock
13
+ ? "yarn"
14
+ : "npm install";
15
+ return { type: "node", installCommand, runHint: "npm start / npm test" };
16
+ }
17
+ if (existsSync(path.join(dir, "requirements.txt")) ||
18
+ existsSync(path.join(dir, "pyproject.toml")) ||
19
+ existsSync(path.join(dir, "setup.py"))) {
20
+ // Create a venv and install into it (replaces what the old setup.sh did).
21
+ // Use venv/bin/pip directly so activation isn't required at install time.
22
+ const pipBin = process.platform === "win32" ? "venv\\Scripts\\pip" : "venv/bin/pip";
23
+ const installCommand = existsSync(path.join(dir, "requirements.txt"))
24
+ ? `python3 -m venv venv && ${pipBin} install -r requirements.txt`
25
+ : `python3 -m venv venv && ${pipBin} install -e .`;
26
+ return {
27
+ type: "python",
28
+ installCommand,
29
+ runHint: "source venv/bin/activate # then: python main.py / pytest",
30
+ };
31
+ }
32
+ if (existsSync(path.join(dir, "go.mod"))) {
33
+ return { type: "go", installCommand: "go mod download", runHint: "go run . / go test ./..." };
34
+ }
35
+ if (existsSync(path.join(dir, "pom.xml"))) {
36
+ return { type: "java", installCommand: "mvn install", runHint: "mvn test" };
37
+ }
38
+ if (existsSync(path.join(dir, "build.gradle")) || existsSync(path.join(dir, "build.gradle.kts"))) {
39
+ return { type: "java", installCommand: "gradle build", runHint: "gradle test" };
40
+ }
41
+ if (existsSync(path.join(dir, "*.csproj")) || existsSync(path.join(dir, "*.sln"))) {
42
+ return { type: "dotnet", installCommand: "dotnet restore", runHint: "dotnet run / dotnet test" };
43
+ }
44
+ if (existsSync(path.join(dir, "Cargo.toml"))) {
45
+ return { type: "rust", installCommand: null, runHint: "cargo run / cargo test" };
46
+ }
47
+ if (existsSync(path.join(dir, "Gemfile"))) {
48
+ return { type: "ruby", installCommand: "bundle install", runHint: "ruby main.rb / rspec" };
49
+ }
50
+ if (existsSync(path.join(dir, "composer.json"))) {
51
+ return { type: "php", installCommand: "composer install", runHint: "php -S localhost:8000" };
52
+ }
53
+ return { type: "unknown", installCommand: null, runHint: null };
54
+ }
55
+ //# sourceMappingURL=detect-project.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"detect-project.js","sourceRoot":"","sources":["../../src/utils/detect-project.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,19 @@
1
+ export declare const FALLBACK_URL = "https://app.litmus.sh";
2
+ /**
3
+ * Print a plain-English error and exit.
4
+ * Always shows a fallback URL for candidates who can't resolve the issue.
5
+ */
6
+ export declare function fatal(message: string, hint?: string): never;
7
+ /**
8
+ * Print a warning (non-fatal).
9
+ */
10
+ export declare function warn(message: string): void;
11
+ /**
12
+ * Print a success message.
13
+ */
14
+ export declare function success(message: string): void;
15
+ /**
16
+ * Print a plain info line.
17
+ */
18
+ export declare function info(message: string): void;
19
+ //# sourceMappingURL=errors.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../../src/utils/errors.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,YAAY,0BAA0B,CAAA;AAEnD;;;GAGG;AACH,wBAAgB,KAAK,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,KAAK,CAW3D;AAED;;GAEG;AACH,wBAAgB,IAAI,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAE1C;AAED;;GAEG;AACH,wBAAgB,OAAO,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAE7C;AAED;;GAEG;AACH,wBAAgB,IAAI,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAE1C"}
@@ -0,0 +1,35 @@
1
+ import chalk from "chalk";
2
+ export const FALLBACK_URL = "https://app.litmus.sh";
3
+ /**
4
+ * Print a plain-English error and exit.
5
+ * Always shows a fallback URL for candidates who can't resolve the issue.
6
+ */
7
+ export function fatal(message, hint) {
8
+ console.error();
9
+ console.error(chalk.red("✗ Error: ") + message);
10
+ if (hint) {
11
+ console.error(chalk.dim(" Hint: ") + hint);
12
+ }
13
+ console.error(chalk.dim(` Need help? Visit ${FALLBACK_URL} to manage your assessment.`));
14
+ console.error();
15
+ process.exit(1);
16
+ }
17
+ /**
18
+ * Print a warning (non-fatal).
19
+ */
20
+ export function warn(message) {
21
+ console.warn(chalk.yellow("⚠ Warning: ") + message);
22
+ }
23
+ /**
24
+ * Print a success message.
25
+ */
26
+ export function success(message) {
27
+ console.log(chalk.green("✓ ") + message);
28
+ }
29
+ /**
30
+ * Print a plain info line.
31
+ */
32
+ export function info(message) {
33
+ console.log(chalk.dim(" ") + message);
34
+ }
35
+ //# sourceMappingURL=errors.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.js","sourceRoot":"","sources":["../../src/utils/errors.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAA;AAEzB,MAAM,CAAC,MAAM,YAAY,GAAG,uBAAuB,CAAA;AAEnD;;;GAGG;AACH,MAAM,UAAU,KAAK,CAAC,OAAe,EAAE,IAAa;IAClD,OAAO,CAAC,KAAK,EAAE,CAAA;IACf,OAAO,CAAC,KAAK,CAAC,KAAK,CAAC,GAAG,CAAC,WAAW,CAAC,GAAG,OAAO,CAAC,CAAA;IAC/C,IAAI,IAAI,EAAE,CAAC;QACT,OAAO,CAAC,KAAK,CAAC,KAAK,CAAC,GAAG,CAAC,UAAU,CAAC,GAAG,IAAI,CAAC,CAAA;IAC7C,CAAC;IACD,OAAO,CAAC,KAAK,CACX,KAAK,CAAC,GAAG,CAAC,sBAAsB,YAAY,6BAA6B,CAAC,CAC3E,CAAA;IACD,OAAO,CAAC,KAAK,EAAE,CAAA;IACf,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;AACjB,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,IAAI,CAAC,OAAe;IAClC,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,aAAa,CAAC,GAAG,OAAO,CAAC,CAAA;AACrD,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,OAAO,CAAC,OAAe;IACrC,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,OAAO,CAAC,CAAA;AAC1C,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,IAAI,CAAC,OAAe;IAClC,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,GAAG,OAAO,CAAC,CAAA;AACxC,CAAC"}
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Litmus file activity watcher.
4
+ * Runs as a detached background process started by `litmus init`.
5
+ * Usage: node watcher.cjs <projectDir> <activityLogPath>
6
+ *
7
+ * Writes newline-delimited JSON to the activity log:
8
+ * { "ts": "ISO-8601", "type": "add"|"change"|"unlink", "path": "relative/path" }
9
+ */
10
+
11
+ const chokidar = require("chokidar")
12
+ const fs = require("fs")
13
+ const path = require("path")
14
+
15
+ const [, , projectDir, activityLogPath] = process.argv
16
+
17
+ if (!projectDir || !activityLogPath) {
18
+ process.stderr.write("Usage: node watcher.cjs <projectDir> <activityLogPath>\n")
19
+ process.exit(1)
20
+ }
21
+
22
+ // Ensure the log directory exists
23
+ fs.mkdirSync(path.dirname(activityLogPath), { recursive: true })
24
+
25
+ // Open the log file for appending
26
+ const log = fs.createWriteStream(activityLogPath, { flags: "a" })
27
+
28
+ function write(type, filePath) {
29
+ const event = JSON.stringify({
30
+ ts: new Date().toISOString(),
31
+ type,
32
+ path: path.relative(projectDir, filePath),
33
+ })
34
+ log.write(event + "\n")
35
+ }
36
+
37
+ const ignored = [
38
+ /(^|[/\\])\./, // dotfiles and dotdirs (including .git, .litmus)
39
+ /node_modules/,
40
+ /__pycache__/,
41
+ /\.pyc$/,
42
+ /\.class$/,
43
+ ]
44
+
45
+ chokidar
46
+ .watch(projectDir, {
47
+ ignored,
48
+ ignoreInitial: true,
49
+ persistent: true,
50
+ awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 50 },
51
+ })
52
+ .on("add", (p) => write("add", p))
53
+ .on("change", (p) => write("change", p))
54
+ .on("unlink", (p) => write("unlink", p))
55
+ .on("error", (err) => {
56
+ // Log errors but don't crash — tracking is non-critical
57
+ process.stderr.write(`[watcher] error: ${err}\n`)
58
+ })
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "litmus-cli",
3
+ "version": "0.2.0",
4
+ "description": "CLI tool for Litmus engineering assessments",
5
+ "license": "MIT",
6
+ "author": "elenazhao",
7
+ "type": "module",
8
+ "main": "dist/index.js",
9
+ "bin": {
10
+ "litmus": "dist/index.js"
11
+ },
12
+ "scripts": {
13
+ "build": "tsc && cp src/lib/watcher.cjs dist/watcher.cjs",
14
+ "dev": "tsc --watch",
15
+ "start": "node dist/index.js",
16
+ "prepublishOnly": "npm run build"
17
+ },
18
+ "dependencies": {
19
+ "archiver": "^7.0.1",
20
+ "chalk": "^5.3.0",
21
+ "chokidar": "^3.6.0",
22
+ "commander": "^12.1.0",
23
+ "form-data": "^4.0.1",
24
+ "node-fetch": "^3.3.2",
25
+ "ora": "^8.1.1",
26
+ "unzipper": "^0.12.3"
27
+ },
28
+ "devDependencies": {
29
+ "@types/archiver": "^6.0.3",
30
+ "@types/node": "^20.0.0",
31
+ "@types/unzipper": "^0.10.10",
32
+ "typescript": "^5.4.5"
33
+ },
34
+ "engines": {
35
+ "node": ">=18.0.0"
36
+ }
37
+ }
@@ -0,0 +1,171 @@
1
+ import { createWriteStream, unlinkSync } from "fs"
2
+ import { pipeline } from "stream/promises"
3
+ import { Readable } from "stream"
4
+ import path from "path"
5
+ import os from "os"
6
+ import chalk from "chalk"
7
+ import ora from "ora"
8
+ import { fetchInitMetadata, downloadZip } from "../lib/api.js"
9
+ import { writeConfig } from "../lib/config.js"
10
+ import { extractZip } from "../lib/extract.js"
11
+ import { startTracker } from "../lib/tracker.js"
12
+ import { detectProject } from "../utils/detect-project.js"
13
+ import { fatal, success, info, warn, FALLBACK_URL } from "../utils/errors.js"
14
+ import { execSync } from "child_process"
15
+
16
+ const DEFAULT_API_BASE = process.env.LITMUS_API_URL || "https://app.litmus.sh"
17
+
18
+ export async function runInit(token: string): Promise<void> {
19
+ const apiBase = DEFAULT_API_BASE
20
+
21
+ // Step 1: Fetch metadata
22
+ const spinner = ora("Connecting to Litmus...").start()
23
+ let metadata: Awaited<ReturnType<typeof fetchInitMetadata>>
24
+
25
+ try {
26
+ metadata = await fetchInitMetadata(apiBase, token)
27
+ spinner.succeed("Connected")
28
+ } catch (err) {
29
+ spinner.fail("Connection failed")
30
+ const msg = err instanceof Error ? err.message : String(err)
31
+ fatal(msg, `Check your token or visit ${FALLBACK_URL} to download manually.`)
32
+ }
33
+
34
+ // Check for naming collision
35
+ const targetDir = path.resolve(process.cwd(), metadata.folderName)
36
+ const { existsSync } = await import("fs")
37
+ if (existsSync(targetDir)) {
38
+ fatal(
39
+ `Folder "${metadata.folderName}" already exists in this directory.`,
40
+ `Delete or rename it, then run: litmus init ${token}`
41
+ )
42
+ }
43
+
44
+ // Print what we're setting up
45
+ console.log()
46
+ console.log(chalk.bold(` ${metadata.assessmentName}`))
47
+ if (metadata.deadline) {
48
+ const deadline = new Date(metadata.deadline)
49
+ const hoursLeft = Math.max(
50
+ 0,
51
+ Math.floor((deadline.getTime() - Date.now()) / (1000 * 60 * 60))
52
+ )
53
+ const deadlineStr = deadline.toLocaleString()
54
+ if (hoursLeft < 24) {
55
+ warn(`Deadline in ${hoursLeft} hours (${deadlineStr})`)
56
+ } else {
57
+ info(`Deadline: ${deadlineStr}`)
58
+ }
59
+ }
60
+ if (metadata.timeLimit) {
61
+ info(`Time limit: ${metadata.timeLimit} minutes`)
62
+ }
63
+ console.log()
64
+
65
+ // Step 2: Download ZIP to a temp file
66
+ const downloadSpinner = ora("Downloading assessment...").start()
67
+ const tmpZip = path.join(os.tmpdir(), `litmus-${Date.now()}.zip`)
68
+
69
+ try {
70
+ const zipResponse = await downloadZip(apiBase, token)
71
+ if (!zipResponse.body) {
72
+ throw new Error("Empty response from server")
73
+ }
74
+ const nodeStream = Readable.fromWeb(
75
+ zipResponse.body as Parameters<typeof Readable.fromWeb>[0]
76
+ )
77
+ await pipeline(nodeStream, createWriteStream(tmpZip))
78
+ downloadSpinner.succeed("Downloaded")
79
+ } catch (err) {
80
+ downloadSpinner.fail("Download failed")
81
+ const msg = err instanceof Error ? err.message : String(err)
82
+ fatal(msg, `Visit ${FALLBACK_URL} to download manually.`)
83
+ }
84
+
85
+ // Step 3: Extract from temp file to target directory
86
+ const extractSpinner = ora(`Setting up ${metadata.folderName}/...`).start()
87
+
88
+ try {
89
+ await extractZip(tmpZip, targetDir)
90
+ extractSpinner.succeed(`Created ${chalk.bold(metadata.folderName)}/`)
91
+ } catch (err) {
92
+ extractSpinner.fail("Extraction failed")
93
+ const msg = err instanceof Error ? err.message : String(err)
94
+ fatal(msg, `Visit ${FALLBACK_URL} to download manually.`)
95
+ } finally {
96
+ try { unlinkSync(tmpZip) } catch { /* cleanup */ }
97
+ }
98
+
99
+ // Step 4: Initialize git repo with initial commit
100
+ try {
101
+ execSync("git init", { cwd: targetDir, stdio: "pipe" })
102
+ execSync("git add -A", { cwd: targetDir, stdio: "pipe" })
103
+ execSync(
104
+ "git commit -m \"Assessment: initial state\"",
105
+ { cwd: targetDir, stdio: "pipe" }
106
+ )
107
+ } catch {
108
+ // Non-critical — git may not be installed; analysis degrades gracefully
109
+ }
110
+
111
+ // Step 5: Write .litmus/config.json
112
+ await writeConfig(targetDir, {
113
+ assessmentId: metadata.assessmentId,
114
+ assessmentName: metadata.assessmentName,
115
+ candidateEmail: metadata.candidateEmail,
116
+ candidateName: metadata.candidateName,
117
+ token,
118
+ startedAt: metadata.startedAt,
119
+ deadline: metadata.deadline,
120
+ timeLimit: metadata.timeLimit,
121
+ apiBase,
122
+ })
123
+
124
+ // Step 5: Detect project type and optionally install
125
+ const project = detectProject(targetDir)
126
+ if (project.installCommand) {
127
+ console.log()
128
+ const installSpinner = ora(
129
+ `Running ${chalk.cyan(project.installCommand)}... (this may take a minute)`
130
+ ).start()
131
+ try {
132
+ execSync(project.installCommand, {
133
+ cwd: targetDir,
134
+ stdio: "pipe",
135
+ })
136
+ installSpinner.succeed("Dependencies installed")
137
+ } catch {
138
+ installSpinner.warn(
139
+ `Couldn't run "${project.installCommand}" automatically. Run it manually inside ${metadata.folderName}/.`
140
+ )
141
+ }
142
+ }
143
+
144
+ // Step 6: Start tracker in background
145
+ try {
146
+ startTracker(targetDir)
147
+ } catch {
148
+ // Non-critical
149
+ }
150
+
151
+ // Done — print next steps
152
+ console.log()
153
+ success("Assessment ready")
154
+ console.log()
155
+ console.log(chalk.bold(" Next steps:"))
156
+ console.log()
157
+ console.log(` ${chalk.cyan(`cd ${metadata.folderName}`)}`)
158
+ if (project.runHint) {
159
+ console.log(` ${chalk.dim(`# ${project.runHint}`)}`)
160
+ }
161
+ console.log()
162
+ console.log(chalk.dim(" Your work is being tracked. When you're done:"))
163
+ console.log()
164
+ console.log(` ${chalk.cyan("litmus submit")}`)
165
+ console.log()
166
+ if (metadata.deadline) {
167
+ const deadline = new Date(metadata.deadline)
168
+ console.log(chalk.dim(` Deadline: ${deadline.toLocaleString()}`))
169
+ console.log()
170
+ }
171
+ }
@@ -0,0 +1,141 @@
1
+ import { statSync, unlinkSync } from "fs"
2
+ import path from "path"
3
+ import chalk from "chalk"
4
+ import ora from "ora"
5
+ import * as readline from "readline/promises"
6
+ import { stdin as input, stdout as output } from "process"
7
+ import { readConfig } from "../lib/config.js"
8
+ import { submitZip } from "../lib/api.js"
9
+ import { createSubmissionZip } from "../lib/zip.js"
10
+ import { fatal, success, warn, info, FALLBACK_URL } from "../utils/errors.js"
11
+
12
+ export async function runSubmit(opts: { yes?: boolean }): Promise<void> {
13
+ // Step 1: Read config
14
+ const config = await readConfig()
15
+
16
+ if (!config) {
17
+ fatal(
18
+ "No Litmus assessment found in this directory.",
19
+ "Run this command from inside your assessment folder, or run 'litmus init <token>' first."
20
+ )
21
+ }
22
+
23
+ // Step 2: Check deadline
24
+ if (config.deadline) {
25
+ const deadline = new Date(config.deadline)
26
+ const now = new Date()
27
+
28
+ if (now > deadline) {
29
+ fatal(
30
+ `The submission deadline passed at ${deadline.toLocaleString()}.`,
31
+ `Contact the company if you need an extension. Visit ${FALLBACK_URL} for help.`
32
+ )
33
+ }
34
+
35
+ const minsLeft = Math.floor((deadline.getTime() - now.getTime()) / (1000 * 60))
36
+ if (minsLeft < 30) {
37
+ warn(`Only ${minsLeft} minutes left before the deadline!`)
38
+ }
39
+ }
40
+
41
+ // Step 3: Find the project root (where .litmus/config.json lives)
42
+ let projectRoot = process.cwd()
43
+ // readConfig walks up the tree, so we use the config's assessed location
44
+ // The config file is at .litmus/config.json relative to the project root
45
+ // We find it by searching from cwd up, same as readConfig does
46
+ {
47
+ let dir = process.cwd()
48
+ while (true) {
49
+ const configPath = path.join(dir, ".litmus", "config.json")
50
+ const { existsSync } = await import("fs")
51
+ if (existsSync(configPath)) {
52
+ projectRoot = dir
53
+ break
54
+ }
55
+ const parent = path.dirname(dir)
56
+ if (parent === dir) break
57
+ dir = parent
58
+ }
59
+ }
60
+
61
+ // Step 4: Create the submission ZIP
62
+ const zipSpinner = ora("Preparing submission...").start()
63
+ let zipPath: string
64
+
65
+ try {
66
+ zipPath = await createSubmissionZip(projectRoot)
67
+ zipSpinner.succeed("Submission prepared")
68
+ } catch (err) {
69
+ zipSpinner.fail("Failed to create submission archive")
70
+ const msg = err instanceof Error ? err.message : String(err)
71
+ fatal(msg, `Visit ${FALLBACK_URL} to submit manually.`)
72
+ }
73
+
74
+ // Step 5: Show what's being submitted
75
+ const zipSizeMb = (statSync(zipPath).size / (1024 * 1024)).toFixed(1)
76
+ const folderName = path.basename(projectRoot)
77
+
78
+ console.log()
79
+ console.log(chalk.bold(` Submitting: ${config.assessmentName}`))
80
+ info(` Archive size: ${zipSizeMb} MB`)
81
+ info(` Tracking log included`)
82
+ if (config.deadline) {
83
+ info(` Deadline: ${new Date(config.deadline).toLocaleString()}`)
84
+ }
85
+ console.log()
86
+
87
+ // Step 6: Confirm (unless --yes flag)
88
+ if (!opts.yes) {
89
+ const rl = readline.createInterface({ input, output })
90
+ let answer: string
91
+ try {
92
+ answer = await rl.question(
93
+ chalk.bold(" Confirm submission? ") + chalk.dim("[Y/n] ")
94
+ )
95
+ } finally {
96
+ rl.close()
97
+ }
98
+
99
+ const confirmed = answer.trim().toLowerCase()
100
+ if (confirmed !== "" && confirmed !== "y" && confirmed !== "yes") {
101
+ console.log()
102
+ console.log(chalk.dim(" Submission cancelled. Your work is saved."))
103
+ console.log()
104
+ try { unlinkSync(zipPath) } catch { /* cleanup */ }
105
+ process.exit(0)
106
+ }
107
+ }
108
+
109
+ console.log()
110
+
111
+ // Step 7: Upload
112
+ const uploadSpinner = ora("Uploading submission...").start()
113
+
114
+ try {
115
+ const fileName = `${folderName}_submission.zip`
116
+ const result = await submitZip(config.apiBase, config.token, zipPath, fileName)
117
+ uploadSpinner.succeed("Submission received")
118
+
119
+ console.log()
120
+ success("You're done!")
121
+ console.log()
122
+ console.log(
123
+ chalk.dim(" The company will review your work and be in touch.")
124
+ )
125
+ if (result.submissionId) {
126
+ console.log(chalk.dim(` Submission ID: ${result.submissionId}`))
127
+ }
128
+ console.log()
129
+ } catch (err) {
130
+ uploadSpinner.fail("Upload failed")
131
+ const msg = err instanceof Error ? err.message : String(err)
132
+ console.log()
133
+ fatal(
134
+ msg,
135
+ `To submit manually, visit ${FALLBACK_URL} and upload your ZIP from there.`
136
+ )
137
+ } finally {
138
+ // Clean up temp ZIP
139
+ try { unlinkSync(zipPath) } catch { /* cleanup */ }
140
+ }
141
+ }