jobhunt-kit 0.3.0 → 0.5.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/.claude-plugin/marketplace.json +1 -1
- package/README.md +58 -11
- package/bin/agents.mjs +66 -0
- package/bin/install-options.mjs +21 -18
- package/bin/jobhunt-kit.mjs +17 -6
- package/installer-assets/gitignore.txt +1 -0
- package/installer-assets/runtime-lock.json +56 -2
- package/installer-assets/workspace-lock.json +56 -2
- package/package.json +3 -1
- package/plugins/jobhunt-kit/.claude-plugin/plugin.json +1 -1
- package/plugins/jobhunt-kit/.codex-plugin/plugin.json +1 -1
- package/plugins/jobhunt-kit/package-lock.json +56 -2
- package/plugins/jobhunt-kit/package.json +2 -1
- package/plugins/jobhunt-kit/references/cli.md +30 -2
- package/plugins/jobhunt-kit/references/harnesses.md +12 -0
- package/plugins/jobhunt-kit/references/privacy.md +24 -0
- package/plugins/jobhunt-kit/references/storage.md +11 -5
- package/plugins/jobhunt-kit/schemas/draft.schema.json +13 -0
- package/plugins/jobhunt-kit/schemas/finish.schema.json +11 -0
- package/plugins/jobhunt-kit/schemas/policy.schema.json +36 -0
- package/plugins/jobhunt-kit/schemas/profile.schema.json +80 -0
- package/plugins/jobhunt-kit/schemas/status.schema.json +12 -0
- package/plugins/jobhunt-kit/schemas/vacancy.schema.json +29 -0
- package/plugins/jobhunt-kit/scripts/commands.mjs +57 -11
- package/plugins/jobhunt-kit/scripts/extract-resume.mjs +5 -3
- package/plugins/jobhunt-kit/scripts/files.mjs +69 -0
- package/plugins/jobhunt-kit/scripts/maintenance.mjs +292 -0
- package/plugins/jobhunt-kit/scripts/profile.mjs +23 -20
- package/plugins/jobhunt-kit/scripts/resume.mjs +42 -38
- package/plugins/jobhunt-kit/scripts/setup.mjs +8 -10
- package/plugins/jobhunt-kit/scripts/tracker.mjs +188 -33
- package/plugins/jobhunt-kit/scripts/validation.mjs +30 -0
- package/plugins/jobhunt-kit/skills/job-apply/SKILL.md +4 -2
- package/plugins/jobhunt-kit/skills/job-resume/SKILL.md +3 -2
- package/plugins/jobhunt-kit/skills/job-track/SKILL.md +8 -0
- package/scripts/check-package.mjs +7 -0
- package/scripts/pack-smoke.mjs +53 -0
- package/tests/hardening.test.mjs +337 -0
- package/tests/installer.test.mjs +47 -2
- package/tests/tracker.test.mjs +3 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { randomUUID, createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
|
|
4
|
+
|
|
5
|
+
export const MAX_JSON_BYTES = 2 * 1024 * 1024;
|
|
6
|
+
export const MAX_RESUME_BYTES = 20 * 1024 * 1024;
|
|
7
|
+
export const sha256 = bytes => createHash('sha256').update(bytes).digest('hex');
|
|
8
|
+
|
|
9
|
+
export function inside(base, candidate) {
|
|
10
|
+
const delta = relative(resolve(base), resolve(candidate));
|
|
11
|
+
return delta === '' || (!isAbsolute(delta) && delta !== '..' && !delta.startsWith(`..${sep}`));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function assertNoSymlink(path, stop = dirname(resolve(path))) {
|
|
15
|
+
let current = resolve(path);
|
|
16
|
+
const boundary = resolve(stop);
|
|
17
|
+
while (inside(boundary, current)) {
|
|
18
|
+
if (existsSync(current) && lstatSync(current).isSymbolicLink()) throw new Error(`Symbolic links are not allowed for local data: ${current}`);
|
|
19
|
+
if (current === boundary) return;
|
|
20
|
+
const parent = dirname(current);
|
|
21
|
+
if (parent === current) break;
|
|
22
|
+
current = parent;
|
|
23
|
+
}
|
|
24
|
+
throw new Error(`Path escapes the expected data directory: ${path}`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function localPath(base, path) {
|
|
28
|
+
if (typeof path !== 'string' || !path.trim() || isAbsolute(path)) throw new Error('Expected a relative path inside the local data directory');
|
|
29
|
+
const output = resolve(base, path);
|
|
30
|
+
if (!inside(base, output)) throw new Error(`Path escapes the local data directory: ${path}`);
|
|
31
|
+
assertNoSymlink(output, base);
|
|
32
|
+
return output;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function readBytes(path, { maxBytes = MAX_JSON_BYTES, base = null } = {}) {
|
|
36
|
+
const target = resolve(path);
|
|
37
|
+
if (base) {
|
|
38
|
+
if (!inside(base, target)) throw new Error(`Path escapes the local data directory: ${path}`);
|
|
39
|
+
assertNoSymlink(target, base);
|
|
40
|
+
} else if (existsSync(target) && lstatSync(target).isSymbolicLink()) throw new Error(`Symbolic link input is not allowed: ${target}`);
|
|
41
|
+
const stat = lstatSync(target);
|
|
42
|
+
if (!stat.isFile()) throw new Error(`Expected a regular file: ${target}`);
|
|
43
|
+
if (stat.size > maxBytes) throw new Error(`File exceeds ${maxBytes} byte limit: ${target}`);
|
|
44
|
+
return readFileSync(target);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function readJSON(path, options = {}) {
|
|
48
|
+
return JSON.parse(readBytes(path, options).toString('utf8').replace(/^\uFEFF/, ''));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function atomicWrite(path, bytes, { base, mode = 0o600 } = {}) {
|
|
52
|
+
const target = resolve(path);
|
|
53
|
+
if (!base || !inside(base, target)) throw new Error(`Write target escapes the local data directory: ${target}`);
|
|
54
|
+
assertNoSymlink(target, base);
|
|
55
|
+
mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
|
|
56
|
+
assertNoSymlink(dirname(target), base);
|
|
57
|
+
const temporary = `${target}.${randomUUID()}.tmp`;
|
|
58
|
+
try {
|
|
59
|
+
writeFileSync(temporary, bytes, { flag: 'wx', mode });
|
|
60
|
+
renameSync(temporary, target);
|
|
61
|
+
} catch (error) {
|
|
62
|
+
try { if (existsSync(temporary)) unlinkSync(temporary); } catch {}
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function atomicWriteJSON(path, value, options) {
|
|
68
|
+
atomicWrite(path, JSON.stringify(value, null, 2) + '\n', options);
|
|
69
|
+
}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, rmSync } from 'node:fs';
|
|
3
|
+
import { basename, dirname, join, relative, resolve } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
6
|
+
import { Tracker, initData } from './tracker.mjs';
|
|
7
|
+
import { atomicWrite, atomicWriteJSON, inside, localPath, readBytes, readJSON, sha256 } from './files.mjs';
|
|
8
|
+
import { validateSchema } from './validation.mjs';
|
|
9
|
+
|
|
10
|
+
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
11
|
+
const BACKUP_ITEMS = ['profile.json', 'policy.json', 'profile.md', 'intake.md', 'resumes', 'materials', 'reports'];
|
|
12
|
+
const MAX_BACKUP_MANIFEST_BYTES = 16 * 1024 * 1024;
|
|
13
|
+
|
|
14
|
+
function requireSafeTree(path) {
|
|
15
|
+
const stat = lstatSync(path);
|
|
16
|
+
if (stat.isSymbolicLink()) throw new Error(`Backup refuses symbolic link: ${path}`);
|
|
17
|
+
if (stat.isDirectory()) for (const entry of readdirSync(path)) requireSafeTree(join(path, entry));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function copyTree(source, destination) {
|
|
21
|
+
requireSafeTree(source);
|
|
22
|
+
const stat = lstatSync(source);
|
|
23
|
+
if (stat.isDirectory()) {
|
|
24
|
+
mkdirSync(destination, { recursive: true, mode: 0o700 });
|
|
25
|
+
for (const entry of readdirSync(source)) copyTree(join(source, entry), join(destination, entry));
|
|
26
|
+
} else copyFileSync(source, destination);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function inventory(root, path = root) {
|
|
30
|
+
const rows = [];
|
|
31
|
+
for (const entry of readdirSync(path)) {
|
|
32
|
+
const full = join(path, entry);
|
|
33
|
+
const stat = lstatSync(full);
|
|
34
|
+
if (stat.isSymbolicLink()) throw new Error(`Backup contains symbolic link: ${full}`);
|
|
35
|
+
if (stat.isDirectory()) rows.push(...inventory(root, full));
|
|
36
|
+
else rows.push({ path: relative(root, full).replaceAll('\\', '/'), bytes: stat.size, sha256: sha256(readBytes(full, { maxBytes: Number.MAX_SAFE_INTEGER })) });
|
|
37
|
+
}
|
|
38
|
+
return rows.sort((a, b) => a.path.localeCompare(b.path));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function quoteSQLite(path) { return `'${String(path).replaceAll("'", "''")}'`; }
|
|
42
|
+
|
|
43
|
+
function invalidateAuthorization(directory, reason) {
|
|
44
|
+
const dir = resolve(directory);
|
|
45
|
+
const store = new Tracker(dir);
|
|
46
|
+
try {
|
|
47
|
+
const profilePath = localPath(dir, 'profile.json');
|
|
48
|
+
const profileBytes = readBytes(profilePath, { base: dir });
|
|
49
|
+
const profile = JSON.parse(profileBytes.toString('utf8').replace(/^\uFEFF/, ''));
|
|
50
|
+
if (profile.confirmed_at !== null) {
|
|
51
|
+
profile.confirmed_at = null;
|
|
52
|
+
validateSchema('profile', profile);
|
|
53
|
+
store.writeManagedJSON('profile.json', profile, { expectedSha256: sha256(profileBytes), eventKind: 'authorization_invalidated', eventPayload: { reason } });
|
|
54
|
+
}
|
|
55
|
+
const policyPath = localPath(dir, 'policy.json');
|
|
56
|
+
const policyBytes = readBytes(policyPath, { base: dir });
|
|
57
|
+
const policy = JSON.parse(policyBytes.toString('utf8').replace(/^\uFEFF/, ''));
|
|
58
|
+
if (policy.mode !== 'review_each' || policy.auto !== null) {
|
|
59
|
+
policy.mode = 'review_each';
|
|
60
|
+
policy.auto = null;
|
|
61
|
+
validateSchema('policy', policy);
|
|
62
|
+
store.writeManagedJSON('policy.json', policy, { expectedSha256: sha256(policyBytes), eventKind: 'authorization_invalidated', eventPayload: { reason } });
|
|
63
|
+
}
|
|
64
|
+
} finally { store.close(); }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function validateDatabaseSnapshot(root) {
|
|
68
|
+
validateSchema('profile', readJSON(join(root, 'profile.json')));
|
|
69
|
+
validateSchema('policy', readJSON(join(root, 'policy.json')));
|
|
70
|
+
const store = new DatabaseSync(join(root, 'history.sqlite'), { readOnly: true });
|
|
71
|
+
try {
|
|
72
|
+
const database = store.prepare('PRAGMA integrity_check').all().map(row => row.integrity_check);
|
|
73
|
+
const foreignKeys = store.prepare('PRAGMA foreign_key_check').all();
|
|
74
|
+
if (database.length !== 1 || database[0] !== 'ok' || foreignKeys.length) throw new Error('Backup database failed integrity check');
|
|
75
|
+
if (store.prepare('PRAGMA user_version').get().user_version !== 2) throw new Error('Backup database schema is not v2');
|
|
76
|
+
if (store.prepare('SELECT COUNT(*) AS count FROM pending_file_writes').get().count) throw new Error('Backup contains pending managed file writes');
|
|
77
|
+
for (const name of ['profile.json', 'policy.json']) {
|
|
78
|
+
const tracked = store.prepare('SELECT sha256 FROM file_revisions WHERE name=?').get(name);
|
|
79
|
+
const actual = sha256(readBytes(join(root, name), { maxBytes: Number.MAX_SAFE_INTEGER }));
|
|
80
|
+
if (!tracked || tracked.sha256 !== actual) throw new Error(`Backup ${name} does not match its database revision`);
|
|
81
|
+
}
|
|
82
|
+
} finally { store.close(); }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function quarantineData(directory, destination) {
|
|
86
|
+
const dir = resolve(directory);
|
|
87
|
+
const target = resolve(destination);
|
|
88
|
+
if (inside(dir, target) || inside(target, dir)) throw new Error('Quarantine directory must be separate from live data');
|
|
89
|
+
if (existsSync(target)) throw new Error('Quarantine destination already exists');
|
|
90
|
+
mkdirSync(target, { recursive: true, mode: 0o700 });
|
|
91
|
+
for (const name of [...BACKUP_ITEMS, 'history.sqlite', 'history.sqlite-wal', 'history.sqlite-shm']) {
|
|
92
|
+
const source = localPath(dir, name);
|
|
93
|
+
if (existsSync(source)) copyTree(source, join(target, name));
|
|
94
|
+
}
|
|
95
|
+
const manifest = { schema_version: 'quarantine-1', created_at: new Date().toISOString(), source_name: basename(dir), files: inventory(target) };
|
|
96
|
+
atomicWriteJSON(join(target, 'quarantine-manifest.json'), manifest, { base: target });
|
|
97
|
+
return { directory: target, files: manifest.files.length, restorable_only_by_manual_recovery: true };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function backupData(directory, destination) {
|
|
101
|
+
const dir = resolve(directory);
|
|
102
|
+
const target = resolve(destination);
|
|
103
|
+
if (inside(dir, target) || inside(target, dir)) throw new Error('Backup directory must be separate from the live data directory');
|
|
104
|
+
if (existsSync(target) && lstatSync(target).isSymbolicLink()) throw new Error('Backup destination cannot be a symbolic link');
|
|
105
|
+
if (existsSync(target) && readdirSync(target).length) throw new Error('Backup destination must be empty');
|
|
106
|
+
mkdirSync(target, { recursive: true, mode: 0o700 });
|
|
107
|
+
const store = new Tracker(dir);
|
|
108
|
+
try {
|
|
109
|
+
const check = store.integrity();
|
|
110
|
+
if (!check.database_ok || check.foreign_key_violations.length || check.pending_file_writes.length ||
|
|
111
|
+
Object.values(check.files).some(file => !file.valid || !file.tracked_sha256_matches)) {
|
|
112
|
+
throw new Error('Refusing to back up invalid data; run doctor and repair first');
|
|
113
|
+
}
|
|
114
|
+
store.db.exec('PRAGMA wal_checkpoint(FULL)');
|
|
115
|
+
store.db.exec(`VACUUM INTO ${quoteSQLite(join(target, 'history.sqlite'))}`);
|
|
116
|
+
} finally { store.close(); }
|
|
117
|
+
for (const name of BACKUP_ITEMS) {
|
|
118
|
+
const source = localPath(dir, name);
|
|
119
|
+
if (existsSync(source)) copyTree(source, join(target, name));
|
|
120
|
+
}
|
|
121
|
+
validateDatabaseSnapshot(target);
|
|
122
|
+
const manifest = { schema_version: 1, created_at: new Date().toISOString(), source_name: basename(dir), files: inventory(target) };
|
|
123
|
+
const manifestBytes = Buffer.from(JSON.stringify(manifest, null, 2) + '\n');
|
|
124
|
+
if (manifestBytes.length > MAX_BACKUP_MANIFEST_BYTES) throw new Error(`Backup manifest exceeds the ${MAX_BACKUP_MANIFEST_BYTES} byte limit`);
|
|
125
|
+
atomicWrite(join(target, 'backup-manifest.json'), manifestBytes, { base: target });
|
|
126
|
+
verifyBackupContents(target);
|
|
127
|
+
return { directory: target, files: manifest.files.length, manifest: join(target, 'backup-manifest.json') };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function verifyBackupContents(source) {
|
|
131
|
+
const root = resolve(source);
|
|
132
|
+
if (lstatSync(root).isSymbolicLink()) throw new Error('Backup root cannot be a symbolic link');
|
|
133
|
+
const manifest = readJSON(join(root, 'backup-manifest.json'), { maxBytes: MAX_BACKUP_MANIFEST_BYTES });
|
|
134
|
+
if (manifest.schema_version !== 1 || !Array.isArray(manifest.files)) throw new Error('Unsupported backup manifest');
|
|
135
|
+
const expectedPaths = new Set();
|
|
136
|
+
for (const expected of manifest.files) {
|
|
137
|
+
if (!expected || typeof expected.path !== 'string' || expectedPaths.has(expected.path)) throw new Error('Backup manifest contains an invalid or duplicate path');
|
|
138
|
+
expectedPaths.add(expected.path);
|
|
139
|
+
const path = resolve(root, expected.path);
|
|
140
|
+
if (!inside(root, path) || !existsSync(path)) throw new Error(`Backup file missing: ${expected.path}`);
|
|
141
|
+
const bytes = readBytes(path, { maxBytes: Number.MAX_SAFE_INTEGER });
|
|
142
|
+
if (bytes.length !== expected.bytes || sha256(bytes) !== expected.sha256) throw new Error(`Backup file failed verification: ${expected.path}`);
|
|
143
|
+
}
|
|
144
|
+
const actual = inventory(root).filter(row => row.path !== 'backup-manifest.json');
|
|
145
|
+
if (JSON.stringify(actual) !== JSON.stringify([...manifest.files].sort((a, b) => a.path.localeCompare(b.path)))) {
|
|
146
|
+
throw new Error('Backup contents do not exactly match the manifest');
|
|
147
|
+
}
|
|
148
|
+
validateDatabaseSnapshot(root);
|
|
149
|
+
return manifest;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function verifyBackup(source) {
|
|
153
|
+
const manifest = verifyBackupContents(source);
|
|
154
|
+
return { valid: true, files: manifest.files.length, created_at: manifest.created_at };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function restoreData(directory, source, confirmation) {
|
|
158
|
+
if (confirmation !== 'RESTORE VERIFIED JOBHUNT BACKUP') throw new Error('Exact restore confirmation required');
|
|
159
|
+
const dir = resolve(directory);
|
|
160
|
+
const backup = resolve(source);
|
|
161
|
+
const manifest = verifyBackupContents(backup);
|
|
162
|
+
const safety = `${dir}.pre-restore-${new Date().toISOString().replace(/[:.]/g, '-')}`;
|
|
163
|
+
const staging = `${dir}.restore-staging-${randomUUID()}`;
|
|
164
|
+
mkdirSync(staging, { recursive: true, mode: 0o700 });
|
|
165
|
+
try {
|
|
166
|
+
for (const name of [...BACKUP_ITEMS, 'history.sqlite']) {
|
|
167
|
+
const input = join(backup, name);
|
|
168
|
+
if (existsSync(input)) copyTree(input, join(staging, name));
|
|
169
|
+
}
|
|
170
|
+
if (JSON.stringify(inventory(staging)) !== JSON.stringify([...manifest.files].sort((a, b) => a.path.localeCompare(b.path)))) {
|
|
171
|
+
throw new Error('Staged restore contents changed after backup verification');
|
|
172
|
+
}
|
|
173
|
+
validateDatabaseSnapshot(staging);
|
|
174
|
+
invalidateAuthorization(staging, 'restore_replaced_local_ledger');
|
|
175
|
+
validateDatabaseSnapshot(staging);
|
|
176
|
+
quarantineData(dir, safety);
|
|
177
|
+
try {
|
|
178
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
179
|
+
for (const name of [...BACKUP_ITEMS, 'history.sqlite', 'history.sqlite-wal', 'history.sqlite-shm']) {
|
|
180
|
+
const target = localPath(dir, name);
|
|
181
|
+
if (existsSync(target)) rmSync(target, { recursive: true, force: true });
|
|
182
|
+
}
|
|
183
|
+
for (const name of [...BACKUP_ITEMS, 'history.sqlite']) {
|
|
184
|
+
const input = join(staging, name);
|
|
185
|
+
if (existsSync(input)) copyTree(input, join(dir, name));
|
|
186
|
+
}
|
|
187
|
+
const ignore = localPath(dir, '.gitignore');
|
|
188
|
+
if (!existsSync(ignore)) atomicWrite(ignore, '*\n', { base: dir });
|
|
189
|
+
validateDatabaseSnapshot(dir);
|
|
190
|
+
} catch (error) {
|
|
191
|
+
try {
|
|
192
|
+
for (const name of [...BACKUP_ITEMS, 'history.sqlite', 'history.sqlite-wal', 'history.sqlite-shm']) {
|
|
193
|
+
const target = localPath(dir, name);
|
|
194
|
+
if (existsSync(target)) rmSync(target, { recursive: true, force: true });
|
|
195
|
+
const input = join(safety, name);
|
|
196
|
+
if (existsSync(input)) copyTree(input, target);
|
|
197
|
+
}
|
|
198
|
+
} catch (rollbackError) {
|
|
199
|
+
throw new AggregateError([error, rollbackError], `Restore and automatic rollback failed; quarantine preserved at ${safety}`);
|
|
200
|
+
}
|
|
201
|
+
throw error;
|
|
202
|
+
}
|
|
203
|
+
} finally { if (existsSync(staging)) rmSync(staging, { recursive: true, force: true }); }
|
|
204
|
+
return { restored: true, source: backup, automatic_quarantine: safety };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function privacyMap(directory) {
|
|
208
|
+
const dir = resolve(directory);
|
|
209
|
+
return {
|
|
210
|
+
data: dir,
|
|
211
|
+
authoritative: { candidate_facts: localPath(dir, 'profile.json'), history: localPath(dir, 'history.sqlite'), policy: localPath(dir, 'policy.json') },
|
|
212
|
+
derived_or_copied: { resumes: localPath(dir, 'resumes'), materials: localPath(dir, 'materials'), reports: localPath(dir, 'reports') },
|
|
213
|
+
notes: ['profile_saved events in schema v2 store changed field names, not complete profile snapshots', 'older schema v1 events may contain full profile snapshots', 'data is local and not encrypted']
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function setPath(object, dotted, replacement) {
|
|
218
|
+
const parts = dotted.split('.').filter(Boolean);
|
|
219
|
+
if (!parts.length) throw new Error('Redaction fields must be dotted profile paths');
|
|
220
|
+
let current = object;
|
|
221
|
+
for (const part of parts.slice(0, -1)) {
|
|
222
|
+
if (!current || typeof current !== 'object' || !(part in current)) throw new Error(`Unknown profile field: ${dotted}`);
|
|
223
|
+
current = current[part];
|
|
224
|
+
}
|
|
225
|
+
const leaf = parts.at(-1);
|
|
226
|
+
if (!current || typeof current !== 'object' || !(leaf in current)) throw new Error(`Unknown profile field: ${dotted}`);
|
|
227
|
+
current[leaf] = replacement;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function redactLegacy(value) {
|
|
231
|
+
if (Array.isArray(value)) return value.map(redactLegacy);
|
|
232
|
+
if (!value || typeof value !== 'object') return value;
|
|
233
|
+
const output = {};
|
|
234
|
+
for (const [key, item] of Object.entries(value)) output[key] = ['previous', 'next', 'user_authorization'].includes(key) ? '[REDACTED]' : redactLegacy(item);
|
|
235
|
+
return output;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function redactData(directory, input) {
|
|
239
|
+
if (input.confirmation !== 'REDACT LOCAL JOBHUNT PII') throw new Error('Exact redaction confirmation required');
|
|
240
|
+
if (!Array.isArray(input.fields) || !input.fields.length || !input.fields.every(field => typeof field === 'string')) throw new Error('Redaction requires profile fields');
|
|
241
|
+
const dir = resolve(directory);
|
|
242
|
+
const store = new Tracker(dir);
|
|
243
|
+
try {
|
|
244
|
+
const profilePath = localPath(dir, 'profile.json');
|
|
245
|
+
const bytes = readBytes(profilePath, { base: dir });
|
|
246
|
+
const profile = JSON.parse(bytes.toString('utf8'));
|
|
247
|
+
for (const field of input.fields) setPath(profile, field, null);
|
|
248
|
+
profile.confirmed_at = null;
|
|
249
|
+
validateSchema('profile', profile);
|
|
250
|
+
store.writeManagedJSON('profile.json', profile, { expectedSha256: sha256(bytes), eventKind: 'privacy_redacted', eventPayload: { fields: input.fields } });
|
|
251
|
+
if (input.redact_legacy_history !== false) {
|
|
252
|
+
const rows = store.db.prepare('SELECT id,payload FROM events').all();
|
|
253
|
+
const update = store.db.prepare('UPDATE events SET payload=? WHERE id=?');
|
|
254
|
+
store.transaction(() => { for (const row of rows) update.run(JSON.stringify(redactLegacy(JSON.parse(row.payload))), row.id); });
|
|
255
|
+
}
|
|
256
|
+
return { redacted: input.fields, confirmation_required_for_search: true, legacy_history_redacted: input.redact_legacy_history !== false };
|
|
257
|
+
} finally { store.close(); }
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function purgeData(directory, input) {
|
|
261
|
+
if (input.confirmation !== 'PURGE LOCAL JOBHUNT DATA') throw new Error('Exact purge confirmation required');
|
|
262
|
+
if (typeof input.backup_directory !== 'string' || !input.backup_directory) throw new Error('Purge requires backup_directory');
|
|
263
|
+
const allowed = ['history', 'materials', 'resumes', 'reports', 'profile'];
|
|
264
|
+
const requested = input.scopes || allowed;
|
|
265
|
+
if (!Array.isArray(requested) || !requested.length || requested.some(scope => !allowed.includes(scope))) throw new Error(`Purge scopes: ${allowed.join(', ')}`);
|
|
266
|
+
const scopes = requested.includes('history') ? [...new Set([...requested, 'reports'])] : requested;
|
|
267
|
+
const dir = resolve(directory);
|
|
268
|
+
const backup = backupData(dir, input.backup_directory);
|
|
269
|
+
if (scopes.includes('history')) invalidateAuthorization(dir, 'history_purged');
|
|
270
|
+
if (scopes.includes('history')) for (const name of ['history.sqlite', 'history.sqlite-wal', 'history.sqlite-shm']) {
|
|
271
|
+
const path = localPath(dir, name); if (existsSync(path)) rmSync(path, { force: true });
|
|
272
|
+
}
|
|
273
|
+
for (const scope of ['materials', 'resumes', 'reports']) if (scopes.includes(scope)) {
|
|
274
|
+
const path = localPath(dir, scope); if (existsSync(path)) rmSync(path, { recursive: true, force: true }); mkdirSync(path, { mode: 0o700 });
|
|
275
|
+
}
|
|
276
|
+
if (scopes.includes('profile')) {
|
|
277
|
+
if (scopes.includes('history')) {
|
|
278
|
+
for (const name of ['profile.json', 'policy.json', 'profile.md', 'intake.md']) atomicWrite(localPath(dir, name), readBytes(join(ROOT, 'templates', name)), { base: dir });
|
|
279
|
+
} else {
|
|
280
|
+
const store = new Tracker(dir);
|
|
281
|
+
try {
|
|
282
|
+
for (const name of ['profile.json', 'policy.json']) {
|
|
283
|
+
const current = readBytes(localPath(dir, name), { base: dir });
|
|
284
|
+
store.writeManagedJSON(name, readJSON(join(ROOT, 'templates', name)), { expectedSha256: sha256(current), eventKind: `${name.split('.')[0]}_purged`, eventPayload: {} });
|
|
285
|
+
}
|
|
286
|
+
} finally { store.close(); }
|
|
287
|
+
for (const name of ['profile.md', 'intake.md']) atomicWrite(localPath(dir, name), readBytes(join(ROOT, 'templates', name)), { base: dir });
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
initData(dir);
|
|
291
|
+
return { purged: scopes, backup };
|
|
292
|
+
}
|
|
@@ -1,19 +1,16 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { join } from 'node:path';
|
|
3
|
-
import { randomUUID } from 'node:crypto';
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
4
3
|
import { Tracker } from './tracker.mjs';
|
|
4
|
+
import { atomicWriteJSON, readBytes, readJSON as safeReadJSON, sha256 } from './files.mjs';
|
|
5
|
+
import { schemaErrors } from './validation.mjs';
|
|
5
6
|
|
|
6
|
-
export const readJSON = path =>
|
|
7
|
-
export function writeJSON(path, value) {
|
|
8
|
-
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
9
|
-
writeFileSync(temporary, JSON.stringify(value, null, 2) + '\n');
|
|
10
|
-
renameSync(temporary, path);
|
|
11
|
-
}
|
|
7
|
+
export const readJSON = path => safeReadJSON(path);
|
|
8
|
+
export function writeJSON(path, value, base = dirname(path)) { atomicWriteJSON(path, value, { base }); }
|
|
12
9
|
const filled = value => typeof value === 'string' && value.trim().length > 0;
|
|
13
10
|
export function validateProfile(p) {
|
|
14
|
-
const errors =
|
|
11
|
+
const errors = schemaErrors('profile', p);
|
|
15
12
|
const warnings = [];
|
|
16
|
-
if (!p || typeof p !== 'object' || Array.isArray(p)) return { valid: false, errors
|
|
13
|
+
if (!p || typeof p !== 'object' || Array.isArray(p)) return { valid: false, errors, warnings };
|
|
17
14
|
if (p.schema_version !== 1) errors.push('schema_version must be 1');
|
|
18
15
|
for (const field of ['identity', 'search', 'resume', 'availability', 'writing']) {
|
|
19
16
|
if (!p[field] || typeof p[field] !== 'object' || Array.isArray(p[field])) errors.push(`${field} must be an object`);
|
|
@@ -53,27 +50,33 @@ export function saveProfile(dir, input) {
|
|
|
53
50
|
if (!check.valid) throw new Error(check.errors.join('; '));
|
|
54
51
|
const file = join(dir, 'profile.json');
|
|
55
52
|
if (!existsSync(file)) throw new Error('Run profile init first');
|
|
56
|
-
const previous = readJSON(file);
|
|
57
53
|
const next = { ...input, confirmed_at: null };
|
|
58
54
|
const store = new Tracker(dir);
|
|
59
55
|
try {
|
|
60
|
-
|
|
61
|
-
|
|
56
|
+
const previousBytes = readBytes(file, { base: dir });
|
|
57
|
+
const previous = JSON.parse(previousBytes.toString('utf8').replace(/^\uFEFF/, ''));
|
|
58
|
+
const changed_fields = [...new Set([...Object.keys(previous), ...Object.keys(next)])]
|
|
59
|
+
.filter(key => JSON.stringify(previous[key]) !== JSON.stringify(next[key]));
|
|
60
|
+
store.writeManagedJSON('profile.json', next, { expectedSha256: sha256(previousBytes), eventKind: 'profile_saved', eventPayload: { changed_fields } });
|
|
62
61
|
return { saved: true, confirmation_required: true, check };
|
|
63
62
|
} finally { store.close(); }
|
|
64
63
|
}
|
|
65
64
|
export function confirmProfile(dir, note) {
|
|
66
65
|
if (!filled(note)) throw new Error('Explicit user confirmation note required');
|
|
67
66
|
const file = join(dir, 'profile.json');
|
|
68
|
-
const profile = readJSON(file);
|
|
69
|
-
const check = validateProfile(profile);
|
|
70
|
-
if (!check.valid) throw new Error(check.errors.join('; '));
|
|
71
|
-
profile.confirmed_at = new Date().toISOString();
|
|
72
67
|
const store = new Tracker(dir);
|
|
73
68
|
try {
|
|
74
|
-
|
|
69
|
+
const profileBytes = readBytes(file, { base: dir });
|
|
70
|
+
const profile = JSON.parse(profileBytes.toString('utf8').replace(/^\uFEFF/, ''));
|
|
71
|
+
const check = validateProfile(profile);
|
|
72
|
+
if (!check.valid) throw new Error(check.errors.join('; '));
|
|
73
|
+
profile.confirmed_at = new Date().toISOString();
|
|
74
|
+
const profileHash = serializedHash(profile);
|
|
75
|
+
store.writeManagedJSON('profile.json', profile, { expectedSha256: sha256(profileBytes), eventKind: 'profile_confirmed',
|
|
76
|
+
eventPayload: { user_confirmation: note, profile_hash: profileHash } });
|
|
75
77
|
const result = store.profile();
|
|
76
|
-
store.event('profile_confirmed', { user_confirmation: note, profile_hash: result.hash });
|
|
77
78
|
return { confirmed_at: profile.confirmed_at, hash: result.hash, warnings: check.warnings };
|
|
78
79
|
} finally { store.close(); }
|
|
79
80
|
}
|
|
81
|
+
|
|
82
|
+
function serializedHash(value) { return sha256(JSON.stringify(value)); }
|
|
@@ -1,41 +1,42 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
1
|
+
import { readFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
3
2
|
import { basename, extname, join, resolve, dirname } from 'node:path';
|
|
4
3
|
import { fileURLToPath } from 'node:url';
|
|
5
4
|
import { spawnSync } from 'node:child_process';
|
|
6
5
|
import { readJSON, writeJSON } from './profile.mjs';
|
|
7
6
|
import { Tracker } from './tracker.mjs';
|
|
7
|
+
import { atomicWrite, localPath, MAX_RESUME_BYTES, readBytes, sha256 as fingerprint } from './files.mjs';
|
|
8
8
|
|
|
9
9
|
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
10
|
-
const fingerprint = bytes => createHash('sha256').update(bytes).digest('hex');
|
|
11
10
|
export function importResume(dir, file) {
|
|
12
11
|
const pfile = join(dir, 'profile.json');
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
12
|
+
const store = new Tracker(dir);
|
|
13
|
+
try {
|
|
14
|
+
const profileBytes = readBytes(pfile, { base: dir });
|
|
15
|
+
const profile = JSON.parse(profileBytes.toString('utf8').replace(/^\uFEFF/, ''));
|
|
16
|
+
const bytes = readBytes(resolve(file), { maxBytes: MAX_RESUME_BYTES });
|
|
17
|
+
if (!bytes.length) throw new Error('Resume file is empty');
|
|
18
|
+
const extension = extname(file).toLowerCase();
|
|
19
|
+
if (!['.txt', '.md', '.pdf', '.docx', '.doc', '.rtf'].includes(extension)) throw new Error('Supported resume files: txt, md, pdf, docx, doc, rtf');
|
|
20
|
+
const sha256 = fingerprint(bytes);
|
|
21
|
+
const filename = `${sha256}${extension}`;
|
|
22
|
+
const path = localPath(dir, `resumes/${filename}`);
|
|
23
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
24
|
+
if (!existsSync(path)) atomicWrite(path, bytes, { base: dir });
|
|
25
|
+
else if (fingerprint(readBytes(path, { maxBytes: MAX_RESUME_BYTES, base: dir })) !== sha256) throw new Error('Stored resume fingerprint mismatch');
|
|
26
|
+
if (profile.resume?.sha256 !== sha256 || profile.resume?.path !== `resumes/${filename}`) {
|
|
27
|
+
profile.resume = { path: `resumes/${filename}`, sha256, review_status: 'not_reviewed' };
|
|
28
|
+
profile.confirmed_at = null;
|
|
29
|
+
store.writeManagedJSON('profile.json', profile, { expectedSha256: fingerprint(profileBytes), eventKind: 'resume_imported',
|
|
30
|
+
eventPayload: { source_name: basename(file), sha256, bytes: bytes.length } });
|
|
31
|
+
}
|
|
32
|
+
return { path, sha256, bytes: bytes.length, review_status: profile.resume.review_status, next: 'resume check' };
|
|
33
|
+
} finally { store.close(); }
|
|
33
34
|
}
|
|
34
35
|
export function checkResume(dir) {
|
|
35
36
|
const profile = readJSON(join(dir, 'profile.json'));
|
|
36
37
|
if (!profile.resume?.path) throw new Error('Import a resume first: resume <file>');
|
|
37
|
-
const path =
|
|
38
|
-
const bytes =
|
|
38
|
+
const path = localPath(dir, profile.resume.path);
|
|
39
|
+
const bytes = readBytes(path, { maxBytes: MAX_RESUME_BYTES, base: dir });
|
|
39
40
|
const sha256 = fingerprint(bytes);
|
|
40
41
|
const extension = extname(path).toLowerCase();
|
|
41
42
|
const findings = [];
|
|
@@ -69,24 +70,27 @@ export function checkResume(dir) {
|
|
|
69
70
|
const output = join(dir, 'materials', `resume-${sha256}`);
|
|
70
71
|
mkdirSync(output, { recursive: true });
|
|
71
72
|
writeJSON(join(output, 'checks.json'), result);
|
|
72
|
-
if (text !== null)
|
|
73
|
+
if (text !== null) atomicWrite(join(output, 'text.txt'), text, { base: dir });
|
|
73
74
|
const review = join(output, 'review.md');
|
|
74
|
-
if (!existsSync(review))
|
|
75
|
+
if (!existsSync(review)) atomicWrite(review, readFileSync(join(ROOT, 'templates', 'resume-review.md')), { base: dir });
|
|
75
76
|
return { ...result, checks_path: join(output, 'checks.json'), review_path: review,
|
|
76
77
|
agent_instruction: `Use job-resume. Review ${path}; mechanical checks: ${join(output, 'checks.json')}. Complete ${review}. Do not infer an ATS score from these checks.` };
|
|
77
78
|
}
|
|
78
79
|
export function recordResumeReview(dir, review) {
|
|
79
80
|
const file = join(dir, 'profile.json');
|
|
80
|
-
const profile = readJSON(file);
|
|
81
|
-
if (!profile.resume?.path) throw new Error('Import a resume first');
|
|
82
|
-
const actual = fingerprint(readFileSync(resolve(dir, profile.resume.path)));
|
|
83
|
-
if (review.sha256 !== actual || profile.resume.sha256 !== actual) throw new Error('Review must refer to the current registered file');
|
|
84
|
-
if (!['ready', 'needs_changes'].includes(review.status) || typeof review.evidence !== 'string' || !review.evidence.trim()) throw new Error('Review needs status and evidence');
|
|
85
|
-
if (review.status === 'ready' && !['text', 'visual', 'facts'].every(k => review.checks?.[k] === 'pass')) throw new Error('Ready requires recorded text, visual and factual checks');
|
|
86
81
|
const store = new Tracker(dir);
|
|
87
|
-
try {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
82
|
+
try {
|
|
83
|
+
const profileBytes = readBytes(file, { base: dir });
|
|
84
|
+
const profile = JSON.parse(profileBytes.toString('utf8').replace(/^\uFEFF/, ''));
|
|
85
|
+
if (!profile.resume?.path) throw new Error('Import a resume first');
|
|
86
|
+
const resumePath = localPath(dir, profile.resume.path);
|
|
87
|
+
const actual = fingerprint(readBytes(resumePath, { maxBytes: MAX_RESUME_BYTES, base: dir }));
|
|
88
|
+
if (review.sha256 !== actual || profile.resume.sha256 !== actual) throw new Error('Review must refer to the current registered file');
|
|
89
|
+
if (!['ready', 'needs_changes'].includes(review.status) || typeof review.evidence !== 'string' || !review.evidence.trim()) throw new Error('Review needs status and evidence');
|
|
90
|
+
if (review.status === 'ready' && !['text', 'visual', 'facts'].every(k => review.checks?.[k] === 'pass')) throw new Error('Ready requires recorded text, visual and factual checks');
|
|
91
|
+
profile.resume.review_status = review.status;
|
|
92
|
+
profile.confirmed_at = null;
|
|
93
|
+
store.writeManagedJSON('profile.json', profile, { expectedSha256: fingerprint(profileBytes), eventKind: 'resume_reviewed', eventPayload: review });
|
|
94
|
+
return { recorded: true, confirmation_required: true, status: review.status };
|
|
95
|
+
} finally { store.close(); }
|
|
92
96
|
}
|
|
@@ -1,25 +1,23 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
import { dirname,
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { dirname, join, resolve } from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { spawnSync } from 'node:child_process';
|
|
6
|
-
import { initData } from './tracker.mjs';
|
|
7
6
|
|
|
8
7
|
try {
|
|
9
8
|
const [flag, data, install] = process.argv.slice(2);
|
|
10
9
|
if (flag !== '--data' || !data || (install && install !== '--install-cli')) throw new Error('Usage: setup.mjs --data <directory> [--install-cli]');
|
|
11
|
-
const
|
|
10
|
+
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
12
11
|
if (install) {
|
|
13
|
-
|
|
14
|
-
const
|
|
15
|
-
mkdirSync(runtime, { recursive: true });
|
|
16
|
-
for (const name of ['package.json', 'package-lock.json']) copyFileSync(join(root, name), join(runtime, name));
|
|
17
|
-
// Fixed arguments; npm on Windows is a .cmd launcher. User paths go via cwd, not shell text.
|
|
18
|
-
const options = { cwd: runtime, stdio: 'inherit', windowsHide: true };
|
|
12
|
+
// Install next to the bundle so ESM resolution is independent of ambient parent node_modules.
|
|
13
|
+
const options = { cwd: root, stdio: 'inherit', windowsHide: true };
|
|
19
14
|
const r = process.platform === 'win32'
|
|
20
15
|
? spawnSync('cmd.exe', ['/d', '/s', '/c', 'npm ci --ignore-scripts --no-audit --no-fund'], options)
|
|
21
16
|
: spawnSync('npm', ['ci', '--ignore-scripts', '--no-audit', '--no-fund'], options);
|
|
22
17
|
if (r.error || r.status !== 0) throw new Error(r.error?.message || 'npm ci failed; no sign-in or search was attempted');
|
|
18
|
+
if (!existsSync(join(root, 'node_modules', 'ajv', 'package.json'))) throw new Error('npm ci completed without installing the local runtime dependencies');
|
|
23
19
|
}
|
|
20
|
+
const { initData } = await import('./tracker.mjs');
|
|
21
|
+
const result = initData(data);
|
|
24
22
|
console.log(JSON.stringify({ ...result, cli_install_requested: Boolean(install), authenticated: false }, null, 2));
|
|
25
23
|
} catch (e) { console.error(`jobhunt-kit: ${e.message}`); process.exitCode = 1; }
|