jobtrack 1.0.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.
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawnSync } from 'node:child_process';
4
+ import { createRequire } from 'node:module';
5
+ import { homedir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ import { fileURLToPath, pathToFileURL } from 'node:url';
8
+
9
+ // Only the installed CLI needs a real per-user data directory for the database, model cache,
10
+ // and .env — `apps/api/src/config.ts` otherwise defaults to the monorepo's own repo root,
11
+ // which is exactly right for `npm run tray`/`npm run dev` (run straight off src/ via tsx,
12
+ // never through this file) and exactly wrong once this is running out of node_modules.
13
+ if (!process.env.JOBTRACK_HOME) {
14
+ const base = process.platform === 'win32' && process.env.APPDATA ? process.env.APPDATA : join(homedir(), '.local', 'share');
15
+ process.env.JOBTRACK_HOME = join(base, 'jobtrack');
16
+ }
17
+
18
+ // The published package ships TypeScript sources rather than a compiled build (matching
19
+ // apps/api and apps/mcp, which also run straight off `src/` via tsx). Loading tsx's ESM hook
20
+ // in-process — via `node:module`'s `register()`, or tsx's own `tsx/esm/api` register (both
21
+ // route through the same internal loader) — throws "tsx must be loaded with --import instead
22
+ // of --loader" on current tsx/Node: that loader's `initialize` hook runs in a worker thread and
23
+ // never receives its options data that way. So this spawns a child `node` with the exact
24
+ // `--require <preflight> --import <loader>` flags the tsx CLI itself uses, resolved via tsx's
25
+ // public `tsx`/`tsx/preflight` export paths rather than hardcoded internal file names.
26
+ //
27
+ // One easy-to-miss gotcha reproducing that: pass `env` to spawnSync at all (even a literal
28
+ // `process.env`) and the *same* "must be loaded with --import" error comes back — something
29
+ // about handing Node a reconstructed environment object, rather than truly inheriting via
30
+ // `stdio`, breaks whatever the loader's worker thread needs. Leaving `env` unset here (true
31
+ // inheritance) is required, not a style choice.
32
+ const require = createRequire(import.meta.url);
33
+ const preflight = require.resolve('tsx/preflight');
34
+ const loader = pathToFileURL(require.resolve('tsx')).href;
35
+ const cliEntry = fileURLToPath(new URL('../src/cli.ts', import.meta.url));
36
+
37
+ const result = spawnSync(
38
+ process.execPath,
39
+ ['--require', preflight, '--import', loader, cliEntry, ...process.argv.slice(2)],
40
+ { stdio: 'inherit' },
41
+ );
42
+
43
+ if (result.error) throw result.error;
44
+ process.exit(result.status ?? 1);
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "jobtrack",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "description": "Runs the JobTrack API + web UI as one background process with a Windows tray icon",
6
+ "main": "./src/cli.ts",
7
+ "bin": {
8
+ "jobtrack": "./bin/jobtrack.js"
9
+ },
10
+ "files": [
11
+ "bin",
12
+ "src",
13
+ "vendor"
14
+ ],
15
+ "scripts": {
16
+ "dev": "tsx watch --clear-screen=false src/cli.ts",
17
+ "start": "tsx src/cli.ts",
18
+ "prepack": "node scripts/stage-assets.mjs"
19
+ },
20
+ "dependencies": {
21
+ "@fastify/static": "^10.1.3",
22
+ "@jobtrack/api": "^1.0.0",
23
+ "fastify": "^5.12.1",
24
+ "systray": "^1.0.5",
25
+ "tsx": "^4.23.12"
26
+ },
27
+ "devDependencies": {
28
+ "@types/node": "^24.10.1"
29
+ }
30
+ }
package/src/assets.ts ADDED
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Resolves read-only files this package needs but doesn't own the source of: the built web
3
+ * UI, and a template `.env.example`. Prefers a copy staged inside this package (see
4
+ * scripts/stage-assets.mjs, which the `prepack` script runs before `npm publish`/`npm pack`)
5
+ * and falls back to the sibling location in this monorepo, for local development before that
6
+ * staging step has ever run.
7
+ *
8
+ * Distinct from `resolveAppDataDir` in `@jobtrack/api/config`: that's where *user* data lives
9
+ * (the database, the model cache); this is where the package's own *shipped* assets live.
10
+ */
11
+ import { existsSync } from 'node:fs';
12
+ import { dirname, resolve } from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+
15
+ const here = dirname(fileURLToPath(import.meta.url));
16
+ const packageRoot = resolve(here, '..');
17
+ /** Only exists in a monorepo checkout — never present in a published, installed package. */
18
+ const monorepoRoot = resolve(here, '../../..');
19
+
20
+ function resolveAsset(bundledRelative: string, monorepoRelative: string): string | undefined {
21
+ const bundled = resolve(packageRoot, bundledRelative);
22
+ if (existsSync(bundled)) return bundled;
23
+ const monorepo = resolve(monorepoRoot, monorepoRelative);
24
+ if (existsSync(monorepo)) return monorepo;
25
+ return undefined;
26
+ }
27
+
28
+ export function resolveWebDist(): string | undefined {
29
+ return resolveAsset('vendor/web-dist', 'apps/web/dist');
30
+ }
31
+
32
+ export function resolveEnvExample(): string | undefined {
33
+ return resolveAsset('vendor/.env.example', '.env.example');
34
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Autostart with Windows, via the per-user Registry Run key — no admin rights needed, and it
3
+ * survives reinstalls since it's keyed off HKCU, not the app's install location.
4
+ *
5
+ * The registered command always points at this package's `bin/jobtrack.js`, resolved relative
6
+ * to this module rather than to however the current process happened to be launched (tsx watch
7
+ * during development must never end up as the autostart target).
8
+ */
9
+ import { execFileSync } from 'node:child_process';
10
+ import { dirname, resolve } from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+
13
+ const here = dirname(fileURLToPath(import.meta.url));
14
+ const binPath = resolve(here, '../bin/jobtrack.js');
15
+
16
+ const RUN_KEY = String.raw`HKCU\Software\Microsoft\Windows\CurrentVersion\Run`;
17
+ const VALUE_NAME = 'JobTrack';
18
+
19
+ function assertWindows(): void {
20
+ if (process.platform !== 'win32') {
21
+ throw new Error('Autostart is only supported on Windows.');
22
+ }
23
+ }
24
+
25
+ export function isAutostartEnabled(): boolean {
26
+ if (process.platform !== 'win32') return false;
27
+ try {
28
+ execFileSync('reg', ['query', RUN_KEY, '/v', VALUE_NAME], { stdio: 'ignore' });
29
+ return true;
30
+ } catch {
31
+ return false;
32
+ }
33
+ }
34
+
35
+ export function enableAutostart(): void {
36
+ assertWindows();
37
+ const command = `"${process.execPath}" "${binPath}"`;
38
+ execFileSync('reg', ['add', RUN_KEY, '/v', VALUE_NAME, '/t', 'REG_SZ', '/d', command, '/f'], {
39
+ stdio: 'ignore',
40
+ });
41
+ }
42
+
43
+ export function disableAutostart(): void {
44
+ assertWindows();
45
+ try {
46
+ execFileSync('reg', ['delete', RUN_KEY, '/v', VALUE_NAME, '/f'], { stdio: 'ignore' });
47
+ } catch {
48
+ // Already absent — nothing to do.
49
+ }
50
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Entry point for the `jobtrack` command (see bin/jobtrack.js).
3
+ *
4
+ * Starts the API + web UI as one process (server.ts) and, on Windows, shows a tray icon for
5
+ * opening the UI, toggling autostart, and opening .env (tray.ts). Elsewhere it just runs
6
+ * headless — Ctrl+C to stop — until a native tray for those platforms is worth building.
7
+ */
8
+ import { startServer } from './server.js';
9
+ import { createTray } from './tray.js';
10
+ import { isAutostartEnabled, enableAutostart, disableAutostart } from './autostart.js';
11
+ import { openSettingsFile } from './settings.js';
12
+ import { openUrl } from './os.js';
13
+
14
+ const { app, config, repos, search } = await startServer();
15
+
16
+ const url = `http://${config.host === '0.0.0.0' ? '127.0.0.1' : config.host}:${config.port}`;
17
+ console.log(`JobTrack running at ${url} (driver: ${config.driver})`);
18
+
19
+ let shuttingDown = false;
20
+ async function shutdown(): Promise<void> {
21
+ if (shuttingDown) return;
22
+ shuttingDown = true;
23
+ search.stop();
24
+ await app.close();
25
+ await repos.close();
26
+ process.exit(0);
27
+ }
28
+
29
+ for (const signal of ['SIGINT', 'SIGTERM'] as const) {
30
+ process.once(signal, () => void shutdown());
31
+ }
32
+
33
+ if (process.platform === 'win32') {
34
+ const tray = createTray({
35
+ autostartEnabled: isAutostartEnabled(),
36
+ onOpen: () => openUrl(url),
37
+ onToggleAutostart: (enabled) => (enabled ? enableAutostart() : disableAutostart()),
38
+ onOpenSettings: () => openSettingsFile(),
39
+ onQuit: () => void shutdown(),
40
+ });
41
+ process.once('exit', () => tray.kill(false));
42
+ } else {
43
+ console.log('[tray] a system tray icon is only implemented for Windows today; running headless. Press Ctrl+C to stop.');
44
+ }
package/src/os.ts ADDED
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Small platform-shell helpers. The tray icon itself (see tray.ts) is Windows-only for now,
3
+ * but these stay cross-platform so running headless on macOS/Linux still gets a working
4
+ * "open the app" experience.
5
+ */
6
+ import { execFile } from 'node:child_process';
7
+
8
+ export function openUrl(url: string): void {
9
+ if (process.platform === 'win32') {
10
+ // The empty string is `start`'s window-title argument — required so it doesn't mistake
11
+ // a quoted URL for the title itself.
12
+ execFile('cmd', ['/c', 'start', '""', url]);
13
+ } else if (process.platform === 'darwin') {
14
+ execFile('open', [url]);
15
+ } else {
16
+ execFile('xdg-open', [url]);
17
+ }
18
+ }
19
+
20
+ export function openInEditor(filePath: string): void {
21
+ if (process.platform === 'win32') {
22
+ execFile('notepad.exe', [filePath]);
23
+ } else if (process.platform === 'darwin') {
24
+ execFile('open', ['-t', filePath]);
25
+ } else {
26
+ execFile('xdg-open', [filePath]);
27
+ }
28
+ }
package/src/server.ts ADDED
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Composes the existing API app with static serving for the built web UI, so the tray manages
3
+ * one process instead of two. Mirrors apps/api/src/index.ts's startup sequence (config, repos,
4
+ * search, then listen) but adds @fastify/static + an SPA fallback on top of the same
5
+ * `buildApp` every other entry point (API server, tests) uses — the JSON API itself is
6
+ * unchanged, including its 404 behavior for `/api/*`.
7
+ */
8
+ import fastifyStatic from '@fastify/static';
9
+ import type { FastifyInstance } from 'fastify';
10
+ import { buildApp } from '@jobtrack/api/app';
11
+ import { loadConfig, type Config } from '@jobtrack/api/config';
12
+ import { createRepos, type RepoBundle } from '@jobtrack/api/db/repos';
13
+ import { SearchIndex } from '@jobtrack/api/search';
14
+ import { DisabledEmbedder, TransformersEmbedder, type Embedder } from '@jobtrack/api/search/embedder';
15
+ import { resolveWebDist } from './assets.js';
16
+
17
+ export interface RunningServer {
18
+ app: FastifyInstance;
19
+ config: Config;
20
+ repos: RepoBundle;
21
+ search: SearchIndex;
22
+ }
23
+
24
+ export async function startServer(): Promise<RunningServer> {
25
+ const config = loadConfig();
26
+ const repos = await createRepos(config);
27
+
28
+ const embedder: Embedder = config.semanticSearchEnabled
29
+ ? new TransformersEmbedder({
30
+ model: config.embeddingModel,
31
+ cacheDir: config.modelCacheDir,
32
+ onError: (error) => {
33
+ console.warn('[search] semantic model unavailable, staying lexical-only:', error);
34
+ },
35
+ })
36
+ : new DisabledEmbedder();
37
+
38
+ const search = new SearchIndex({
39
+ repos,
40
+ embedder,
41
+ log: (message, error) => console.warn(`[search] ${message}`, error ?? ''),
42
+ });
43
+
44
+ const app = await buildApp({ repos, search, config }, { logger: true });
45
+
46
+ const webDist = resolveWebDist();
47
+ if (webDist) {
48
+ await app.register(fastifyStatic, { root: webDist });
49
+ // React Router routes (e.g. /applications, /companies/:id) are only real files at
50
+ // `/`; anything else that isn't an API call falls back to index.html so client-side
51
+ // routing can take over.
52
+ app.setNotFoundHandler((request, reply) => {
53
+ if (request.method === 'GET' && !request.url.startsWith('/api/')) {
54
+ return reply.sendFile('index.html');
55
+ }
56
+ return reply.status(404).send({ error: 'not_found', message: 'Not found' });
57
+ });
58
+ } else {
59
+ console.warn('[tray] no built web UI found — run "npm run build" to serve it. API-only for now.');
60
+ }
61
+
62
+ await search.start();
63
+ await app.listen({ host: config.host, port: config.port });
64
+
65
+ return { app, config, repos, search };
66
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * "Open app settings" — the tray's escape hatch to .env, which is the only place connection
3
+ * settings and other secrets live (see apps/api/src/config.ts). Seeded from .env.example on
4
+ * first use so there's always a real file to open, matching `npm install`'s quick-start step.
5
+ */
6
+ import { copyFileSync, existsSync } from 'node:fs';
7
+ import { resolve } from 'node:path';
8
+ import { resolveAppDataDir } from '@jobtrack/api/config';
9
+ import { resolveEnvExample } from './assets.js';
10
+ import { openInEditor } from './os.js';
11
+
12
+ export function openSettingsFile(): void {
13
+ const envPath = resolve(resolveAppDataDir(), '.env');
14
+ const examplePath = resolveEnvExample();
15
+
16
+ if (!existsSync(envPath) && examplePath) {
17
+ copyFileSync(examplePath, envPath);
18
+ }
19
+
20
+ openInEditor(envPath);
21
+ }
package/src/tray.ts ADDED
@@ -0,0 +1,88 @@
1
+ /**
2
+ * The Windows tray icon: open the UI, toggle autostart, open .env, quit. Built on `systray`
3
+ * (a thin wrapper over a portable Go tray binary), which needs no native compilation — a
4
+ * plain dependency install is enough, unlike node-gyp-based tray libraries.
5
+ */
6
+ import { readFileSync } from 'node:fs';
7
+ import { createRequire } from 'node:module';
8
+ import { resolve } from 'node:path';
9
+ import { resolveWebDist } from './assets.js';
10
+
11
+ // `systray` is CommonJS and sets an `__esModule: true` flag on its own exports while also
12
+ // assigning `exports.default`. Node's native ESM/CJS interop does not honor that flag the way
13
+ // tsc/webpack do — a plain `import SysTray from 'systray'` resolves to the *whole*
14
+ // `module.exports` object (which itself has a `.default`), one level up from the real class,
15
+ // so `new SysTray(...)` fails with "SysTray is not a constructor". Going through `require`
16
+ // directly sidesteps that ESM-interop ambiguity and gets the real class, as plain CommonJS
17
+ // consumption always would.
18
+ type SysTrayCtor = typeof import('systray').default;
19
+ const SysTray = createRequire(import.meta.url)('systray').default as SysTrayCtor;
20
+
21
+ const ITEM = { OPEN: 0, AUTOSTART: 1, SETTINGS: 2, QUIT: 3 } as const;
22
+
23
+ export interface TrayHandlers {
24
+ autostartEnabled: boolean;
25
+ onOpen: () => void;
26
+ onToggleAutostart: (nextEnabled: boolean) => void;
27
+ onOpenSettings: () => void;
28
+ onQuit: () => void;
29
+ }
30
+
31
+ export function createTray(handlers: TrayHandlers): InstanceType<SysTrayCtor> {
32
+ const webDist = resolveWebDist();
33
+ if (!webDist) {
34
+ throw new Error('No built web UI found — run "npm run build" before starting the tray.');
35
+ }
36
+ const icon = readFileSync(resolve(webDist, 'favicon.ico')).toString('base64');
37
+
38
+ const systray = new SysTray({
39
+ menu: {
40
+ icon,
41
+ title: 'JobTrack',
42
+ tooltip: 'JobTrack is running',
43
+ items: [
44
+ { title: 'Open JobTrack', tooltip: 'Open the web UI', checked: false, enabled: true },
45
+ {
46
+ title: 'Autostart with Windows',
47
+ tooltip: 'Launch JobTrack automatically when you sign in',
48
+ checked: handlers.autostartEnabled,
49
+ enabled: true,
50
+ },
51
+ { title: 'Open App Settings', tooltip: 'Edit .env', checked: false, enabled: true },
52
+ { title: 'Quit', tooltip: 'Stop JobTrack', checked: false, enabled: true },
53
+ ],
54
+ },
55
+ debug: false,
56
+ // Copies the bundled Go tray binary out of node_modules before running it — needed for
57
+ // packaging tools (and harmless otherwise), per the systray README.
58
+ copyDir: true,
59
+ });
60
+
61
+ systray.onClick((action) => {
62
+ switch (action.seq_id) {
63
+ case ITEM.OPEN:
64
+ handlers.onOpen();
65
+ break;
66
+ case ITEM.AUTOSTART: {
67
+ const next = !action.item.checked;
68
+ handlers.onToggleAutostart(next);
69
+ systray.sendAction({
70
+ type: 'update-item',
71
+ item: { ...action.item, checked: next },
72
+ seq_id: action.seq_id,
73
+ });
74
+ break;
75
+ }
76
+ case ITEM.SETTINGS:
77
+ handlers.onOpenSettings();
78
+ break;
79
+ case ITEM.QUIT:
80
+ handlers.onQuit();
81
+ break;
82
+ default:
83
+ break;
84
+ }
85
+ });
86
+
87
+ return systray;
88
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Minimal ambient types for the `systray` package (no upstream types or @types package).
3
+ * Covers only the surface this app actually uses — see the README at
4
+ * https://github.com/zaaack/node-systray for the full API.
5
+ */
6
+ declare module 'systray' {
7
+ export interface MenuItem {
8
+ title: string;
9
+ tooltip: string;
10
+ checked: boolean;
11
+ enabled: boolean;
12
+ }
13
+
14
+ export interface Menu {
15
+ icon: string;
16
+ title: string;
17
+ tooltip: string;
18
+ items: MenuItem[];
19
+ }
20
+
21
+ export interface SysTrayConf {
22
+ menu: Menu;
23
+ debug?: boolean;
24
+ copyDir?: boolean;
25
+ }
26
+
27
+ export interface ClickAction {
28
+ type: 'clicked';
29
+ item: MenuItem;
30
+ seq_id: number;
31
+ }
32
+
33
+ export interface UpdateItemAction {
34
+ type: 'update-item';
35
+ item: MenuItem;
36
+ seq_id: number;
37
+ }
38
+
39
+ export default class SysTray {
40
+ constructor(conf: SysTrayConf);
41
+ onClick(listener: (action: ClickAction) => void): void;
42
+ sendAction(action: UpdateItemAction): void;
43
+ kill(exitNode?: boolean): void;
44
+ }
45
+ }
@@ -0,0 +1,29 @@
1
+ # Copy this file to `.env` and adjust as needed. Every value below is optional — an
2
+ # unmodified `.env` (or no `.env` at all) runs JobTrack on local SQLite at
3
+ # http://127.0.0.1:3001. See README.md "Configuration" for the full write-up.
4
+
5
+ # sqlite (default), postgres, or mysql — the driver for the implicit "default" target.
6
+ # DB_DRIVER=sqlite
7
+
8
+ # SQLite only. Path is relative to the repo root.
9
+ # DB_FILE=data/jobtrack.db
10
+
11
+ # Postgres/MySQL only. Required when DB_DRIVER is postgres or mysql.
12
+ # DATABASE_URL=postgres://user:pass@host/db
13
+
14
+ # JSON array of additional named DB targets, switchable from the Settings page without
15
+ # editing this file again. See README.md "Switching databases".
16
+ # DB_TARGETS=[{"name":"cloud","driver":"postgres","url":"postgres://user:pass@host/db"}]
17
+
18
+ # API bind address.
19
+ # HOST=127.0.0.1
20
+ # PORT=3001
21
+
22
+ # Set to false to skip loading the embedding model entirely and stay lexical-only search.
23
+ # SEMANTIC_SEARCH=true
24
+
25
+ # Any transformers.js feature-extraction model.
26
+ # EMBEDDING_MODEL=Xenova/all-MiniLM-L6-v2
27
+
28
+ # Where the ONNX embedding model is cached, relative to the repo root.
29
+ # MODEL_CACHE_DIR=.models