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
package/src/index.ts ADDED
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env node
2
+ import { program } from "commander"
3
+ import { runInit } from "./commands/init.js"
4
+ import { runSubmit } from "./commands/submit.js"
5
+
6
+ const VERSION = "0.1.0"
7
+
8
+ program
9
+ .name("litmus")
10
+ .description("Litmus engineering assessment CLI")
11
+ .version(VERSION)
12
+
13
+ program
14
+ .command("init <token>")
15
+ .description("Download and set up a Litmus assessment")
16
+ .action(async (token: string) => {
17
+ try {
18
+ await runInit(token)
19
+ } catch (err) {
20
+ console.error(err instanceof Error ? err.message : String(err))
21
+ process.exit(1)
22
+ }
23
+ })
24
+
25
+ program
26
+ .command("submit")
27
+ .description("Submit your completed assessment")
28
+ .option("-y, --yes", "Skip confirmation prompt")
29
+ .action(async (opts: { yes?: boolean }) => {
30
+ try {
31
+ await runSubmit(opts)
32
+ } catch (err) {
33
+ console.error(err instanceof Error ? err.message : String(err))
34
+ process.exit(1)
35
+ }
36
+ })
37
+
38
+ program.parse()
package/src/lib/api.ts ADDED
@@ -0,0 +1,93 @@
1
+ import FormData from "form-data"
2
+ import { createReadStream } from "fs"
3
+
4
+ export interface InitMetadata {
5
+ assessmentId: string
6
+ assessmentName: string
7
+ folderName: string
8
+ candidateEmail: string
9
+ candidateName: string
10
+ deadline: string | null
11
+ timeLimit: number | null
12
+ startedAt: string
13
+ }
14
+
15
+ export interface SubmitResult {
16
+ success: boolean
17
+ submissionId: string
18
+ message: string
19
+ }
20
+
21
+ /**
22
+ * Fetch assessment metadata by token.
23
+ * Marks the candidate as in_progress if they were pending.
24
+ */
25
+ export async function fetchInitMetadata(
26
+ apiBase: string,
27
+ token: string
28
+ ): Promise<InitMetadata> {
29
+ const url = `${apiBase}/api/cli/init?token=${encodeURIComponent(token)}`
30
+ const res = await fetch(url)
31
+ const body = await res.json().catch(() => ({})) as Record<string, unknown>
32
+
33
+ if (!res.ok) {
34
+ const message = (body.message as string) || (body.error as string) || `Server returned ${res.status}`
35
+ throw new Error(message)
36
+ }
37
+
38
+ return body as unknown as InitMetadata
39
+ }
40
+
41
+ /**
42
+ * Download the candidate ZIP by token.
43
+ * Returns a ReadableStream of the ZIP bytes.
44
+ */
45
+ export async function downloadZip(
46
+ apiBase: string,
47
+ token: string
48
+ ): Promise<Response> {
49
+ const url = `${apiBase}/api/cli/download?token=${encodeURIComponent(token)}`
50
+ const res = await fetch(url)
51
+
52
+ if (!res.ok) {
53
+ const body = await res.json().catch(() => ({})) as Record<string, unknown>
54
+ const message = (body.message as string) || (body.error as string) || `Server returned ${res.status}`
55
+ throw new Error(message)
56
+ }
57
+
58
+ return res
59
+ }
60
+
61
+ /**
62
+ * Upload a ZIP file for submission.
63
+ */
64
+ export async function submitZip(
65
+ apiBase: string,
66
+ token: string,
67
+ zipPath: string,
68
+ fileName: string
69
+ ): Promise<SubmitResult> {
70
+ const url = `${apiBase}/api/cli/submit?token=${encodeURIComponent(token)}`
71
+
72
+ const form = new FormData()
73
+ form.append("file", createReadStream(zipPath), {
74
+ filename: fileName,
75
+ contentType: "application/zip",
76
+ })
77
+
78
+ const res = await fetch(url, {
79
+ method: "POST",
80
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
81
+ body: form as any,
82
+ headers: form.getHeaders(),
83
+ })
84
+
85
+ const body = await res.json().catch(() => ({})) as Record<string, unknown>
86
+
87
+ if (!res.ok) {
88
+ const message = (body.message as string) || (body.error as string) || `Server returned ${res.status}`
89
+ throw new Error(message)
90
+ }
91
+
92
+ return body as unknown as SubmitResult
93
+ }
@@ -0,0 +1,59 @@
1
+ import { readFile, writeFile, mkdir } from "fs/promises"
2
+ import { existsSync } from "fs"
3
+ import path from "path"
4
+
5
+ export interface LitmusConfig {
6
+ assessmentId: string
7
+ assessmentName: string
8
+ candidateEmail: string
9
+ candidateName: string
10
+ token: string
11
+ startedAt: string
12
+ deadline: string | null
13
+ timeLimit: number | null // minutes
14
+ apiBase: string
15
+ }
16
+
17
+ const CONFIG_DIR = ".litmus"
18
+ const CONFIG_FILE = "config.json"
19
+
20
+ export function getConfigPath(projectRoot: string): string {
21
+ return path.join(projectRoot, CONFIG_DIR, CONFIG_FILE)
22
+ }
23
+
24
+ export async function writeConfig(
25
+ projectRoot: string,
26
+ config: LitmusConfig
27
+ ): Promise<void> {
28
+ const configDir = path.join(projectRoot, CONFIG_DIR)
29
+ await mkdir(configDir, { recursive: true })
30
+ const configPath = path.join(configDir, CONFIG_FILE)
31
+ await writeFile(configPath, JSON.stringify(config, null, 2), "utf8")
32
+ }
33
+
34
+ /**
35
+ * Read .litmus/config.json from the current directory or a parent directory.
36
+ * Returns null if no config is found.
37
+ */
38
+ export async function readConfig(): Promise<LitmusConfig | null> {
39
+ let dir = process.cwd()
40
+
41
+ // Walk up the directory tree looking for .litmus/config.json
42
+ while (true) {
43
+ const configPath = path.join(dir, CONFIG_DIR, CONFIG_FILE)
44
+ if (existsSync(configPath)) {
45
+ try {
46
+ const raw = await readFile(configPath, "utf8")
47
+ return JSON.parse(raw) as LitmusConfig
48
+ } catch {
49
+ return null
50
+ }
51
+ }
52
+
53
+ const parent = path.dirname(dir)
54
+ if (parent === dir) break // Reached filesystem root
55
+ dir = parent
56
+ }
57
+
58
+ return null
59
+ }
@@ -0,0 +1,15 @@
1
+ import { mkdir } from "fs/promises"
2
+ import unzipper from "unzipper"
3
+
4
+ /**
5
+ * Extract a ZIP file from disk to a target directory.
6
+ * Using Open.file + extract() is the most reliable unzipper API.
7
+ */
8
+ export async function extractZip(
9
+ zipPath: string,
10
+ targetDir: string
11
+ ): Promise<void> {
12
+ await mkdir(targetDir, { recursive: true })
13
+ const directory = await unzipper.Open.file(zipPath)
14
+ await directory.extract({ path: targetDir })
15
+ }
@@ -0,0 +1,38 @@
1
+ import { spawn } from "child_process"
2
+ import { mkdirSync, writeFileSync } from "fs"
3
+ import path from "path"
4
+ import { fileURLToPath } from "url"
5
+
6
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
7
+
8
+ /**
9
+ * Start the chokidar file watcher in the background.
10
+ * Spawns dist/watcher.cjs as a detached process; logs file events to
11
+ * <projectDir>/.litmus/activity.jsonl for later inclusion in the submission ZIP.
12
+ */
13
+ export function startTracker(projectDir: string): void {
14
+ const litmusDir = path.join(projectDir, ".litmus")
15
+ const activityLog = path.join(litmusDir, "activity.jsonl")
16
+ const pidFile = path.join(litmusDir, "tracker.pid")
17
+ const watcherScript = path.join(__dirname, "watcher.cjs")
18
+
19
+ try {
20
+ mkdirSync(litmusDir, { recursive: true })
21
+ } catch {
22
+ // Non-critical
23
+ }
24
+
25
+ const child = spawn("node", [watcherScript, projectDir, activityLog], {
26
+ detached: true,
27
+ stdio: "ignore",
28
+ })
29
+
30
+ if (child.pid) {
31
+ child.unref()
32
+ try {
33
+ writeFileSync(pidFile, String(child.pid), "utf8")
34
+ } catch {
35
+ // Non-critical
36
+ }
37
+ }
38
+ }
@@ -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/src/lib/zip.ts ADDED
@@ -0,0 +1,104 @@
1
+ import archiver from "archiver"
2
+ import { createWriteStream, readFileSync, existsSync } from "fs"
3
+ import path from "path"
4
+ import os from "os"
5
+
6
+ // Directories always excluded from submissions.
7
+ // NOTE: .git is intentionally NOT excluded — git history is included so the
8
+ // analysis backend can run parse_git_history() on the submission.
9
+ const ALWAYS_EXCLUDE = [
10
+ "node_modules",
11
+ "__pycache__",
12
+ "venv",
13
+ ".venv",
14
+ "env",
15
+ ".env",
16
+ "target", // Rust/Java build output
17
+ "build",
18
+ "dist",
19
+ ".gradle",
20
+ ".idea",
21
+ ".DS_Store",
22
+ "*.pyc",
23
+ "*.class",
24
+ // Legacy injected tracking files — exclude so they don't pollute submissions
25
+ // from assessments downloaded before the CLI-native tracking was deployed.
26
+ "setup.sh",
27
+ "tracker.py",
28
+ "tracker.js",
29
+ "heartbeat.py",
30
+ ]
31
+
32
+ /**
33
+ * Create a ZIP of the project directory for submission.
34
+ * Respects .gitignore patterns (basic support) and excludes common large dirs.
35
+ * Always includes .litmus/activity.jsonl (the tracking log).
36
+ *
37
+ * @param projectDir - Root of the project to zip
38
+ * @returns Path to the temporary ZIP file
39
+ */
40
+ export async function createSubmissionZip(projectDir: string): Promise<string> {
41
+ const tmpFile = path.join(os.tmpdir(), `litmus-submission-${Date.now()}.zip`)
42
+ const output = createWriteStream(tmpFile)
43
+
44
+ const archive = archiver("zip", { zlib: { level: 6 } })
45
+
46
+ return new Promise((resolve, reject) => {
47
+ output.on("close", () => resolve(tmpFile))
48
+ archive.on("error", reject)
49
+ archive.pipe(output)
50
+
51
+ // Compute glob patterns to ignore
52
+ const ignoredGlobs = buildIgnoreGlobs(projectDir)
53
+
54
+ // Add the project directory contents (not the directory itself)
55
+ archive.glob("**/*", {
56
+ cwd: projectDir,
57
+ dot: true, // Include dotfiles like .litmus/
58
+ ignore: ignoredGlobs,
59
+ })
60
+
61
+ archive.finalize()
62
+ })
63
+ }
64
+
65
+ function buildIgnoreGlobs(projectDir: string): string[] {
66
+ const patterns: string[] = []
67
+
68
+ // Always-excluded dirs/patterns
69
+ for (const excluded of ALWAYS_EXCLUDE) {
70
+ if (excluded.startsWith("*")) {
71
+ patterns.push(excluded)
72
+ } else {
73
+ patterns.push(`${excluded}/**`)
74
+ patterns.push(`**/${excluded}/**`)
75
+ }
76
+ }
77
+
78
+ // Basic .gitignore support
79
+ const gitignorePath = path.join(projectDir, ".gitignore")
80
+ if (existsSync(gitignorePath)) {
81
+ try {
82
+ const lines = readFileSync(gitignorePath, "utf8")
83
+ .split("\n")
84
+ .map((l) => l.trim())
85
+ .filter((l) => l && !l.startsWith("#"))
86
+
87
+ for (const line of lines) {
88
+ if (line.endsWith("/")) {
89
+ // Directory pattern
90
+ const dir = line.slice(0, -1)
91
+ patterns.push(`${dir}/**`)
92
+ patterns.push(`**/${dir}/**`)
93
+ } else {
94
+ patterns.push(line)
95
+ patterns.push(`**/${line}`)
96
+ }
97
+ }
98
+ } catch {
99
+ // Ignore .gitignore parse errors
100
+ }
101
+ }
102
+
103
+ return patterns
104
+ }
@@ -0,0 +1,83 @@
1
+ import { existsSync } from "fs"
2
+ import path from "path"
3
+
4
+ export type ProjectType =
5
+ | "node"
6
+ | "python"
7
+ | "go"
8
+ | "java"
9
+ | "dotnet"
10
+ | "rust"
11
+ | "ruby"
12
+ | "php"
13
+ | "unknown"
14
+
15
+ export interface ProjectInfo {
16
+ type: ProjectType
17
+ installCommand: string | null
18
+ runHint: string | null
19
+ }
20
+
21
+ /**
22
+ * Detect project type and suggest an install command.
23
+ */
24
+ export function detectProject(dir: string): ProjectInfo {
25
+ if (existsSync(path.join(dir, "package.json"))) {
26
+ const hasYarnLock = existsSync(path.join(dir, "yarn.lock"))
27
+ const hasPnpmLock = existsSync(path.join(dir, "pnpm-lock.yaml"))
28
+ const installCommand = hasPnpmLock
29
+ ? "pnpm install"
30
+ : hasYarnLock
31
+ ? "yarn"
32
+ : "npm install"
33
+ return { type: "node", installCommand, runHint: "npm start / npm test" }
34
+ }
35
+
36
+ if (
37
+ existsSync(path.join(dir, "requirements.txt")) ||
38
+ existsSync(path.join(dir, "pyproject.toml")) ||
39
+ existsSync(path.join(dir, "setup.py"))
40
+ ) {
41
+ // Create a venv and install into it (replaces what the old setup.sh did).
42
+ // Use venv/bin/pip directly so activation isn't required at install time.
43
+ const pipBin = process.platform === "win32" ? "venv\\Scripts\\pip" : "venv/bin/pip"
44
+ const installCommand = existsSync(path.join(dir, "requirements.txt"))
45
+ ? `python3 -m venv venv && ${pipBin} install -r requirements.txt`
46
+ : `python3 -m venv venv && ${pipBin} install -e .`
47
+ return {
48
+ type: "python",
49
+ installCommand,
50
+ runHint: "source venv/bin/activate # then: python main.py / pytest",
51
+ }
52
+ }
53
+
54
+ if (existsSync(path.join(dir, "go.mod"))) {
55
+ return { type: "go", installCommand: "go mod download", runHint: "go run . / go test ./..." }
56
+ }
57
+
58
+ if (existsSync(path.join(dir, "pom.xml"))) {
59
+ return { type: "java", installCommand: "mvn install", runHint: "mvn test" }
60
+ }
61
+
62
+ if (existsSync(path.join(dir, "build.gradle")) || existsSync(path.join(dir, "build.gradle.kts"))) {
63
+ return { type: "java", installCommand: "gradle build", runHint: "gradle test" }
64
+ }
65
+
66
+ if (existsSync(path.join(dir, "*.csproj")) || existsSync(path.join(dir, "*.sln"))) {
67
+ return { type: "dotnet", installCommand: "dotnet restore", runHint: "dotnet run / dotnet test" }
68
+ }
69
+
70
+ if (existsSync(path.join(dir, "Cargo.toml"))) {
71
+ return { type: "rust", installCommand: null, runHint: "cargo run / cargo test" }
72
+ }
73
+
74
+ if (existsSync(path.join(dir, "Gemfile"))) {
75
+ return { type: "ruby", installCommand: "bundle install", runHint: "ruby main.rb / rspec" }
76
+ }
77
+
78
+ if (existsSync(path.join(dir, "composer.json"))) {
79
+ return { type: "php", installCommand: "composer install", runHint: "php -S localhost:8000" }
80
+ }
81
+
82
+ return { type: "unknown", installCommand: null, runHint: null }
83
+ }
@@ -0,0 +1,41 @@
1
+ import chalk from "chalk"
2
+
3
+ export const FALLBACK_URL = "https://app.litmus.sh"
4
+
5
+ /**
6
+ * Print a plain-English error and exit.
7
+ * Always shows a fallback URL for candidates who can't resolve the issue.
8
+ */
9
+ export function fatal(message: string, hint?: string): never {
10
+ console.error()
11
+ console.error(chalk.red("✗ Error: ") + message)
12
+ if (hint) {
13
+ console.error(chalk.dim(" Hint: ") + hint)
14
+ }
15
+ console.error(
16
+ chalk.dim(` Need help? Visit ${FALLBACK_URL} to manage your assessment.`)
17
+ )
18
+ console.error()
19
+ process.exit(1)
20
+ }
21
+
22
+ /**
23
+ * Print a warning (non-fatal).
24
+ */
25
+ export function warn(message: string): void {
26
+ console.warn(chalk.yellow("⚠ Warning: ") + message)
27
+ }
28
+
29
+ /**
30
+ * Print a success message.
31
+ */
32
+ export function success(message: string): void {
33
+ console.log(chalk.green("✓ ") + message)
34
+ }
35
+
36
+ /**
37
+ * Print a plain info line.
38
+ */
39
+ export function info(message: string): void {
40
+ console.log(chalk.dim(" ") + message)
41
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "lib": ["ES2020"],
7
+ "outDir": "dist",
8
+ "rootDir": "src",
9
+ "strict": true,
10
+ "esModuleInterop": true,
11
+ "skipLibCheck": true,
12
+ "resolveJsonModule": true,
13
+ "declaration": true,
14
+ "declarationMap": true,
15
+ "sourceMap": true
16
+ },
17
+ "include": ["src/**/*"],
18
+ "exclude": ["node_modules", "dist"]
19
+ }