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.
- package/dist/commands/init.d.ts +2 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +153 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/submit.d.ts +4 -0
- package/dist/commands/submit.d.ts.map +1 -0
- package/dist/commands/submit.js +124 -0
- package/dist/commands/submit.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +36 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/api.d.ts +30 -0
- package/dist/lib/api.d.ts.map +1 -0
- package/dist/lib/api.js +54 -0
- package/dist/lib/api.js.map +1 -0
- package/dist/lib/config.d.ts +19 -0
- package/dist/lib/config.d.ts.map +1 -0
- package/dist/lib/config.js +40 -0
- package/dist/lib/config.js.map +1 -0
- package/dist/lib/extract.d.ts +6 -0
- package/dist/lib/extract.d.ts.map +1 -0
- package/dist/lib/extract.js +12 -0
- package/dist/lib/extract.js.map +1 -0
- package/dist/lib/tracker.d.ts +7 -0
- package/dist/lib/tracker.d.ts.map +1 -0
- package/dist/lib/tracker.js +36 -0
- package/dist/lib/tracker.js.map +1 -0
- package/dist/lib/zip.d.ts +10 -0
- package/dist/lib/zip.d.ts.map +1 -0
- package/dist/lib/zip.js +96 -0
- package/dist/lib/zip.js.map +1 -0
- package/dist/utils/detect-project.d.ts +11 -0
- package/dist/utils/detect-project.d.ts.map +1 -0
- package/dist/utils/detect-project.js +55 -0
- package/dist/utils/detect-project.js.map +1 -0
- package/dist/utils/errors.d.ts +19 -0
- package/dist/utils/errors.d.ts.map +1 -0
- package/dist/utils/errors.js +35 -0
- package/dist/utils/errors.js.map +1 -0
- package/dist/watcher.cjs +58 -0
- package/package.json +37 -0
- package/src/commands/init.ts +171 -0
- package/src/commands/submit.ts +141 -0
- package/src/index.ts +38 -0
- package/src/lib/api.ts +93 -0
- package/src/lib/config.ts +59 -0
- package/src/lib/extract.ts +15 -0
- package/src/lib/tracker.ts +38 -0
- package/src/lib/watcher.cjs +58 -0
- package/src/lib/zip.ts +104 -0
- package/src/utils/detect-project.ts +83 -0
- package/src/utils/errors.ts +41 -0
- 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
|
+
}
|