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
|
@@ -2,14 +2,15 @@
|
|
|
2
2
|
// Local bookkeeping only. No network, credentials, or application sending here.
|
|
3
3
|
import { DatabaseSync } from 'node:sqlite';
|
|
4
4
|
import { createHash, randomUUID } from 'node:crypto';
|
|
5
|
-
import { mkdirSync, readFileSync,
|
|
5
|
+
import { mkdirSync, readFileSync, existsSync } from 'node:fs';
|
|
6
6
|
import { resolve, dirname, join } from 'node:path';
|
|
7
7
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
8
|
+
import { atomicWrite, localPath, MAX_JSON_BYTES, readBytes, readJSON, sha256 } from './files.mjs';
|
|
9
|
+
import { schemaErrors, validateSchema } from './validation.mjs';
|
|
8
10
|
|
|
9
11
|
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
10
12
|
const now = () => new Date().toISOString();
|
|
11
13
|
const hash = value => createHash('sha256').update(typeof value === 'string' ? value : JSON.stringify(value)).digest('hex');
|
|
12
|
-
const readJSON = path => JSON.parse(readFileSync(path, 'utf8').replace(/^\uFEFF/, ''));
|
|
13
14
|
const need = (condition, message) => { if (!condition) throw new Error(message); };
|
|
14
15
|
const text = value => typeof value === 'string' && value.trim().length > 0;
|
|
15
16
|
const safeURL = value => {
|
|
@@ -25,12 +26,12 @@ export function initData(directory) {
|
|
|
25
26
|
const dir = resolve(directory);
|
|
26
27
|
// Never store candidate state in a plugin cache, including a locally loaded plugin.
|
|
27
28
|
need(dir !== ROOT && !dir.startsWith(ROOT + '/') && !dir.startsWith(ROOT + '\\'), 'Data must be outside the plugin directory');
|
|
28
|
-
mkdirSync(dir, { recursive: true });
|
|
29
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
29
30
|
for (const file of ['profile.json', 'policy.json', 'profile.md', 'intake.md']) {
|
|
30
|
-
if (!existsSync(join(dir, file)))
|
|
31
|
+
if (!existsSync(join(dir, file))) atomicWrite(join(dir, file), readFileSync(join(ROOT, 'templates', file)), { base: dir });
|
|
31
32
|
}
|
|
32
33
|
for (const folder of ['resumes', 'materials', 'reports']) mkdirSync(join(dir, folder), { recursive: true });
|
|
33
|
-
|
|
34
|
+
if (!existsSync(join(dir, '.gitignore'))) atomicWrite(join(dir, '.gitignore'), '*\n', { base: dir });
|
|
34
35
|
const store = new Tracker(dir);
|
|
35
36
|
store.close();
|
|
36
37
|
return { data: dir, initialized: true, profile: 'empty unless previously populated' };
|
|
@@ -39,11 +40,12 @@ export function initData(directory) {
|
|
|
39
40
|
export class Tracker {
|
|
40
41
|
constructor(directory) {
|
|
41
42
|
this.dir = resolve(directory);
|
|
42
|
-
need(existsSync(
|
|
43
|
-
this.db = new DatabaseSync(
|
|
43
|
+
need(existsSync(localPath(this.dir, 'profile.json')), 'Run init first');
|
|
44
|
+
this.db = new DatabaseSync(localPath(this.dir, 'history.sqlite'));
|
|
44
45
|
const version = this.db.prepare('PRAGMA user_version').get().user_version;
|
|
45
|
-
if (![0, 1].includes(version)) { this.db.close(); throw new Error('Unsupported database version; explicit migration required'); }
|
|
46
|
-
this.db.exec(
|
|
46
|
+
if (![0, 1, 2].includes(version)) { this.db.close(); throw new Error('Unsupported database version; explicit migration required'); }
|
|
47
|
+
this.db.exec('PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000; PRAGMA journal_mode=WAL;');
|
|
48
|
+
if (version === 0) this.db.exec(`
|
|
47
49
|
CREATE TABLE IF NOT EXISTS runs(id TEXT PRIMARY KEY, started_at TEXT NOT NULL, finished_at TEXT,
|
|
48
50
|
status TEXT NOT NULL, profile_hash TEXT NOT NULL, input TEXT NOT NULL, result TEXT);
|
|
49
51
|
CREATE TABLE IF NOT EXISTS jobs(id TEXT PRIMARY KEY, canonical_url TEXT UNIQUE NOT NULL,
|
|
@@ -52,11 +54,47 @@ export class Tracker {
|
|
|
52
54
|
CREATE TABLE IF NOT EXISTS observations(id INTEGER PRIMARY KEY, run_id TEXT NOT NULL REFERENCES runs(id),
|
|
53
55
|
job_id TEXT NOT NULL REFERENCES jobs(id), at TEXT NOT NULL, payload TEXT NOT NULL);
|
|
54
56
|
CREATE TABLE IF NOT EXISTS attempts(id TEXT PRIMARY KEY, job_id TEXT UNIQUE NOT NULL REFERENCES jobs(id),
|
|
55
|
-
started_at TEXT NOT NULL, mode TEXT NOT NULL, packet TEXT NOT NULL, result TEXT
|
|
57
|
+
started_at TEXT NOT NULL, mode TEXT NOT NULL, packet TEXT NOT NULL, result TEXT,
|
|
58
|
+
policy_hash TEXT, application_snapshot TEXT NOT NULL);
|
|
56
59
|
CREATE TABLE IF NOT EXISTS dispatches(attempt_id TEXT PRIMARY KEY REFERENCES attempts(id), at TEXT NOT NULL);
|
|
57
60
|
CREATE TABLE IF NOT EXISTS events(id INTEGER PRIMARY KEY, at TEXT NOT NULL, kind TEXT NOT NULL,
|
|
58
61
|
job_id TEXT, run_id TEXT, payload TEXT NOT NULL);
|
|
59
|
-
|
|
62
|
+
CREATE UNIQUE INDEX IF NOT EXISTS one_running_search ON runs(status) WHERE status='running';
|
|
63
|
+
CREATE TABLE IF NOT EXISTS file_revisions(name TEXT PRIMARY KEY, revision INTEGER NOT NULL, sha256 TEXT NOT NULL, updated_at TEXT NOT NULL);
|
|
64
|
+
CREATE TABLE IF NOT EXISTS pending_file_writes(id TEXT PRIMARY KEY, name TEXT NOT NULL, old_sha256 TEXT NOT NULL,
|
|
65
|
+
new_sha256 TEXT NOT NULL, content TEXT NOT NULL, event_kind TEXT NOT NULL, event_payload TEXT NOT NULL, created_at TEXT NOT NULL,
|
|
66
|
+
UNIQUE(name));
|
|
67
|
+
CREATE TABLE IF NOT EXISTS schema_migrations(version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL, details TEXT NOT NULL);
|
|
68
|
+
INSERT OR IGNORE INTO schema_migrations VALUES(2,datetime('now'),'fresh schema v2');
|
|
69
|
+
PRAGMA user_version=2;`);
|
|
70
|
+
if (version === 1) this.migrateV1();
|
|
71
|
+
this.db.exec('CREATE UNIQUE INDEX IF NOT EXISTS one_pending_write_per_file ON pending_file_writes(name)');
|
|
72
|
+
this.db.exec("CREATE TABLE IF NOT EXISTS schema_migrations(version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL, details TEXT NOT NULL)");
|
|
73
|
+
this.db.prepare('INSERT OR IGNORE INTO schema_migrations VALUES(?,?,?)').run(2, now(), 'schema v2 registered');
|
|
74
|
+
this.recoverFileWrites();
|
|
75
|
+
this.seedFileRevision('profile.json');
|
|
76
|
+
this.seedFileRevision('policy.json');
|
|
77
|
+
}
|
|
78
|
+
migrateV1() {
|
|
79
|
+
this.transaction(() => {
|
|
80
|
+
const running = this.db.prepare("SELECT id FROM runs WHERE status='running' ORDER BY started_at DESC").all();
|
|
81
|
+
for (const row of running.slice(1)) {
|
|
82
|
+
const result = { status: 'blocked', reason: 'Closed during schema v2 migration because another newer search was active' };
|
|
83
|
+
this.db.prepare("UPDATE runs SET status='blocked',finished_at=?,result=? WHERE id=?").run(now(), JSON.stringify(result), row.id);
|
|
84
|
+
}
|
|
85
|
+
this.db.exec(`ALTER TABLE attempts ADD COLUMN policy_hash TEXT;
|
|
86
|
+
ALTER TABLE attempts ADD COLUMN application_snapshot TEXT;
|
|
87
|
+
UPDATE attempts SET application_snapshot=json_object('legacy',1,'packet',json(packet)) WHERE application_snapshot IS NULL;
|
|
88
|
+
CREATE UNIQUE INDEX one_running_search ON runs(status) WHERE status='running';
|
|
89
|
+
CREATE TABLE file_revisions(name TEXT PRIMARY KEY, revision INTEGER NOT NULL, sha256 TEXT NOT NULL, updated_at TEXT NOT NULL);
|
|
90
|
+
CREATE TABLE pending_file_writes(id TEXT PRIMARY KEY, name TEXT NOT NULL, old_sha256 TEXT NOT NULL,
|
|
91
|
+
new_sha256 TEXT NOT NULL, content TEXT NOT NULL, event_kind TEXT NOT NULL, event_payload TEXT NOT NULL, created_at TEXT NOT NULL,
|
|
92
|
+
UNIQUE(name));
|
|
93
|
+
CREATE TABLE schema_migrations(version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL, details TEXT NOT NULL);
|
|
94
|
+
PRAGMA user_version=2;`);
|
|
95
|
+
this.db.prepare('INSERT INTO schema_migrations VALUES(?,?,?)').run(2, now(), JSON.stringify({ from: 1, closed_duplicate_runs: running.slice(1).map(row => row.id) }));
|
|
96
|
+
this.event('schema_migrated', { from: 1, to: 2, closed_duplicate_runs: running.slice(1).map(row => row.id) });
|
|
97
|
+
});
|
|
60
98
|
}
|
|
61
99
|
close() { this.db.close(); }
|
|
62
100
|
transaction(fn) {
|
|
@@ -68,8 +106,60 @@ export class Tracker {
|
|
|
68
106
|
this.db.prepare('INSERT INTO events(at,kind,job_id,run_id,payload) VALUES(?,?,?,?,?)')
|
|
69
107
|
.run(now(), kind, job, run, JSON.stringify(payload));
|
|
70
108
|
}
|
|
109
|
+
seedFileRevision(name) {
|
|
110
|
+
const path = localPath(this.dir, name);
|
|
111
|
+
const digest = sha256(readBytes(path, { base: this.dir }));
|
|
112
|
+
this.db.prepare('INSERT OR IGNORE INTO file_revisions VALUES(?,?,?,?)').run(name, 1, digest, now());
|
|
113
|
+
}
|
|
114
|
+
recoverFileWrites() {
|
|
115
|
+
if (!this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='pending_file_writes'").get()) return;
|
|
116
|
+
for (const row of this.db.prepare('SELECT * FROM pending_file_writes ORDER BY created_at').all()) {
|
|
117
|
+
this.transaction(() => {
|
|
118
|
+
const pending = this.db.prepare('SELECT * FROM pending_file_writes WHERE id=?').get(row.id);
|
|
119
|
+
if (!pending) return;
|
|
120
|
+
const path = localPath(this.dir, pending.name);
|
|
121
|
+
const actual = existsSync(path) ? sha256(readBytes(path, { base: this.dir })) : null;
|
|
122
|
+
if (actual !== pending.new_sha256 && actual !== pending.old_sha256) throw new Error(`Managed file ${pending.name} changed during an interrupted write; inspect it before continuing`);
|
|
123
|
+
if (actual !== pending.new_sha256) atomicWrite(path, pending.content, { base: this.dir });
|
|
124
|
+
const revision = (this.db.prepare('SELECT revision FROM file_revisions WHERE name=?').get(row.name)?.revision || 0) + 1;
|
|
125
|
+
this.db.prepare('INSERT INTO file_revisions VALUES(?,?,?,?) ON CONFLICT(name) DO UPDATE SET revision=excluded.revision,sha256=excluded.sha256,updated_at=excluded.updated_at')
|
|
126
|
+
.run(pending.name, revision, pending.new_sha256, now());
|
|
127
|
+
this.event(pending.event_kind, { ...JSON.parse(pending.event_payload), revision, sha256: pending.new_sha256, recovered_interrupted_write: true });
|
|
128
|
+
this.db.prepare('DELETE FROM pending_file_writes WHERE id=?').run(pending.id);
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
writeManagedJSON(name, value, { expectedSha256, eventKind, eventPayload = {} }) {
|
|
133
|
+
need(['profile.json', 'policy.json'].includes(name), 'Unsupported managed file');
|
|
134
|
+
const path = localPath(this.dir, name);
|
|
135
|
+
const content = JSON.stringify(value, null, 2) + '\n';
|
|
136
|
+
need(Buffer.byteLength(content, 'utf8') <= MAX_JSON_BYTES, `${name} serialized output exceeds the ${MAX_JSON_BYTES} byte limit`);
|
|
137
|
+
const newDigest = sha256(content);
|
|
138
|
+
const id = randomUUID();
|
|
139
|
+
this.transaction(() => {
|
|
140
|
+
const oldDigest = sha256(readBytes(path, { base: this.dir }));
|
|
141
|
+
need(!expectedSha256 || oldDigest === expectedSha256, `${name} changed concurrently; reload and retry`);
|
|
142
|
+
this.db.prepare('INSERT INTO pending_file_writes(id,name,old_sha256,new_sha256,content,event_kind,event_payload,created_at) VALUES(?,?,?,?,?,?,?,?)')
|
|
143
|
+
.run(id, name, oldDigest, newDigest, content, eventKind, JSON.stringify(eventPayload), now());
|
|
144
|
+
});
|
|
145
|
+
const revision = this.transaction(() => {
|
|
146
|
+
const pending = this.db.prepare('SELECT * FROM pending_file_writes WHERE id=?').get(id);
|
|
147
|
+
need(pending, `${name} write was superseded; reload and retry`);
|
|
148
|
+
const actual = sha256(readBytes(path, { base: this.dir }));
|
|
149
|
+
need(actual === pending.old_sha256 || actual === pending.new_sha256, `${name} changed concurrently; reload and retry`);
|
|
150
|
+
if (actual !== pending.new_sha256) atomicWrite(path, pending.content, { base: this.dir });
|
|
151
|
+
const revision = (this.db.prepare('SELECT revision FROM file_revisions WHERE name=?').get(name)?.revision || 0) + 1;
|
|
152
|
+
this.db.prepare('INSERT INTO file_revisions VALUES(?,?,?,?) ON CONFLICT(name) DO UPDATE SET revision=excluded.revision,sha256=excluded.sha256,updated_at=excluded.updated_at')
|
|
153
|
+
.run(name, revision, newDigest, now());
|
|
154
|
+
this.event(eventKind, { ...eventPayload, revision, sha256: newDigest });
|
|
155
|
+
this.db.prepare('DELETE FROM pending_file_writes WHERE id=?').run(id);
|
|
156
|
+
return revision;
|
|
157
|
+
});
|
|
158
|
+
return { revision, sha256: newDigest };
|
|
159
|
+
}
|
|
71
160
|
profile(requireReady = true) {
|
|
72
|
-
const profile = readJSON(
|
|
161
|
+
const profile = readJSON(localPath(this.dir, 'profile.json'), { base: this.dir });
|
|
162
|
+
validateSchema('profile', profile);
|
|
73
163
|
need(profile.schema_version === 1, 'Unsupported profile schema');
|
|
74
164
|
if (requireReady) {
|
|
75
165
|
need(text(profile.confirmed_at) && Number.isFinite(Date.parse(profile.confirmed_at)), 'Candidate must confirm profile before search/apply');
|
|
@@ -83,26 +173,34 @@ export class Tracker {
|
|
|
83
173
|
return { ...row, payload: JSON.parse(row.payload), draft: row.draft && JSON.parse(row.draft), approval: row.approval && JSON.parse(row.approval) };
|
|
84
174
|
}
|
|
85
175
|
runStart(input) {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
176
|
+
return this.transaction(() => {
|
|
177
|
+
need(!this.db.prepare("SELECT id FROM runs WHERE status='running'").get(), 'Finish or recover the previous running search before starting another');
|
|
178
|
+
const p = this.profile();
|
|
179
|
+
const id = randomUUID();
|
|
180
|
+
this.db.prepare('INSERT INTO runs VALUES(?,?,NULL,?,?,?,NULL)').run(id, now(), 'running', p.hash, JSON.stringify(input));
|
|
181
|
+
return { run_id: id, profile_hash: p.hash };
|
|
182
|
+
});
|
|
90
183
|
}
|
|
91
184
|
runEvent(input) {
|
|
92
|
-
need(this.db.prepare('SELECT id FROM runs WHERE id=?').get(input.run_id), 'Unknown run');
|
|
93
185
|
need(text(input.kind), 'Event kind required');
|
|
94
|
-
this.
|
|
95
|
-
|
|
186
|
+
return this.transaction(() => {
|
|
187
|
+
need(this.db.prepare("SELECT id FROM runs WHERE id=? AND status='running'").get(input.run_id), 'Unknown or finished run');
|
|
188
|
+
this.event(input.kind, input.data ?? {}, null, input.run_id);
|
|
189
|
+
return { recorded: true };
|
|
190
|
+
});
|
|
96
191
|
}
|
|
97
192
|
runFinish(input) {
|
|
98
193
|
need(['complete', 'partial', 'blocked', 'failed'].includes(input.status), 'Invalid run result');
|
|
99
194
|
need(text(input.reason), 'Run stop reason required');
|
|
100
|
-
|
|
101
|
-
.
|
|
102
|
-
|
|
103
|
-
|
|
195
|
+
return this.transaction(() => {
|
|
196
|
+
const result = this.db.prepare('UPDATE runs SET status=?,finished_at=?,result=? WHERE id=? AND status=?')
|
|
197
|
+
.run(input.status, now(), JSON.stringify(input), input.run_id, 'running');
|
|
198
|
+
need(result.changes === 1, 'Unknown or already finished run');
|
|
199
|
+
return { recorded: true };
|
|
200
|
+
});
|
|
104
201
|
}
|
|
105
202
|
put(input) {
|
|
203
|
+
validateSchema('vacancy', input);
|
|
106
204
|
need(/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(input.slug), 'Invalid slug');
|
|
107
205
|
need(text(input.title), 'Title required');
|
|
108
206
|
const canonical = safeURL(input.original_url || input.url);
|
|
@@ -140,6 +238,7 @@ export class Tracker {
|
|
|
140
238
|
return this.transaction(() => {
|
|
141
239
|
const job = this.job(input.slug);
|
|
142
240
|
need(['discovered', 'draft', 'needs_user', 'approved'].includes(job.status), 'Vacancy cannot be drafted in this status');
|
|
241
|
+
validateSchema('draft', input);
|
|
143
242
|
const p = this.profile();
|
|
144
243
|
need(job.payload.profile_hash === p.hash, 'Reassess vacancy for current profile');
|
|
145
244
|
need(['suitable', 'review'].includes(job.payload.match.verdict), 'Vacancy must be assessed first');
|
|
@@ -167,7 +266,9 @@ export class Tracker {
|
|
|
167
266
|
});
|
|
168
267
|
}
|
|
169
268
|
policy(input) {
|
|
170
|
-
const
|
|
269
|
+
const path = localPath(this.dir, 'policy.json');
|
|
270
|
+
const currentBytes = readBytes(path, { base: this.dir });
|
|
271
|
+
const current = JSON.parse(currentBytes.toString('utf8').replace(/^\uFEFF/, ''));
|
|
171
272
|
need(['review_each', 'auto'].includes(input.mode), 'Invalid policy mode');
|
|
172
273
|
if (input.mode === 'auto') {
|
|
173
274
|
need(text(input.auto?.user_authorization), 'Explicit scoped user authorization required');
|
|
@@ -176,8 +277,11 @@ export class Tracker {
|
|
|
176
277
|
need(input.auto.profile_hash === this.profile().hash, 'Auto authorization must bind current profile hash');
|
|
177
278
|
}
|
|
178
279
|
const policy = { ...current, mode: input.mode, auto: input.mode === 'auto' ? input.auto : null };
|
|
179
|
-
|
|
180
|
-
|
|
280
|
+
validateSchema('policy', policy);
|
|
281
|
+
const autoAudit = policy.auto && { profile_hash: policy.auto.profile_hash, expires_at: policy.auto.expires_at,
|
|
282
|
+
max_attempts_per_utc_day: policy.auto.max_attempts_per_utc_day };
|
|
283
|
+
this.writeManagedJSON('policy.json', policy, { expectedSha256: sha256(currentBytes), eventKind: 'policy_changed',
|
|
284
|
+
eventPayload: { mode: policy.mode, auto: autoAudit } });
|
|
181
285
|
return policy;
|
|
182
286
|
}
|
|
183
287
|
begin(input) {
|
|
@@ -192,17 +296,19 @@ export class Tracker {
|
|
|
192
296
|
need(Number.isInteger(p.profile.hirify_profile_id) && p.profile.hirify_profile_id > 0 && job.draft.hirify_profile_id === p.profile.hirify_profile_id, 'Confirm exact Hirify profile');
|
|
193
297
|
need(p.profile.resume?.review_status === 'ready', 'Resume review not ready');
|
|
194
298
|
need(text(p.profile.resume.path) && text(p.profile.resume.sha256), 'Resume file and fingerprint required');
|
|
195
|
-
const resume =
|
|
299
|
+
const resume = localPath(this.dir, p.profile.resume.path);
|
|
196
300
|
need(createHash('sha256').update(readFileSync(resume)).digest('hex') === p.profile.resume.sha256, 'Resume file changed; review and update profile');
|
|
197
301
|
const policy = readJSON(join(this.dir, 'policy.json'));
|
|
198
302
|
const approved = job.approval?.draft_hash === hash(job.draft);
|
|
199
303
|
let mode = 'review_each';
|
|
304
|
+
let policyHash = null;
|
|
200
305
|
if (!approved) {
|
|
201
306
|
mode = 'auto';
|
|
202
307
|
const auto = policy.auto;
|
|
203
308
|
need(policy.mode === 'auto' && auto && text(auto.user_authorization), 'Explicit approval required');
|
|
204
309
|
need(auto.profile_hash === p.hash && Date.parse(auto.expires_at) > Date.now(), 'Auto authorization expired or profile changed');
|
|
205
310
|
need(Number.isInteger(auto.max_attempts_per_utc_day) && auto.max_attempts_per_utc_day > 0, 'Invalid auto cap');
|
|
311
|
+
policyHash = hash(policy);
|
|
206
312
|
const requirements = job.payload.match.requirements;
|
|
207
313
|
need(Array.isArray(requirements) && requirements.length > 0 && requirements.every(r => r.result === 'pass' && text(r.evidence)), 'Auto mode requires all critical requirements checked');
|
|
208
314
|
const used = this.db.prepare('SELECT COUNT(*) AS count FROM attempts WHERE started_at>=?').get(now().slice(0, 10)).count;
|
|
@@ -210,20 +316,24 @@ export class Tracker {
|
|
|
210
316
|
}
|
|
211
317
|
// UNIQUE(job_id) is the durable duplicate-send guard, including unknown results.
|
|
212
318
|
const attempt = randomUUID();
|
|
213
|
-
const
|
|
214
|
-
|
|
319
|
+
const applicationSnapshot = { vacancy: job.payload, draft: job.draft, profile_hash: p.hash, created_at: now() };
|
|
320
|
+
const packet = { ...job.draft, slug: job.payload.slug, mode, attempt_id: attempt, policy_hash: policyHash, application_snapshot_hash: hash(applicationSnapshot) };
|
|
321
|
+
this.db.prepare('INSERT INTO attempts(id,job_id,started_at,mode,packet,result,policy_hash,application_snapshot) VALUES(?,?,?,?,?,NULL,?,?)')
|
|
322
|
+
.run(attempt, job.id, now(), mode, JSON.stringify(packet), policyHash, JSON.stringify(applicationSnapshot));
|
|
215
323
|
this.db.prepare('UPDATE jobs SET status=?,updated_at=? WHERE id=?').run('submitting', now(), job.id);
|
|
216
324
|
this.event('send_intent', { attempt_id: attempt, mode }, job.id);
|
|
217
325
|
return packet;
|
|
218
326
|
});
|
|
219
327
|
}
|
|
220
328
|
finish(input) {
|
|
329
|
+
validateSchema('finish', input);
|
|
221
330
|
need(['submitted', 'failed', 'unknown'].includes(input.outcome), 'Invalid send outcome');
|
|
222
331
|
need(text(input.evidence), 'Response or uncertainty evidence required');
|
|
223
332
|
if (input.outcome === 'submitted') need(text(input.application_id), 'Successful Hirify response must include application ID');
|
|
224
333
|
return this.transaction(() => {
|
|
225
334
|
const attempt = this.db.prepare('SELECT * FROM attempts WHERE id=?').get(input.attempt_id);
|
|
226
335
|
need(attempt, 'Unknown attempt');
|
|
336
|
+
if (['submitted', 'unknown'].includes(input.outcome)) need(this.db.prepare('SELECT attempt_id FROM dispatches WHERE attempt_id=?').get(attempt.id), 'Cannot record a network outcome before dispatch');
|
|
227
337
|
if (attempt.result) {
|
|
228
338
|
need(JSON.parse(attempt.result).outcome === input.outcome, 'Use resolve for an unknown result');
|
|
229
339
|
return { unchanged: true };
|
|
@@ -243,10 +353,14 @@ export class Tracker {
|
|
|
243
353
|
need(job.status === 'submitting' && job.id === attempt.job_id, 'Attempt is not pending');
|
|
244
354
|
const p = this.profile();
|
|
245
355
|
need(p.hash === packet.profile_hash && hash(job.payload) === packet.vacancy_hash, 'Profile or vacancy changed after begin');
|
|
246
|
-
need(createHash('sha256').update(readFileSync(
|
|
356
|
+
need(createHash('sha256').update(readFileSync(localPath(this.dir, p.profile.resume.path))).digest('hex') === p.profile.resume.sha256, 'Resume changed after begin');
|
|
247
357
|
if (packet.mode === 'auto') {
|
|
248
|
-
const policy = readJSON(
|
|
358
|
+
const policy = readJSON(localPath(this.dir, 'policy.json'), { base: this.dir });
|
|
359
|
+
const policyHash = hash(policy);
|
|
249
360
|
need(policy.mode === 'auto' && policy.auto?.profile_hash === p.hash && Date.parse(policy.auto.expires_at) > Date.now(), 'Auto permission revoked or expired');
|
|
361
|
+
need(packet.policy_hash && attempt.policy_hash === packet.policy_hash && policyHash === packet.policy_hash, 'Auto permission changed after begin');
|
|
362
|
+
const used = this.db.prepare('SELECT COUNT(*) AS count FROM attempts WHERE started_at>=?').get(now().slice(0, 10)).count;
|
|
363
|
+
need(used <= policy.auto.max_attempts_per_utc_day, 'Auto daily cap changed after begin');
|
|
250
364
|
} else need(job.approval?.draft_hash === hash(job.draft), 'Approval no longer valid');
|
|
251
365
|
this.db.prepare('INSERT INTO dispatches VALUES(?,?)').run(attempt.id, now());
|
|
252
366
|
this.event('cli_dispatch', { attempt_id: attempt.id }, job.id);
|
|
@@ -268,6 +382,7 @@ export class Tracker {
|
|
|
268
382
|
});
|
|
269
383
|
}
|
|
270
384
|
status(input) {
|
|
385
|
+
validateSchema('status', input);
|
|
271
386
|
const allowed = {
|
|
272
387
|
discovered: ['dismissed', 'needs_user'], draft: ['dismissed', 'needs_user'],
|
|
273
388
|
approved: ['dismissed', 'needs_user'], needs_user: ['dismissed', 'submitted_external'],
|
|
@@ -288,6 +403,43 @@ export class Tracker {
|
|
|
288
403
|
list() {
|
|
289
404
|
return this.db.prepare('SELECT * FROM jobs ORDER BY created_at DESC').all().map(r => ({ ...r, payload: JSON.parse(r.payload), draft: r.draft && JSON.parse(r.draft), approval: r.approval && JSON.parse(r.approval) }));
|
|
290
405
|
}
|
|
406
|
+
attempts() {
|
|
407
|
+
return this.db.prepare('SELECT * FROM attempts ORDER BY started_at DESC').all().map(row => ({ ...row,
|
|
408
|
+
packet: JSON.parse(row.packet), result: row.result && JSON.parse(row.result), application_snapshot: JSON.parse(row.application_snapshot) }));
|
|
409
|
+
}
|
|
410
|
+
attemptPacket(attemptId) {
|
|
411
|
+
need(text(attemptId), 'Attempt id required');
|
|
412
|
+
const row = this.db.prepare('SELECT packet FROM attempts WHERE id=?').get(attemptId);
|
|
413
|
+
need(row, 'Unknown attempt');
|
|
414
|
+
return JSON.parse(row.packet);
|
|
415
|
+
}
|
|
416
|
+
integrity() {
|
|
417
|
+
const database = this.db.prepare('PRAGMA integrity_check').all().map(row => row.integrity_check);
|
|
418
|
+
const foreignKeys = this.db.prepare('PRAGMA foreign_key_check').all();
|
|
419
|
+
const files = {};
|
|
420
|
+
for (const [name, schema] of [['profile.json', 'profile'], ['policy.json', 'policy']]) {
|
|
421
|
+
try {
|
|
422
|
+
const value = readJSON(localPath(this.dir, name), { base: this.dir });
|
|
423
|
+
const errors = schemaErrors(schema, value);
|
|
424
|
+
const actual = sha256(readBytes(localPath(this.dir, name), { base: this.dir }));
|
|
425
|
+
const tracked = this.db.prepare('SELECT revision,sha256 FROM file_revisions WHERE name=?').get(name);
|
|
426
|
+
files[name] = { valid: errors.length === 0, errors, revision: tracked?.revision || null,
|
|
427
|
+
tracked_sha256_matches: tracked?.sha256 === actual };
|
|
428
|
+
} catch (error) { files[name] = { valid: false, errors: [error.message] }; }
|
|
429
|
+
}
|
|
430
|
+
return { schema_version: this.db.prepare('PRAGMA user_version').get().user_version,
|
|
431
|
+
database_ok: database.length === 1 && database[0] === 'ok', database, foreign_key_violations: foreignKeys,
|
|
432
|
+
files, running_runs: this.db.prepare("SELECT id,started_at FROM runs WHERE status='running'").all(),
|
|
433
|
+
unresolved_attempts: this.db.prepare("SELECT attempts.id,jobs.status,attempts.started_at FROM attempts JOIN jobs ON jobs.id=attempts.job_id WHERE jobs.status IN ('submitting','submission_unknown')").all(),
|
|
434
|
+
pending_file_writes: this.db.prepare('SELECT id,name,created_at FROM pending_file_writes').all(),
|
|
435
|
+
migrations: this.db.prepare('SELECT * FROM schema_migrations ORDER BY version').all() };
|
|
436
|
+
}
|
|
437
|
+
recover(input) {
|
|
438
|
+
need(input && typeof input === 'object', 'Recovery input required');
|
|
439
|
+
if (input.type === 'run') return this.runFinish({ run_id: input.run_id, status: input.status || 'blocked', reason: input.reason });
|
|
440
|
+
if (input.type === 'attempt') return this.resolveUnknown({ slug: input.slug, outcome: input.outcome, evidence: input.evidence, application_id: input.application_id });
|
|
441
|
+
throw new Error('Recovery type must be run or attempt');
|
|
442
|
+
}
|
|
291
443
|
history() { return this.db.prepare('SELECT * FROM events ORDER BY id').all().map(r => ({ ...r, payload: JSON.parse(r.payload) })); }
|
|
292
444
|
runs() { return this.db.prepare('SELECT * FROM runs ORDER BY started_at DESC').all().map(r => ({ ...r, input: JSON.parse(r.input), result: r.result && JSON.parse(r.result) })); }
|
|
293
445
|
report() {
|
|
@@ -295,17 +447,20 @@ export class Tracker {
|
|
|
295
447
|
const lines = ['# Поиск работы — локальный отчёт', '', `Обновлён: ${now()}`, '',
|
|
296
448
|
'История SQLite; ответы работодателей вносятся по сообщению пользователя.', '',
|
|
297
449
|
'| Вакансия | Статус | Совпадение / причины | Следующий шаг |', '|---|---|---|---|'];
|
|
450
|
+
const attempts = new Map(this.attempts().map(attempt => [attempt.job_id, attempt]));
|
|
298
451
|
for (const job of this.list()) {
|
|
452
|
+
const snapshot = attempts.get(job.id)?.application_snapshot?.vacancy;
|
|
453
|
+
const shown = ['submitting', 'submission_unknown', 'submitted', 'failed', 'interview', 'rejected', 'withdrawn', 'offer', 'accepted', 'declined'].includes(job.status) && snapshot ? snapshot : job.payload;
|
|
299
454
|
const next = ({ discovered: 'Проверить и подготовить', draft: 'Согласовать отклик', approved: 'Проверить квоту и отправить',
|
|
300
455
|
needs_user: 'Ответы / ручной отклик', submitting: 'Проверить результат; не повторять', submission_unknown: 'Уточнить результат; не повторять',
|
|
301
456
|
submitted: 'Ждать ответа', submitted_external: 'Ждать ответа', interview: 'Подготовиться к интервью', offer: 'Обсудить предложение' })[job.status] || 'Нет автоматического действия';
|
|
302
|
-
lines.push(`| ${esc(
|
|
457
|
+
lines.push(`| ${esc(shown.title)} (${esc(shown.url)}) | ${job.status} | ${esc(shown.match.verdict)}: ${esc(shown.match.reasons?.join('; '))} | ${next} |`);
|
|
303
458
|
}
|
|
304
459
|
lines.push('', '## Запуски', '');
|
|
305
460
|
for (const run of this.runs()) lines.push(`- ${run.started_at}: ${run.status}; ${esc(run.result?.reason || 'Не завершён — проверить журнал перед продолжением')}`);
|
|
306
461
|
const content = lines.join('\n') + '\n';
|
|
307
462
|
const path = join(this.dir, 'reports', 'latest.md');
|
|
308
|
-
|
|
463
|
+
atomicWrite(path, content, { base: this.dir });
|
|
309
464
|
return { path, content };
|
|
310
465
|
}
|
|
311
466
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import Ajv2020 from 'ajv/dist/2020.js';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, join, resolve } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
7
|
+
const ajv = new Ajv2020({ allErrors: true, strict: true, allowUnionTypes: true });
|
|
8
|
+
ajv.addFormat('date-time', value => typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(value) && Number.isFinite(Date.parse(value)));
|
|
9
|
+
ajv.addFormat('uri', value => {
|
|
10
|
+
try { const url = new URL(value); return Boolean(url.protocol); } catch { return false; }
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const validators = new Map();
|
|
14
|
+
for (const name of ['profile', 'policy', 'vacancy', 'draft', 'finish', 'status']) {
|
|
15
|
+
const schema = JSON.parse(readFileSync(join(ROOT, 'schemas', `${name}.schema.json`), 'utf8'));
|
|
16
|
+
validators.set(name, ajv.compile(schema));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function validateSchema(name, value) {
|
|
20
|
+
const validate = validators.get(name);
|
|
21
|
+
if (!validate) throw new Error(`Unknown schema: ${name}`);
|
|
22
|
+
if (validate(value)) return value;
|
|
23
|
+
const detail = validate.errors.map(error => `${error.instancePath || '/'} ${error.message}`).join('; ');
|
|
24
|
+
throw new Error(`${name} schema: ${detail}`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function schemaErrors(name, value) {
|
|
28
|
+
try { validateSchema(name, value); return []; }
|
|
29
|
+
catch (error) { return [error.message]; }
|
|
30
|
+
}
|
|
@@ -43,10 +43,12 @@ review_each: показать роль/компанию/ссылку, выбра
|
|
|
43
43
|
не заменяют серверный профиль, и CLI не загружает локальное резюме автоматически.
|
|
44
44
|
Если профиль Hirify неполон/изменился, сначала показать отличия кандидату.
|
|
45
45
|
2. `begin` создаёт единственное намерение и возвращает attempt_id и точный пакет.
|
|
46
|
-
|
|
46
|
+
CLI уже сохраняет пакет в уникальный `packet_path` вида
|
|
47
|
+
`D/materials/attempt-<attempt_id>.json`. Использовать именно возвращённый путь,
|
|
48
|
+
не переименовывать и не собирать пакет вручную. Begin не отправляет отклик.
|
|
47
49
|
3. Только после успешного begin в этом процессе вызвать Hirify один раз. Чтобы не
|
|
48
50
|
исказить кавычки/переносы письма, использовать
|
|
49
|
-
`node <P>/scripts/send-packet.mjs --data <D> <
|
|
51
|
+
`node <P>/scripts/send-packet.mjs --data <D> <packet_path из begin>`.
|
|
50
52
|
Команда проверяет сохранённое намерение, отмечает запуск до сети и вызывает CLI
|
|
51
53
|
с `vacancy apply <slug> --profile <id> --cover <text> --json` массивом аргументов.
|
|
52
54
|
4. По достоверному успеху сохранить finish submitted с application_id и кратким
|
|
@@ -20,8 +20,9 @@ findings; затем отдельно оценить вёрстку и факт
|
|
|
20
20
|
с реальным форматом файла. Если парсер/рендер недоступен, указать границу проверки.
|
|
21
21
|
Не загружать резюме в сторонний ATS-checker без разрешения пользователя.
|
|
22
22
|
|
|
23
|
-
Сохранить результат по [шаблону отчёта](../../templates/resume-review.md) в
|
|
24
|
-
D/materials/resume
|
|
23
|
+
Сохранить результат по [шаблону отчёта](../../templates/resume-review.md) в точный
|
|
24
|
+
versioned `review_path`, возвращённый `resume check` (`D/materials/resume-<sha>/review.md`).
|
|
25
|
+
Предлагать конкретные правки, не создавать достижения.
|
|
25
26
|
Если запрошена правка, сохранить новую версию, проверить её и обновить путь, SHA-256
|
|
26
27
|
и review_status в профиле. Повторно подтвердить изменённые факты с кандидатом.
|
|
27
28
|
|
|
@@ -28,3 +28,11 @@ note (содержание, источник, дата). Не заявлять,
|
|
|
28
28
|
|
|
29
29
|
Ничего не отправлять работодателям из скилла трекинга. Отмена локального статуса
|
|
30
30
|
не отзывает отправленное через Hirify заявление. Напоминания — только пользователю.
|
|
31
|
+
|
|
32
|
+
Для диагностики использовать doctor и recover inspect; recover apply закрывает run
|
|
33
|
+
или подтверждает исход attempt только по переданному пользователем доказательству.
|
|
34
|
+
Backup/restore всегда проверяют точный inventory manifest, SQLite и согласованность
|
|
35
|
+
ревизий JSON; restore требует точную фразу и сохраняет сырой quarantine-снимок
|
|
36
|
+
подтверждения. Privacy map и [карта данных](../../references/privacy.md) показывают
|
|
37
|
+
размещение PII, export создаёт проверяемую копию, redact и purge требуют точных фраз,
|
|
38
|
+
а purge ещё и обязательный backup_directory.
|
|
@@ -33,6 +33,13 @@ const codex = load(join(root, '.codex-plugin/plugin.json'));
|
|
|
33
33
|
const claude = load(join(root, '.claude-plugin/plugin.json'));
|
|
34
34
|
assert.equal(codex.name, 'jobhunt-kit'); assert.equal(claude.name, codex.name);
|
|
35
35
|
assert.equal(codex.version, claude.version);
|
|
36
|
+
assert.equal(load('package.json').version, codex.version);
|
|
37
|
+
assert.equal(load('plugins/jobhunt-kit/package.json').version, codex.version);
|
|
38
|
+
assert.equal(load('.claude-plugin/marketplace.json').plugins[0].version, codex.version);
|
|
39
|
+
for (const name of ['profile', 'policy', 'vacancy', 'draft', 'finish', 'status']) {
|
|
40
|
+
const schema = load(join(root, 'schemas', `${name}.schema.json`));
|
|
41
|
+
assert.equal(schema.$schema, 'https://json-schema.org/draft/2020-12/schema');
|
|
42
|
+
}
|
|
36
43
|
for (const file of ['.agents/plugins/marketplace.json', '.claude-plugin/marketplace.json']) {
|
|
37
44
|
const market = load(file);
|
|
38
45
|
for (const plugin of market.plugins) {
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { delimiter, join, resolve } from 'node:path';
|
|
6
|
+
import { pathToFileURL } from 'node:url';
|
|
7
|
+
|
|
8
|
+
const root = mkdtempSync(join(tmpdir(), 'jobhunt-pack-smoke-'));
|
|
9
|
+
try {
|
|
10
|
+
const env = { ...process.env, npm_config_cache: join(root, 'npm-cache') };
|
|
11
|
+
const npmCli = process.env.npm_execpath;
|
|
12
|
+
if (!npmCli) throw new Error('Run this smoke check through npm run pack:smoke');
|
|
13
|
+
const runNpm = (args, options = {}) => execFileSync(process.execPath, [npmCli, ...args], { env, ...options });
|
|
14
|
+
const packed = JSON.parse(runNpm(['pack', '--json', '--pack-destination', root], { cwd: resolve('.'), encoding: 'utf8' }));
|
|
15
|
+
const archive = join(root, packed[0].filename);
|
|
16
|
+
const consumer = join(root, 'consumer');
|
|
17
|
+
mkdirSync(consumer);
|
|
18
|
+
execFileSync('tar', ['-xzf', archive, '-C', consumer], { stdio: 'pipe' });
|
|
19
|
+
const installed = join(consumer, 'package');
|
|
20
|
+
const { install } = await import(pathToFileURL(join(installed, 'bin', 'jobhunt-kit.mjs')));
|
|
21
|
+
const workspace = join(root, 'installed-workspace');
|
|
22
|
+
const result = install(workspace, { source: installed, installDependencies: false, agents: ['codex'] });
|
|
23
|
+
assert.equal(result.scope, 'project');
|
|
24
|
+
for (const name of ['ajv', 'fast-deep-equal', 'fast-uri', 'json-schema-traverse', 'require-from-string']) {
|
|
25
|
+
cpSync(resolve('node_modules', name), join(workspace, 'node_modules', name), { recursive: true });
|
|
26
|
+
}
|
|
27
|
+
const { runCommand } = await import(pathToFileURL(join(workspace, 'plugins', 'jobhunt-kit', 'scripts', 'commands.mjs')));
|
|
28
|
+
const initialized = runCommand(['profile', 'init', '--workspace', workspace], { cwd: workspace });
|
|
29
|
+
assert.equal(initialized.initialized, true);
|
|
30
|
+
const doctor = runCommand(['doctor', '--workspace', workspace], { cwd: workspace });
|
|
31
|
+
assert.equal(doctor.integrity.database_ok, true);
|
|
32
|
+
assert.equal(JSON.parse(readFileSync(join(workspace, 'plugins', 'jobhunt-kit', 'package.json'))).dependencies.ajv, '8.20.0');
|
|
33
|
+
const globalHome = join(root, 'global-home');
|
|
34
|
+
const global = install('.', { source: installed, installDependencies: false, agents: ['codex'], scope: 'global', home: globalHome });
|
|
35
|
+
const bundle = join(global.agents[0].path, 'bundle');
|
|
36
|
+
assert.equal(existsSync(join(bundle, 'node_modules')), false);
|
|
37
|
+
const globalData = join(root, 'global-data');
|
|
38
|
+
const fakeBin = join(root, 'fake-bin');
|
|
39
|
+
mkdirSync(fakeBin);
|
|
40
|
+
const fakeNpm = join(fakeBin, 'fake-npm.mjs');
|
|
41
|
+
writeFileSync(fakeNpm, `import { cpSync, mkdirSync, readdirSync } from 'node:fs';\nimport { join } from 'node:path';\nconst destination=join(process.cwd(),'node_modules');\nmkdirSync(destination,{recursive:true});\nfor(const name of readdirSync(process.env.JOBHUNT_SMOKE_MODULES)){if(name!=='.bin'&&name!=='jobhunt-kit')cpSync(join(process.env.JOBHUNT_SMOKE_MODULES,name),join(destination,name),{recursive:true});}\n`);
|
|
42
|
+
writeFileSync(join(fakeBin, 'npm.cmd'), `@echo off\r\n"${process.execPath}" "${fakeNpm}" %*\r\n`);
|
|
43
|
+
writeFileSync(join(fakeBin, 'npm'), `#!${process.execPath}\nimport '${fakeNpm.replaceAll('\\', '/')}';\n`, { mode: 0o755 });
|
|
44
|
+
const isolatedEnv = { ...env, PATH: `${fakeBin}${delimiter}${process.env.PATH}`, JOBHUNT_SMOKE_MODULES: resolve('node_modules') };
|
|
45
|
+
execFileSync(process.execPath, [join(bundle, 'scripts', 'setup.mjs'), '--data', globalData, '--install-cli'], { env: isolatedEnv, stdio: 'pipe', timeout: 60000 });
|
|
46
|
+
assert.equal(existsSync(join(bundle, 'node_modules', 'ajv')), true);
|
|
47
|
+
assert.equal(JSON.parse(readFileSync(join(globalData, 'profile.json'))).schema_version, 1);
|
|
48
|
+
console.log(`PACK_SMOKE_OK: ${packed[0].filename}, ${packed[0].entryCount} entries`);
|
|
49
|
+
} finally {
|
|
50
|
+
const absolute = resolve(root);
|
|
51
|
+
assert.ok(absolute.startsWith(resolve(tmpdir())) && absolute.includes('jobhunt-pack-smoke-'));
|
|
52
|
+
rmSync(absolute, { recursive: true, force: true });
|
|
53
|
+
}
|