jobhunt-kit 0.4.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.
Files changed (37) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +19 -2
  3. package/bin/jobhunt-kit.mjs +7 -3
  4. package/installer-assets/gitignore.txt +1 -0
  5. package/installer-assets/runtime-lock.json +56 -2
  6. package/installer-assets/workspace-lock.json +56 -2
  7. package/package.json +3 -1
  8. package/plugins/jobhunt-kit/.claude-plugin/plugin.json +1 -1
  9. package/plugins/jobhunt-kit/.codex-plugin/plugin.json +1 -1
  10. package/plugins/jobhunt-kit/package-lock.json +56 -2
  11. package/plugins/jobhunt-kit/package.json +2 -1
  12. package/plugins/jobhunt-kit/references/cli.md +27 -1
  13. package/plugins/jobhunt-kit/references/privacy.md +24 -0
  14. package/plugins/jobhunt-kit/references/storage.md +11 -5
  15. package/plugins/jobhunt-kit/schemas/draft.schema.json +13 -0
  16. package/plugins/jobhunt-kit/schemas/finish.schema.json +11 -0
  17. package/plugins/jobhunt-kit/schemas/policy.schema.json +36 -0
  18. package/plugins/jobhunt-kit/schemas/profile.schema.json +80 -0
  19. package/plugins/jobhunt-kit/schemas/status.schema.json +12 -0
  20. package/plugins/jobhunt-kit/schemas/vacancy.schema.json +29 -0
  21. package/plugins/jobhunt-kit/scripts/commands.mjs +57 -11
  22. package/plugins/jobhunt-kit/scripts/extract-resume.mjs +5 -3
  23. package/plugins/jobhunt-kit/scripts/files.mjs +69 -0
  24. package/plugins/jobhunt-kit/scripts/maintenance.mjs +292 -0
  25. package/plugins/jobhunt-kit/scripts/profile.mjs +23 -20
  26. package/plugins/jobhunt-kit/scripts/resume.mjs +42 -38
  27. package/plugins/jobhunt-kit/scripts/setup.mjs +8 -10
  28. package/plugins/jobhunt-kit/scripts/tracker.mjs +188 -33
  29. package/plugins/jobhunt-kit/scripts/validation.mjs +30 -0
  30. package/plugins/jobhunt-kit/skills/job-apply/SKILL.md +4 -2
  31. package/plugins/jobhunt-kit/skills/job-resume/SKILL.md +3 -2
  32. package/plugins/jobhunt-kit/skills/job-track/SKILL.md +8 -0
  33. package/scripts/check-package.mjs +7 -0
  34. package/scripts/pack-smoke.mjs +53 -0
  35. package/tests/hardening.test.mjs +337 -0
  36. package/tests/installer.test.mjs +9 -2
  37. package/tests/tracker.test.mjs +3 -0
@@ -0,0 +1,80 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "jobhunt-kit/profile",
4
+ "type": "object",
5
+ "required": ["schema_version", "confirmed_at", "identity", "search", "languages", "experience", "education", "skills", "achievements", "evidence", "answers", "availability", "writing", "hirify_profile_id", "resume"],
6
+ "properties": {
7
+ "schema_version": { "const": 1 },
8
+ "confirmed_at": { "type": ["string", "null"], "format": "date-time" },
9
+ "identity": {
10
+ "type": "object",
11
+ "required": ["name", "email", "phone", "city", "country", "timezone", "contact_links"],
12
+ "properties": {
13
+ "name": { "type": ["string", "null"] }, "email": { "type": ["string", "null"] },
14
+ "phone": { "type": ["string", "null"] }, "city": { "type": ["string", "null"] },
15
+ "country": { "type": ["string", "null"] }, "timezone": { "type": ["string", "null"] },
16
+ "contact_links": { "type": "array", "items": { "type": "string" } }
17
+ },
18
+ "additionalProperties": false
19
+ },
20
+ "search": {
21
+ "type": "object",
22
+ "required": ["roles", "levels", "countries", "work_modes", "employment_types", "industries", "excluded_companies", "excluded_keywords", "work_authorization", "hard_constraints", "preferences", "feed_ids", "queries", "salary"],
23
+ "properties": {
24
+ "roles": { "$ref": "#/$defs/strings" }, "levels": { "$ref": "#/$defs/strings" },
25
+ "countries": { "$ref": "#/$defs/strings" }, "work_modes": { "$ref": "#/$defs/strings" },
26
+ "employment_types": { "$ref": "#/$defs/strings" }, "industries": { "$ref": "#/$defs/strings" },
27
+ "excluded_companies": { "$ref": "#/$defs/strings" }, "excluded_keywords": { "$ref": "#/$defs/strings" },
28
+ "work_authorization": { "$ref": "#/$defs/strings" }, "hard_constraints": { "$ref": "#/$defs/strings" },
29
+ "preferences": { "$ref": "#/$defs/strings" }, "feed_ids": { "type": "array", "items": { "type": ["string", "integer"] } },
30
+ "queries": { "$ref": "#/$defs/strings" },
31
+ "relocation": {}, "travel": {}, "working_hours": {}, "sponsorship": {},
32
+ "unknown_salary": { "type": "string" }, "unknown_critical_requirement": { "type": "string" },
33
+ "salary": {
34
+ "type": "object",
35
+ "required": ["min", "target", "currency", "period", "tax_basis"],
36
+ "properties": {
37
+ "min": { "type": ["number", "null"], "minimum": 0 }, "target": { "type": ["number", "null"], "minimum": 0 },
38
+ "currency": { "type": ["string", "null"] }, "period": { "type": ["string", "null"] }, "tax_basis": { "type": ["string", "null"] }
39
+ },
40
+ "additionalProperties": false
41
+ }
42
+ },
43
+ "additionalProperties": false
44
+ },
45
+ "languages": { "type": "array", "items": { "$ref": "#/$defs/object" } },
46
+ "experience": { "type": "array", "items": { "$ref": "#/$defs/evidenced" } },
47
+ "education": { "type": "array", "items": { "$ref": "#/$defs/evidenced" } },
48
+ "skills": { "type": "array", "items": { "$ref": "#/$defs/evidenced" } },
49
+ "achievements": { "type": "array", "items": { "$ref": "#/$defs/evidenced" } },
50
+ "evidence": {
51
+ "type": "array",
52
+ "items": {
53
+ "type": "object", "required": ["id", "text", "source", "confirmed"],
54
+ "properties": { "id": { "type": "string", "minLength": 1 }, "text": { "type": "string", "minLength": 1 }, "source": { "type": "string", "minLength": 1 }, "confirmed": { "type": "boolean" } },
55
+ "additionalProperties": true
56
+ }
57
+ },
58
+ "answers": { "type": "array", "items": { "$ref": "#/$defs/evidenced" } },
59
+ "availability": { "$ref": "#/$defs/object" }, "writing": { "$ref": "#/$defs/object" },
60
+ "hirify_profile_id": { "type": ["integer", "null"], "minimum": 1 },
61
+ "resume": {
62
+ "type": "object", "required": ["path", "sha256", "review_status"],
63
+ "properties": {
64
+ "path": { "type": ["string", "null"] }, "sha256": { "type": ["string", "null"], "pattern": "^[a-f0-9]{64}$" },
65
+ "review_status": { "enum": ["not_reviewed", "needs_changes", "ready"] }
66
+ },
67
+ "additionalProperties": false
68
+ }
69
+ },
70
+ "$defs": {
71
+ "strings": { "type": "array", "items": { "type": "string", "minLength": 1 } },
72
+ "object": { "type": "object", "additionalProperties": true },
73
+ "evidenced": {
74
+ "type": "object",
75
+ "properties": { "evidence_ids": { "type": "array", "items": { "type": "string", "minLength": 1 } } },
76
+ "additionalProperties": true
77
+ }
78
+ },
79
+ "additionalProperties": false
80
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "jobhunt-kit/status",
4
+ "type": "object",
5
+ "required": ["slug", "status", "note"],
6
+ "properties": {
7
+ "slug": { "type": "string", "minLength": 1 },
8
+ "status": { "enum": ["dismissed", "needs_user", "submitted_external", "interview", "rejected", "withdrawn", "offer", "accepted", "declined"] },
9
+ "note": { "type": "string", "minLength": 1 }
10
+ },
11
+ "additionalProperties": true
12
+ }
@@ -0,0 +1,29 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "jobhunt-kit/vacancy",
4
+ "type": "object",
5
+ "required": ["run_id", "slug", "title", "url", "route", "match"],
6
+ "properties": {
7
+ "run_id": { "type": "string", "minLength": 1 }, "slug": { "type": "string", "pattern": "^[a-zA-Z0-9][a-zA-Z0-9_-]*$" },
8
+ "title": { "type": "string", "minLength": 1 }, "company": { "type": ["string", "null"] },
9
+ "url": { "type": "string", "format": "uri" }, "original_url": { "type": "string", "format": "uri" },
10
+ "apply_url": { "type": "string", "format": "uri" }, "route": { "enum": ["hosted", "external", "unknown"] },
11
+ "description": { "type": "string" }, "read_at": { "type": "string", "format": "date-time" },
12
+ "match": {
13
+ "type": "object", "required": ["verdict"],
14
+ "properties": {
15
+ "verdict": { "enum": ["unreviewed", "suitable", "review", "rejected"] },
16
+ "reasons": { "type": "array", "items": { "type": "string", "minLength": 1 } },
17
+ "requirements": {
18
+ "type": "array", "items": {
19
+ "type": "object", "required": ["requirement", "result", "evidence"],
20
+ "properties": { "requirement": { "type": "string", "minLength": 1 }, "result": { "enum": ["pass", "fail", "unknown"] }, "evidence": { "type": "string" } },
21
+ "additionalProperties": true
22
+ }
23
+ }
24
+ },
25
+ "additionalProperties": true
26
+ }
27
+ },
28
+ "additionalProperties": true
29
+ }
@@ -1,5 +1,5 @@
1
1
  import { parseArgs } from 'node:util';
2
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
2
+ import { readFileSync, existsSync, mkdirSync } from 'node:fs';
3
3
  import { resolve, join, dirname, basename } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { createHash } from 'node:crypto';
@@ -8,27 +8,36 @@ import { readJSON, writeJSON, validateProfile, saveProfile, confirmProfile } fro
8
8
  import { importResume, checkResume, recordResumeReview } from './resume.mjs';
9
9
  import { cliPath } from './hirify.mjs';
10
10
  import { sendPacket } from './send-packet.mjs';
11
+ import { backupData, privacyMap, purgeData, redactData, restoreData, verifyBackup } from './maintenance.mjs';
12
+ import { atomicWrite, atomicWriteJSON, localPath } from './files.mjs';
13
+ import { validateSchema } from './validation.mjs';
11
14
 
12
15
  const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
13
16
  const required = (value, name) => { if (!value) throw new Error(`${name} required`); return value; };
14
17
  function using(dir, fn) { const s = new Tracker(dir); try { return fn(s); } finally { s.close(); } }
18
+ function persistAttemptPacket(dir, store, attemptId) {
19
+ const packet = store.attemptPacket(attemptId);
20
+ const path = localPath(dir, `materials/attempt-${attemptId}.json`);
21
+ atomicWriteJSON(path, packet, { base: dir });
22
+ return { packet_path: path, attempt_id: attemptId, sent: false };
23
+ }
15
24
  function exportDraft(dir, job) {
16
25
  if (!job.draft) throw new Error('Prepare a draft first');
17
26
  const version = createHash('sha256').update(JSON.stringify(job.draft)).digest('hex').slice(0, 16);
18
27
  const folder = join(dir, 'materials', `application-${job.id}-${version}`);
19
28
  mkdirSync(folder, { recursive: true });
20
- writeFileSync(join(folder, 'cover-letter.txt'), job.draft.cover_letter);
29
+ atomicWrite(join(folder, 'cover-letter.txt'), job.draft.cover_letter, { base: dir });
21
30
  writeJSON(join(folder, 'answers.json'), job.draft.answers || []);
22
31
  writeJSON(join(folder, 'draft.json'), job.draft);
23
32
  const destination = job.payload.apply_url || job.payload.original_url || job.payload.url;
24
- writeFileSync(join(folder, 'handoff.md'), [
33
+ atomicWrite(join(folder, 'handoff.md'), [
25
34
  '# Материалы отклика', '', `Вакансия: ${job.payload.title}`, `Ссылка: ${destination}`, '',
26
35
  `Маршрут: ${job.payload.route}. Статус: ${job.status}.`,
27
36
  'Письмо: cover-letter.txt. Ответы: answers.json. Полный снимок: draft.json.', '',
28
37
  '## Требует действия', ...(job.draft.unresolved || []).map(x => `- ${x}`), '',
29
38
  job.payload.route === 'external' ? 'Отклик отправляет пользователь. Если ссылка на форму ещё не раскрыта, сначала получить её через Hirify reveal.' : 'Перед отправкой проверить профиль Hirify и текущую квоту; требуется действующее согласование.',
30
39
  '', 'Экспорт файлов не отправляет отклик.'
31
- ].join('\n'));
40
+ ].join('\n'), { base: dir });
32
41
  return { directory: folder, handoff: join(folder, 'handoff.md'), sent: false };
33
42
  }
34
43
  function searchContext(dir, workspace) {
@@ -37,6 +46,7 @@ function searchContext(dir, workspace) {
37
46
  const validation = validateProfile(p.profile);
38
47
  if (!validation.valid) throw new Error(validation.errors.join('; '));
39
48
  const policy = readJSON(join(dir, 'policy.json'));
49
+ validateSchema('policy', policy);
40
50
  for (const k of ['target', 'max_cards', 'max_reads', 'max_reveals', 'max_minutes']) {
41
51
  if (!Number.isInteger(policy.search?.[k]) || policy.search[k] < (k === 'max_reveals' ? 0 : 1)) throw new Error(`Invalid search budget: ${k}`);
42
52
  }
@@ -50,7 +60,7 @@ function searchContext(dir, workspace) {
50
60
  const task = join(dir, 'materials', 'search-task.md');
51
61
  const localPlugin = join(workspace, 'plugins', 'jobhunt-kit');
52
62
  const plugin = existsSync(join(localPlugin, 'skills', 'job-search', 'SKILL.md')) ? localPlugin : ROOT;
53
- writeFileSync(task, `Use job-search at ${join(plugin, 'skills', 'job-search', 'SKILL.md')}.\nRead ${path} and ${join(dir, 'profile.json')}.\nCheck live Hirify quotas and filter guide, preview filters, then search. Record runs and observations using the CLI.\nNo live search has been performed by this context command.\n`);
63
+ atomicWrite(task, `Use job-search at ${join(plugin, 'skills', 'job-search', 'SKILL.md')}.\nRead ${path} and ${join(dir, 'profile.json')}.\nCheck live Hirify quotas and filter guide, preview filters, then search. Record runs and observations using the CLI.\nNo live search has been performed by this context command.\n`, { base: dir });
54
64
  return { ...context, context_path: path, task_path: task };
55
65
  });
56
66
  }
@@ -69,7 +79,7 @@ function scheduleTemplate(dir, workspace, input) {
69
79
  '<абсолютный workspace>': workspace, '<абсолютный путь плагина>': plugin,
70
80
  '<абсолютный путь локальных данных>': dir, '<search / prepare / auto>': input.mode })) prompt = prompt.replaceAll(key, String(value));
71
81
  const path = join(dir, 'materials', 'scheduled-search.md');
72
- writeFileSync(path, prompt);
82
+ atomicWrite(path, prompt, { base: dir });
73
83
  return { path, scheduled: false, auto_permission_granted: false, next: 'Review prompt and create a task using your agent scheduler' };
74
84
  }
75
85
  export function runCommand(args, { cwd = process.cwd(), transport } = {}) {
@@ -85,12 +95,29 @@ export function runCommand(args, { cwd = process.cwd(), transport } = {}) {
85
95
  let cli; try { cli = { available: true, path: cliPath(dir), version: '0.4.5' }; }
86
96
  catch (e) { cli = { available: false, reason: e.message }; }
87
97
  const initialized = existsSync(join(dir, 'profile.json'));
98
+ let integrity = null;
99
+ let profile = null;
100
+ if (initialized) {
101
+ try { integrity = using(dir, store => store.integrity()); }
102
+ catch (error) { integrity = { database_ok: false, error: error.message }; }
103
+ try { profile = validateProfile(readJSON(join(dir, 'profile.json'))); }
104
+ catch (error) { profile = { valid: false, errors: [error.message], warnings: [] }; }
105
+ }
88
106
  return { node: process.versions.node, workspace, data: dir, initialized, cli,
89
- profile: initialized ? validateProfile(readJSON(join(dir, 'profile.json'))) : null, network_checked: false,
107
+ profile, integrity, network_checked: false,
90
108
  next: initialized ? 'profile check' : 'profile init' };
91
109
  }
92
110
  if (command === 'profile' && argument) throw new Error('Unexpected profile argument; use --input or --note');
93
111
  if (command === 'profile' && action === 'init') return initData(dir);
112
+ if (command === 'backup-verify') {
113
+ if (!action || argument) throw new Error('backup-verify requires one backup directory');
114
+ return verifyBackup(resolve(cwd, action));
115
+ }
116
+ if (command === 'restore') {
117
+ if (action || argument) throw new Error('restore uses --input with source and confirmation');
118
+ const request = input();
119
+ return restoreData(dir, resolve(cwd, required(request.source, 'source')), request.confirmation);
120
+ }
94
121
  if (!existsSync(join(dir, 'profile.json'))) throw new Error(`No profile at ${dir}. Run profile init first.`);
95
122
  if (command === 'profile') {
96
123
  if (argument) throw new Error('Unexpected profile argument; use --input or --note');
@@ -115,7 +142,6 @@ export function runCommand(args, { cwd = process.cwd(), transport } = {}) {
115
142
  const methods = { start: 'runStart', event: 'runEvent', record: 'put', finish: 'runFinish' };
116
143
  if (!methods[action]) throw new Error('search: plan | start | event | record | finish (JSON via --input)');
117
144
  return using(dir, s => {
118
- if (action === 'start' && s.runs().some(r => r.status === 'running')) throw new Error('Finish or resolve the previous running search before starting another');
119
145
  return s[methods[action]](input());
120
146
  });
121
147
  }
@@ -125,9 +151,8 @@ export function runCommand(args, { cwd = process.cwd(), transport } = {}) {
125
151
  if (action === 'export') return using(dir, s => exportDraft(dir, s.job(required(argument, 'slug'))));
126
152
  if (action === 'begin') return using(dir, s => {
127
153
  const packet = s.begin({ slug: required(argument, 'slug') });
128
- const path = join(dir, 'materials', `attempt-${packet.attempt_id}.json`);
129
- writeJSON(path, packet);
130
- return { packet_path: path, attempt_id: packet.attempt_id, sent: false };
154
+ try { return persistAttemptPacket(dir, s, packet.attempt_id); }
155
+ catch (error) { throw new Error(`Attempt reserved but packet write failed. Recover with: recover packet ${packet.attempt_id}. ${error.message}`); }
131
156
  });
132
157
  const methods = { prepare: 'prepare', approve: 'approve', finish: 'finish', resolve: 'resolveUnknown' };
133
158
  if (methods[action]) return using(dir, s => s[methods[action]](input()));
@@ -146,6 +171,27 @@ export function runCommand(args, { cwd = process.cwd(), transport } = {}) {
146
171
  if (action) throw new Error(`${command} takes no positional arguments`);
147
172
  return using(dir, s => s[command]());
148
173
  }
174
+ if (command === 'backup') {
175
+ if (!action || argument) throw new Error('backup requires one destination directory');
176
+ return backupData(dir, resolve(cwd, action));
177
+ }
178
+ if (command === 'recover') {
179
+ if (!action || action === 'inspect') return using(dir, store => store.integrity());
180
+ if (action === 'apply') return using(dir, store => store.recover(input()));
181
+ if (action === 'packet') return using(dir, store => persistAttemptPacket(dir, store, required(argument, 'attempt_id')));
182
+ throw new Error('recover: inspect | apply --input file | packet <attempt_id>');
183
+ }
184
+ if (command === 'privacy') {
185
+ if (!action || action === 'map') return privacyMap(dir);
186
+ if (action === 'export') return backupData(dir, resolve(cwd, required(argument, 'destination')));
187
+ if (['redact', 'purge'].includes(action) && argument) throw new Error(`privacy ${action} uses --input`);
188
+ if (action === 'redact') return redactData(dir, input());
189
+ if (action === 'purge') {
190
+ const request = input();
191
+ return purgeData(dir, { ...request, backup_directory: resolve(cwd, required(request.backup_directory, 'backup_directory')) });
192
+ }
193
+ throw new Error('privacy: map | export <directory> | redact/purge --input file');
194
+ }
149
195
  if (command === 'policy') {
150
196
  if (argument) throw new Error('Unexpected policy argument');
151
197
  if (!action || action === 'show') return readJSON(join(dir, 'policy.json'));
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  // Parser subprocess: local bytes only, bounded by parent timeout/memory/output limits.
3
- import { readFileSync, existsSync } from 'node:fs';
4
- import { extname, join, resolve } from 'node:path';
3
+ import { existsSync } from 'node:fs';
4
+ import { extname, join, relative, resolve } from 'node:path';
5
5
  import { createRequire } from 'node:module';
6
+ import { localPath, MAX_RESUME_BYTES, readBytes } from './files.mjs';
6
7
 
7
8
  try {
8
9
  const [dir, file] = process.argv.slice(2);
@@ -10,7 +11,8 @@ try {
10
11
  const bundled = createRequire(import.meta.url);
11
12
  const runtime = existsSync(local) ? createRequire(local) : bundled;
12
13
  const load = name => { try { return runtime(name); } catch (error) { if (error.code !== 'MODULE_NOT_FOUND') throw error; return bundled(name); } };
13
- const bytes = readFileSync(file);
14
+ const safeFile = localPath(dir, relative(resolve(dir), resolve(file)));
15
+ const bytes = readBytes(safeFile, { base: resolve(dir), maxBytes: MAX_RESUME_BYTES });
14
16
  let result;
15
17
  if (extname(file).toLowerCase() === '.pdf') {
16
18
  const { PDFParse } = load('pdf-parse');
@@ -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
+ }