litmus-cli 1.0.6 → 1.0.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "litmus-cli",
3
- "version": "1.0.6",
3
+ "version": "1.0.7",
4
4
  "description": "CLI tool for Litmus engineering assessments",
5
5
  "license": "MIT",
6
6
  "author": "elenazhao",
@@ -1,177 +0,0 @@
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://www.litmus.build"
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 and try again, or contact the company for help.`)
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 re-run litmus init with your 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, `Contact the company for help or visit ${FALLBACK_URL}.`)
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, `Contact the company for help or visit ${FALLBACK_URL}.`)
95
- } finally {
96
- try { unlinkSync(tmpZip) } catch { /* cleanup */ }
97
- }
98
-
99
- // Step 4: Initialize git repo with initial commit
100
- try {
101
- execSync("git --version", { stdio: "pipe" })
102
- try {
103
- execSync("git init", { cwd: targetDir, stdio: "pipe" })
104
- execSync("git add -A", { cwd: targetDir, stdio: "pipe" })
105
- execSync(
106
- "git commit -m \"Assessment: initial state\"",
107
- { cwd: targetDir, stdio: "pipe" }
108
- )
109
- } catch {
110
- warn("Git is installed but failed to initialize. Check your git config (user.name / user.email).")
111
- }
112
- } catch {
113
- // git not installed — non-critical, analysis degrades gracefully
114
- }
115
-
116
- // Step 5: Write .litmus/config.json
117
- await writeConfig(targetDir, {
118
- assessmentId: metadata.assessmentId,
119
- assessmentName: metadata.assessmentName,
120
- candidateEmail: metadata.candidateEmail,
121
- candidateName: metadata.candidateName,
122
- token,
123
- startedAt: metadata.startedAt,
124
- deadline: metadata.deadline,
125
- timeLimit: metadata.timeLimit,
126
- apiBase,
127
- })
128
-
129
- // Step 5: Detect project type and optionally install
130
- const project = detectProject(targetDir)
131
- if (project.installCommand) {
132
- console.log()
133
- const installSpinner = ora(
134
- `Running ${chalk.cyan(project.installCommand)}... (this may take a minute)`
135
- ).start()
136
- try {
137
- execSync(project.installCommand, {
138
- cwd: targetDir,
139
- stdio: "pipe",
140
- })
141
- installSpinner.succeed("Dependencies installed")
142
- } catch {
143
- installSpinner.warn(
144
- `Couldn't run "${project.installCommand}" automatically. Run it manually inside ${metadata.folderName}/.`
145
- )
146
- }
147
- }
148
-
149
- // Step 6: Start tracker in background
150
- try {
151
- startTracker(targetDir)
152
- } catch {
153
- // Non-critical
154
- }
155
-
156
- // Done — print next steps
157
- console.log()
158
- success("Assessment ready")
159
- console.log()
160
- console.log(chalk.bold(" Next steps:"))
161
- console.log()
162
- console.log(` ${chalk.cyan(`cd ${metadata.folderName}`)}`)
163
- console.log(` ${chalk.dim("# Optional: open in a new window")}`)
164
- console.log(` ${chalk.cyan("code .")} ${chalk.dim("or")} ${chalk.cyan("cursor .")}`)
165
- console.log()
166
- console.log(chalk.dim(" Commit often to save your progress."))
167
- console.log()
168
- console.log(chalk.dim(" Your work is being tracked. When you're done:"))
169
- console.log()
170
- console.log(` ${chalk.cyan("litmus submit")}`)
171
- console.log()
172
- if (metadata.deadline) {
173
- const deadline = new Date(metadata.deadline)
174
- console.log(chalk.dim(` Deadline: ${deadline.toLocaleString()}`))
175
- console.log()
176
- }
177
- }
@@ -1,145 +0,0 @@
1
- import { existsSync, readFileSync, statSync, readdirSync } from "fs"
2
- import path from "path"
3
- import chalk from "chalk"
4
- import { readConfig, findProjectRoot } from "../lib/config.js"
5
- import { fatal, info } from "../utils/errors.js"
6
- import { execSync } from "child_process"
7
-
8
- export async function runStatus(): Promise<void> {
9
- const config = await readConfig()
10
-
11
- if (!config) {
12
- fatal(
13
- "No Litmus assessment found in this directory.",
14
- "Run this command from inside your assessment folder, or run 'litmus init <token>' first."
15
- )
16
- }
17
-
18
- const projectRoot = findProjectRoot() ?? process.cwd()
19
- const folderName = path.basename(projectRoot)
20
-
21
- console.log()
22
- console.log(chalk.bold(` ${config.assessmentName}`))
23
- console.log()
24
-
25
- // Candidate info
26
- info(` Candidate: ${config.candidateName} (${config.candidateEmail})`)
27
- info(` Folder: ${folderName}/`)
28
-
29
- // Time elapsed since init
30
- const startedAt = new Date(config.startedAt)
31
- const elapsed = Date.now() - startedAt.getTime()
32
- const elapsedHours = Math.floor(elapsed / (1000 * 60 * 60))
33
- const elapsedMins = Math.floor((elapsed % (1000 * 60 * 60)) / (1000 * 60))
34
- info(` Started: ${startedAt.toLocaleString()} (${elapsedHours}h ${elapsedMins}m ago)`)
35
-
36
- // Deadline / time remaining
37
- if (config.deadline) {
38
- const deadline = new Date(config.deadline)
39
- const remaining = deadline.getTime() - Date.now()
40
-
41
- if (remaining <= 0) {
42
- console.log(chalk.red(` Deadline: PASSED (${deadline.toLocaleString()})`))
43
- } else {
44
- const remHours = Math.floor(remaining / (1000 * 60 * 60))
45
- const remMins = Math.floor((remaining % (1000 * 60 * 60)) / (1000 * 60))
46
- info(` Deadline: ${deadline.toLocaleString()} (${remHours}h ${remMins}m remaining)`)
47
- }
48
- }
49
-
50
- if (config.timeLimit) {
51
- const timeLimitMs = config.timeLimit * 60 * 1000
52
- const used = Date.now() - startedAt.getTime()
53
- const remaining = timeLimitMs - used
54
- if (remaining <= 0) {
55
- console.log(chalk.red(` Time limit: EXCEEDED (${config.timeLimit} minutes)`))
56
- } else {
57
- const remMins = Math.floor(remaining / (1000 * 60))
58
- info(` Time limit: ${config.timeLimit} min (${remMins} min remaining)`)
59
- }
60
- }
61
-
62
- console.log()
63
-
64
- // Git stats
65
- try {
66
- execSync("git --version", { stdio: "pipe" })
67
- try {
68
- const commitCount = execSync("git rev-list --count HEAD", { cwd: projectRoot, stdio: "pipe" }).toString().trim()
69
- const lastCommit = execSync("git log -1 --format=%s", { cwd: projectRoot, stdio: "pipe" }).toString().trim()
70
- info(` Commits: ${commitCount}`)
71
- info(` Last commit: "${lastCommit}"`)
72
- } catch {
73
- info(` Commits: no commits yet`)
74
- }
75
- } catch {
76
- info(` Git: not installed`)
77
- }
78
-
79
- // Estimated submission size (count files, approximate size)
80
- const sizeInfo = estimateSubmissionSize(projectRoot)
81
- info(` Files: ~${sizeInfo.fileCount}`)
82
- info(` Estimated submission size: ~${sizeInfo.sizeMb} MB`)
83
-
84
- // Tracker status
85
- const pidFile = path.join(projectRoot, ".litmus", "tracker.pid")
86
- let trackerRunning = false
87
- try {
88
- const pid = parseInt(readFileSync(pidFile, "utf8").trim(), 10)
89
- if (!isNaN(pid)) {
90
- process.kill(pid, 0) // check if process exists (signal 0)
91
- trackerRunning = true
92
- }
93
- } catch { /* not running */ }
94
- info(` Tracker: ${trackerRunning ? "running" : "not running"}`)
95
-
96
- // Activity log stats
97
- const activityPath = path.join(projectRoot, ".litmus", "activity.jsonl")
98
- if (existsSync(activityPath)) {
99
- try {
100
- const content = readFileSync(activityPath, "utf8")
101
- const lines = content.split("\n").filter(l => l.trim())
102
- info(` Activity events: ${lines.length}`)
103
- } catch { /* ignore */ }
104
- }
105
-
106
- console.log()
107
- console.log(chalk.dim(" When you're ready to submit:"))
108
- console.log(` ${chalk.cyan("litmus submit")}`)
109
- console.log()
110
- }
111
-
112
- function estimateSubmissionSize(projectRoot: string): { fileCount: number; sizeMb: string } {
113
- let fileCount = 0
114
- let totalBytes = 0
115
-
116
- const SKIP_DIRS = new Set([
117
- "node_modules", "__pycache__", "venv", ".venv", "env", ".env",
118
- "target", "build", "dist", ".gradle", ".idea",
119
- ])
120
-
121
- function walk(dir: string): void {
122
- let entries: string[]
123
- try {
124
- entries = readdirSync(dir)
125
- } catch {
126
- return
127
- }
128
- for (const entry of entries) {
129
- if (SKIP_DIRS.has(entry)) continue
130
- const fullPath = path.join(dir, entry)
131
- try {
132
- const stat = statSync(fullPath)
133
- if (stat.isDirectory()) {
134
- walk(fullPath)
135
- } else {
136
- fileCount++
137
- totalBytes += stat.size
138
- }
139
- } catch { /* permission errors, etc. */ }
140
- }
141
- }
142
-
143
- walk(projectRoot)
144
- return { fileCount, sizeMb: (totalBytes / (1024 * 1024)).toFixed(1) }
145
- }
@@ -1,165 +0,0 @@
1
- import { statSync, unlinkSync, readFileSync } from "fs"
2
- import { rm } from "fs/promises"
3
- import path from "path"
4
- import chalk from "chalk"
5
- import ora from "ora"
6
- import * as readline from "readline/promises"
7
- import { stdin as input, stdout as output } from "process"
8
- import { readConfig, findProjectRoot } from "../lib/config.js"
9
- import { submitZip } from "../lib/api.js"
10
- import { createSubmissionZip } from "../lib/zip.js"
11
- import { fatal, success, warn, info, FALLBACK_URL } from "../utils/errors.js"
12
-
13
- export async function runSubmit(opts: { yes?: boolean }): Promise<void> {
14
- // Step 1: Read config
15
- const config = await readConfig()
16
-
17
- if (!config) {
18
- fatal(
19
- "No Litmus assessment found in this directory.",
20
- "Run this command from inside your assessment folder, or run 'litmus init <token>' first."
21
- )
22
- }
23
-
24
- // Step 2: Check deadline
25
- if (config.deadline) {
26
- const deadline = new Date(config.deadline)
27
- const now = new Date()
28
-
29
- if (now > deadline) {
30
- fatal(
31
- `The submission deadline passed at ${deadline.toLocaleString()}.`,
32
- `Contact the company if you need an extension. Visit ${FALLBACK_URL} for help.`
33
- )
34
- }
35
-
36
- const minsLeft = Math.floor((deadline.getTime() - now.getTime()) / (1000 * 60))
37
- if (minsLeft < 30) {
38
- warn(`Only ${minsLeft} minutes left before the deadline!`)
39
- }
40
- }
41
-
42
- // Step 3: Find the project root (where .litmus/config.json lives)
43
- const projectRoot = findProjectRoot() ?? process.cwd()
44
-
45
- // Step 4: Create the submission ZIP
46
- const zipSpinner = ora("Preparing submission...").start()
47
- let zipPath: string
48
-
49
- try {
50
- zipPath = await createSubmissionZip(projectRoot)
51
- zipSpinner.succeed("Submission prepared")
52
- } catch (err) {
53
- zipSpinner.fail("Failed to create submission archive")
54
- const msg = err instanceof Error ? err.message : String(err)
55
- fatal(msg, `Contact the company for help or visit ${FALLBACK_URL}.`)
56
- }
57
-
58
- // Step 5: Show what's being submitted
59
- const zipSizeMb = (statSync(zipPath).size / (1024 * 1024)).toFixed(1)
60
- const folderName = path.basename(projectRoot)
61
-
62
- console.log()
63
- console.log(chalk.bold(` Submitting: ${config.assessmentName}`))
64
- info(` Archive size: ${zipSizeMb} MB`)
65
- info(` Tracking log included`)
66
- if (config.deadline) {
67
- info(` Deadline: ${new Date(config.deadline).toLocaleString()}`)
68
- }
69
- console.log()
70
-
71
- // Step 6: Confirm (unless --yes flag)
72
- if (!opts.yes) {
73
- const rl = readline.createInterface({ input, output })
74
- let answer: string
75
- try {
76
- answer = await rl.question(
77
- chalk.bold(" Confirm submission? ") + chalk.dim("[Y/n] ")
78
- )
79
- } finally {
80
- rl.close()
81
- }
82
-
83
- const confirmed = answer.trim().toLowerCase()
84
- if (confirmed !== "" && confirmed !== "y" && confirmed !== "yes") {
85
- console.log()
86
- console.log(chalk.dim(" Submission cancelled. Your work is saved."))
87
- console.log()
88
- try { unlinkSync(zipPath) } catch { /* cleanup */ }
89
- process.exit(0)
90
- }
91
- }
92
-
93
- console.log()
94
-
95
- // Step 7: Upload with progress
96
- const uploadSpinner = ora("Uploading submission...").start()
97
-
98
- try {
99
- const fileName = `${folderName}_submission.zip`
100
- const result = await submitZip(config.apiBase, config.token, zipPath, fileName, (uploaded, total) => {
101
- const pct = Math.round((uploaded / total) * 100)
102
- const uploadedMb = (uploaded / (1024 * 1024)).toFixed(1)
103
- const totalMb = (total / (1024 * 1024)).toFixed(1)
104
- uploadSpinner.text = `Uploading submission... ${uploadedMb}/${totalMb} MB (${pct}%)`
105
- })
106
- uploadSpinner.succeed("Submission received")
107
-
108
- console.log()
109
- success("You're done!")
110
- console.log()
111
- console.log(
112
- chalk.dim(" The company will review your work and be in touch.")
113
- )
114
- if (result.submissionId) {
115
- console.log(chalk.dim(` Submission ID: ${result.submissionId}`))
116
- }
117
- console.log()
118
-
119
- // Step 8: Delete assessment folder after successful submission
120
- await deleteAssessmentFolder(projectRoot, config.token, folderName)
121
- } catch (err) {
122
- uploadSpinner.fail("Upload failed")
123
- const msg = err instanceof Error ? err.message : String(err)
124
- console.log()
125
- fatal(
126
- msg,
127
- `Visit ${FALLBACK_URL} to upload manually, or contact the company for help.`
128
- )
129
- } finally {
130
- // Clean up temp ZIP
131
- try { unlinkSync(zipPath) } catch { /* cleanup */ }
132
- }
133
- }
134
-
135
- async function deleteAssessmentFolder(projectRoot: string, token: string, folderName: string): Promise<void> {
136
- // Safety check: verify this is actually a litmus assessment directory with matching token
137
- try {
138
- const configPath = path.join(projectRoot, ".litmus", "config.json")
139
- const configData = JSON.parse(readFileSync(configPath, "utf8"))
140
- if (configData.token !== token) {
141
- warn(`Could not verify assessment folder. You can manually remove: ${projectRoot}`)
142
- return
143
- }
144
- } catch {
145
- warn(`Could not verify assessment folder. You can manually remove: ${projectRoot}`)
146
- return
147
- }
148
-
149
- // Verified — proceed with deletion
150
- try {
151
- // Stop the background tracker process
152
- const pidFile = path.join(projectRoot, ".litmus", "tracker.pid")
153
- try {
154
- const pid = parseInt(readFileSync(pidFile, "utf8").trim(), 10)
155
- if (!isNaN(pid)) process.kill(pid)
156
- } catch { /* tracker may not be running */ }
157
-
158
- await rm(projectRoot, { recursive: true, force: true })
159
- console.log(chalk.dim(` Assessment folder deleted: ${folderName}`))
160
- console.log(chalk.dim(` You may need to \`cd\` to another directory.`))
161
- console.log()
162
- } catch {
163
- warn(`Could not delete assessment folder. You can manually remove: ${projectRoot}`)
164
- }
165
- }
package/src/index.ts DELETED
@@ -1,58 +0,0 @@
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
- import { runStatus } from "./commands/status.js"
6
-
7
- import { readFileSync } from "fs"
8
- import { fileURLToPath } from "url"
9
- import { dirname, join } from "path"
10
-
11
- const __filename = fileURLToPath(import.meta.url)
12
- const __dirname = dirname(__filename)
13
- const pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"))
14
- const VERSION = pkg.version
15
-
16
- program
17
- .name("litmus")
18
- .description("Litmus engineering assessment CLI")
19
- .version(VERSION)
20
-
21
- program
22
- .command("init <token>")
23
- .description("Download and set up a Litmus assessment")
24
- .action(async (token: string) => {
25
- try {
26
- await runInit(token)
27
- } catch (err) {
28
- console.error(err instanceof Error ? err.message : String(err))
29
- process.exit(1)
30
- }
31
- })
32
-
33
- program
34
- .command("submit")
35
- .description("Submit your completed assessment")
36
- .option("-y, --yes", "Skip confirmation prompt")
37
- .action(async (opts: { yes?: boolean }) => {
38
- try {
39
- await runSubmit(opts)
40
- } catch (err) {
41
- console.error(err instanceof Error ? err.message : String(err))
42
- process.exit(1)
43
- }
44
- })
45
-
46
- program
47
- .command("status")
48
- .description("Show assessment status and stats")
49
- .action(async () => {
50
- try {
51
- await runStatus()
52
- } catch (err) {
53
- console.error(err instanceof Error ? err.message : String(err))
54
- process.exit(1)
55
- }
56
- })
57
-
58
- program.parse()
package/src/lib/api.ts DELETED
@@ -1,140 +0,0 @@
1
- import { createReadStream, statSync } from "fs"
2
- import { request as httpsRequest } from "https"
3
- import { request as httpRequest } from "http"
4
-
5
- export interface InitMetadata {
6
- assessmentId: string
7
- assessmentName: string
8
- folderName: string
9
- candidateEmail: string
10
- candidateName: string
11
- deadline: string | null
12
- timeLimit: number | null
13
- startedAt: string
14
- }
15
-
16
- export interface SubmitResult {
17
- success: boolean
18
- submissionId: string
19
- message: string
20
- }
21
-
22
- const TIMEOUT_MS = 30_000 // 30s for metadata/download requests
23
-
24
- /**
25
- * Fetch assessment metadata by token.
26
- * Marks the candidate as in_progress if they were pending.
27
- */
28
- export async function fetchInitMetadata(
29
- apiBase: string,
30
- token: string
31
- ): Promise<InitMetadata> {
32
- const url = `${apiBase}/api/cli/init?token=${encodeURIComponent(token)}`
33
- const res = await fetch(url, { signal: AbortSignal.timeout(TIMEOUT_MS) })
34
- const body = await res.json().catch(() => ({})) as Record<string, unknown>
35
-
36
- if (!res.ok) {
37
- const message = (body.message as string) || (body.error as string) || `Server returned ${res.status}`
38
- throw new Error(message)
39
- }
40
-
41
- return body as unknown as InitMetadata
42
- }
43
-
44
- /**
45
- * Download the candidate ZIP by token.
46
- * Returns a ReadableStream of the ZIP bytes.
47
- */
48
- export async function downloadZip(
49
- apiBase: string,
50
- token: string
51
- ): Promise<Response> {
52
- const url = `${apiBase}/api/cli/download?token=${encodeURIComponent(token)}`
53
- const res = await fetch(url, { signal: AbortSignal.timeout(TIMEOUT_MS) })
54
-
55
- if (!res.ok) {
56
- const body = await res.json().catch(() => ({})) as Record<string, unknown>
57
- const message = (body.message as string) || (body.error as string) || `Server returned ${res.status}`
58
- throw new Error(message)
59
- }
60
-
61
- return res
62
- }
63
-
64
- /**
65
- * Upload a ZIP file for submission.
66
- * Streams the file to avoid loading it entirely into memory.
67
- * Calls onProgress with (bytesUploaded, totalBytes) during upload.
68
- */
69
- export async function submitZip(
70
- apiBase: string,
71
- token: string,
72
- zipPath: string,
73
- fileName: string,
74
- onProgress?: (uploaded: number, total: number) => void
75
- ): Promise<SubmitResult> {
76
- const parsedUrl = new URL(`/api/cli/submit?token=${encodeURIComponent(token)}`, apiBase)
77
- const fileSize = statSync(zipPath).size
78
-
79
- const boundary = `----litmus${Date.now()}`
80
- const headerBuf = Buffer.from(
81
- `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${fileName}"\r\nContent-Type: application/zip\r\n\r\n`
82
- )
83
- const footerBuf = Buffer.from(`\r\n--${boundary}--\r\n`)
84
- const totalSize = headerBuf.length + fileSize + footerBuf.length
85
-
86
- return new Promise((resolve, reject) => {
87
- const reqFn = parsedUrl.protocol === "https:" ? httpsRequest : httpRequest
88
- const req = reqFn(parsedUrl, {
89
- method: "POST",
90
- headers: {
91
- "Content-Type": `multipart/form-data; boundary=${boundary}`,
92
- "Content-Length": totalSize.toString(),
93
- },
94
- timeout: 300_000, // 5 min for upload
95
- }, (res) => {
96
- let data = ""
97
- res.on("data", (chunk: Buffer) => { data += chunk.toString() })
98
- res.on("end", () => {
99
- try {
100
- const body = JSON.parse(data) as Record<string, unknown>
101
- if (res.statusCode && res.statusCode >= 400) {
102
- const message = (body.message as string) || (body.error as string) || `Server returned ${res.statusCode}`
103
- reject(new Error(message))
104
- } else {
105
- resolve(body as unknown as SubmitResult)
106
- }
107
- } catch {
108
- reject(new Error(`Server returned invalid response (${res.statusCode})`))
109
- }
110
- })
111
- })
112
-
113
- req.on("error", reject)
114
- req.on("timeout", () => {
115
- req.destroy(new Error("Upload timed out"))
116
- })
117
-
118
- // Write multipart header
119
- req.write(headerBuf)
120
- let uploaded = headerBuf.length
121
-
122
- // Stream the file in chunks with backpressure handling
123
- const fileStream = createReadStream(zipPath, { highWaterMark: 64 * 1024 })
124
- fileStream.on("data", (chunk: string | Buffer) => {
125
- uploaded += typeof chunk === "string" ? Buffer.byteLength(chunk) : chunk.length
126
- onProgress?.(uploaded, totalSize)
127
- if (!req.write(chunk)) {
128
- fileStream.pause()
129
- req.once("drain", () => fileStream.resume())
130
- }
131
- })
132
- fileStream.on("end", () => {
133
- req.end(footerBuf)
134
- })
135
- fileStream.on("error", (err) => {
136
- req.destroy()
137
- reject(err)
138
- })
139
- })
140
- }
package/src/lib/config.ts DELETED
@@ -1,79 +0,0 @@
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
- * Walk up from cwd to find the directory containing .litmus/config.json.
36
- * Returns null if not found.
37
- */
38
- export function findProjectRoot(): string | null {
39
- let dir = process.cwd()
40
- while (true) {
41
- const configPath = path.join(dir, CONFIG_DIR, CONFIG_FILE)
42
- if (existsSync(configPath)) {
43
- return dir
44
- }
45
- const parent = path.dirname(dir)
46
- if (parent === dir) break
47
- dir = parent
48
- }
49
- return null
50
- }
51
-
52
- /**
53
- * Read .litmus/config.json from the current directory or a parent directory.
54
- * Returns null if no config is found.
55
- */
56
- export async function readConfig(): Promise<LitmusConfig | null> {
57
- let dir = process.cwd()
58
-
59
- // Walk up the directory tree looking for .litmus/config.json
60
- while (true) {
61
- const configPath = path.join(dir, CONFIG_DIR, CONFIG_FILE)
62
- if (existsSync(configPath)) {
63
- const raw = await readFile(configPath, "utf8")
64
- try {
65
- return JSON.parse(raw) as LitmusConfig
66
- } catch {
67
- throw new Error(
68
- `Your .litmus/config.json is corrupted. Delete the .litmus folder and re-run litmus init with your token.`
69
- )
70
- }
71
- }
72
-
73
- const parent = path.dirname(dir)
74
- if (parent === dir) break // Reached filesystem root
75
- dir = parent
76
- }
77
-
78
- return null
79
- }
@@ -1,15 +0,0 @@
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
- }
@@ -1,41 +0,0 @@
1
- import { spawn } from "child_process"
2
- import { mkdirSync, openSync, 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 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 errLog = path.join(litmusDir, "tracker.log")
18
- const watcherScript = path.join(__dirname, "watcher.cjs")
19
-
20
- try {
21
- mkdirSync(litmusDir, { recursive: true })
22
- } catch {
23
- // Non-critical
24
- }
25
-
26
- const errFd = openSync(errLog, "a")
27
-
28
- const child = spawn("node", [watcherScript, projectDir, activityLog], {
29
- detached: true,
30
- stdio: ["ignore", "ignore", errFd],
31
- })
32
-
33
- if (child.pid) {
34
- child.unref()
35
- try {
36
- writeFileSync(pidFile, String(child.pid), "utf8")
37
- } catch {
38
- // Non-critical
39
- }
40
- }
41
- }
@@ -1,79 +0,0 @@
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
- * Uses Node's built-in fs.watch (no external dependencies).
8
- * Writes newline-delimited JSON to the activity log:
9
- * { "ts": "ISO-8601", "type": "rename"|"change", "path": "relative/path" }
10
- */
11
-
12
- const fs = require("fs")
13
- const path = require("path")
14
-
15
- const [, , projectDir, activityLogPath] = process.argv
16
-
17
- process.stderr.write(`[watcher] starting: projectDir=${projectDir} log=${activityLogPath}\n`)
18
- process.stderr.write(`[watcher] node ${process.version}, platform=${process.platform}, arch=${process.arch}\n`)
19
-
20
- if (!projectDir || !activityLogPath) {
21
- process.stderr.write("[watcher] missing arguments\n")
22
- process.exit(1)
23
- }
24
-
25
- if (!fs.existsSync(projectDir)) {
26
- process.stderr.write(`[watcher] projectDir does not exist: ${projectDir}\n`)
27
- process.exit(1)
28
- }
29
-
30
- // Ensure the log directory exists
31
- fs.mkdirSync(path.dirname(activityLogPath), { recursive: true })
32
-
33
- // Open the log file for appending
34
- const log = fs.createWriteStream(activityLogPath, { flags: "a" })
35
-
36
- const IGNORED = [
37
- /[/\\]\./, // dotfiles and dotdirs (including .git, .litmus)
38
- /node_modules/,
39
- /__pycache__/,
40
- /\.pyc$/,
41
- /\.class$/,
42
- /[/\\]venv[/\\]/,
43
- /[/\\]\.venv[/\\]/,
44
- ]
45
-
46
- function shouldIgnore(relPath) {
47
- return IGNORED.some((re) => re.test(relPath))
48
- }
49
-
50
- function write(type, relPath) {
51
- const event = JSON.stringify({
52
- ts: new Date().toISOString(),
53
- type,
54
- path: relPath,
55
- })
56
- log.write(event + "\n")
57
- }
58
-
59
- process.on("uncaughtException", (err) => {
60
- process.stderr.write(`[watcher] uncaughtException: ${err.stack || err}\n`)
61
- process.exit(1)
62
- })
63
-
64
- try {
65
- const watcher = fs.watch(projectDir, { recursive: true }, (eventType, filename) => {
66
- if (!filename) return
67
- if (shouldIgnore(filename)) return
68
- write(eventType, filename)
69
- })
70
-
71
- watcher.on("error", (err) => {
72
- process.stderr.write(`[watcher] fs.watch error: ${err}\n`)
73
- })
74
-
75
- process.stderr.write("[watcher] started successfully\n")
76
- } catch (err) {
77
- process.stderr.write(`[watcher] failed to start: ${err.stack || err}\n`)
78
- process.exit(1)
79
- }
package/src/lib/zip.ts DELETED
@@ -1,110 +0,0 @@
1
- import { readFileSync, existsSync } from "fs"
2
- import path from "path"
3
- import os from "os"
4
- import fg from "fast-glob"
5
- import { Zip } from "zip-lib"
6
-
7
- // Directories always excluded from submissions.
8
- // NOTE: .git is intentionally NOT excluded — git history is included so the
9
- // analysis backend can run parse_git_history() on the submission.
10
- const ALWAYS_EXCLUDE = [
11
- "node_modules",
12
- "__pycache__",
13
- "venv",
14
- ".venv",
15
- "env",
16
- ".env",
17
- "target", // Rust/Java build output
18
- "build",
19
- "dist",
20
- ".gradle",
21
- ".idea",
22
- ".DS_Store",
23
- "*.pyc",
24
- "*.class",
25
- // Legacy injected tracking files — exclude so they don't pollute submissions
26
- // from assessments downloaded before the CLI-native tracking was deployed.
27
- "setup.sh",
28
- "tracker.py",
29
- "tracker.js",
30
- "heartbeat.py",
31
- ]
32
-
33
- /**
34
- * Create a ZIP of the project directory for submission.
35
- * Respects .gitignore patterns (basic support) and excludes common large dirs.
36
- * Always includes .litmus/activity.jsonl (the tracking log).
37
- *
38
- * @param projectDir - Root of the project to zip
39
- * @returns Path to the temporary ZIP file
40
- */
41
- export async function createSubmissionZip(projectDir: string): Promise<string> {
42
- const tmpFile = path.join(os.tmpdir(), `litmus-submission-${Date.now()}.zip`)
43
-
44
- // Compute glob patterns to ignore
45
- const ignoredGlobs = buildIgnoreGlobs(projectDir)
46
-
47
- // Find all files, including dotfiles
48
- const files = await fg(["**/*"], {
49
- cwd: projectDir,
50
- dot: true,
51
- onlyFiles: true,
52
- followSymbolicLinks: false,
53
- ignore: ignoredGlobs,
54
- })
55
-
56
- // Always include activity log if it exists
57
- const activityPath = ".litmus/activity.jsonl"
58
- if (existsSync(path.join(projectDir, activityPath)) && !files.includes(activityPath)) {
59
- files.push(activityPath)
60
- }
61
-
62
- const zip = new Zip({ compressionLevel: 6 })
63
- for (const file of files) {
64
- zip.addFile(path.join(projectDir, file), file)
65
- }
66
- await zip.archive(tmpFile)
67
-
68
- return tmpFile
69
- }
70
-
71
- function buildIgnoreGlobs(projectDir: string): string[] {
72
- const patterns: string[] = []
73
-
74
- // Always-excluded dirs/patterns
75
- for (const excluded of ALWAYS_EXCLUDE) {
76
- if (excluded.startsWith("*")) {
77
- patterns.push(excluded)
78
- } else {
79
- patterns.push(`${excluded}/**`)
80
- patterns.push(`**/${excluded}/**`)
81
- }
82
- }
83
-
84
- // Basic .gitignore support
85
- const gitignorePath = path.join(projectDir, ".gitignore")
86
- if (existsSync(gitignorePath)) {
87
- try {
88
- const lines = readFileSync(gitignorePath, "utf8")
89
- .split("\n")
90
- .map((l) => l.trim())
91
- .filter((l) => l && !l.startsWith("#"))
92
-
93
- for (const line of lines) {
94
- if (line.endsWith("/")) {
95
- // Directory pattern
96
- const dir = line.slice(0, -1)
97
- patterns.push(`${dir}/**`)
98
- patterns.push(`**/${dir}/**`)
99
- } else {
100
- patterns.push(line)
101
- patterns.push(`**/${line}`)
102
- }
103
- }
104
- } catch {
105
- // Ignore .gitignore parse errors
106
- }
107
- }
108
-
109
- return patterns
110
- }
@@ -1,84 +0,0 @@
1
- import { existsSync, readdirSync } 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
- const files = readdirSync(dir)
67
- if (files.some(f => f.endsWith(".csproj") || f.endsWith(".sln"))) {
68
- return { type: "dotnet", installCommand: "dotnet restore", runHint: "dotnet run / dotnet test" }
69
- }
70
-
71
- if (existsSync(path.join(dir, "Cargo.toml"))) {
72
- return { type: "rust", installCommand: null, runHint: "cargo run / cargo test" }
73
- }
74
-
75
- if (existsSync(path.join(dir, "Gemfile"))) {
76
- return { type: "ruby", installCommand: "bundle install", runHint: "ruby main.rb / rspec" }
77
- }
78
-
79
- if (existsSync(path.join(dir, "composer.json"))) {
80
- return { type: "php", installCommand: "composer install", runHint: "php -S localhost:8000" }
81
- }
82
-
83
- return { type: "unknown", installCommand: null, runHint: null }
84
- }
@@ -1,41 +0,0 @@
1
- import chalk from "chalk"
2
-
3
- export const FALLBACK_URL = "https://www.litmus.build"
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} or contact the company directly.`)
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 DELETED
@@ -1,19 +0,0 @@
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
- }