job-forge 2.14.25 → 2.14.27
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/.cursor/rules/main.mdc +9 -3
- package/.opencode/skills/job-forge.md +16 -5
- package/AGENTS.md +9 -3
- package/CLAUDE.md +9 -3
- package/README.md +7 -5
- package/bin/create-job-forge.mjs +30 -0
- package/bin/job-forge.mjs +59 -0
- package/bin/sync.mjs +30 -0
- package/docs/ARCHITECTURE.md +8 -2
- package/docs/CUSTOMIZATION.md +9 -1
- package/docs/README.md +1 -1
- package/docs/SETUP.md +4 -0
- package/iso/commands/job-forge.md +16 -5
- package/iso/instructions.md +9 -3
- package/lib/jobforge-cache.mjs +9 -4
- package/lib/jobforge-canon.mjs +88 -0
- package/lib/jobforge-index.mjs +77 -1
- package/lib/jobforge-ledger.mjs +33 -15
- package/lib/jobforge-migrate.mjs +33 -0
- package/package.json +11 -1
- package/scripts/canon.mjs +178 -0
- package/scripts/ledger.mjs +27 -2
- package/scripts/migrate.mjs +89 -0
- package/templates/canon.json +65 -0
- package/templates/capabilities.json +4 -1
- package/templates/migrations.json +67 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { readFileSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import {
|
|
4
|
+
canonicalizeCompany,
|
|
5
|
+
canonicalizeCompanyRole,
|
|
6
|
+
canonicalizeEntity,
|
|
7
|
+
canonicalizeRole,
|
|
8
|
+
canonicalizeUrl,
|
|
9
|
+
compareCanon,
|
|
10
|
+
loadCanonConfig,
|
|
11
|
+
parseJson,
|
|
12
|
+
resolveProfile,
|
|
13
|
+
} from '@razroo/iso-canon';
|
|
14
|
+
|
|
15
|
+
export const CANON_CONFIG_FILE = 'templates/canon.json';
|
|
16
|
+
export const CANON_PROFILE = 'jobforge';
|
|
17
|
+
|
|
18
|
+
export function resolveProjectDir(projectDir = process.env.JOB_FORGE_PROJECT || process.cwd()) {
|
|
19
|
+
return projectDir;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function jobForgeCanonConfigPath(projectDir = resolveProjectDir()) {
|
|
23
|
+
return process.env.JOB_FORGE_CANON_CONFIG || join(projectDir, CANON_CONFIG_FILE);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function readJobForgeCanonConfig(projectDir = resolveProjectDir()) {
|
|
27
|
+
const path = jobForgeCanonConfigPath(projectDir);
|
|
28
|
+
return loadCanonConfig(parseJson(readFileSync(path, 'utf8'), path), path);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function jobForgeCanonProfile(projectDir = resolveProjectDir()) {
|
|
32
|
+
return resolveProfile(readJobForgeCanonConfig(projectDir), process.env.JOB_FORGE_CANON_PROFILE || CANON_PROFILE);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function canonicalizeJobForgeUrl(url, projectDir = resolveProjectDir()) {
|
|
36
|
+
return canonicalizeUrl(url, jobForgeCanonProfile(projectDir));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function canonicalizeJobForgeCompany(company, projectDir = resolveProjectDir()) {
|
|
40
|
+
return canonicalizeCompany(company, jobForgeCanonProfile(projectDir));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function canonicalizeJobForgeRole(role, projectDir = resolveProjectDir()) {
|
|
44
|
+
return canonicalizeRole(role, jobForgeCanonProfile(projectDir));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function canonicalizeJobForgeCompanyRole(company, role, projectDir = resolveProjectDir()) {
|
|
48
|
+
return canonicalizeCompanyRole(company, role, jobForgeCanonProfile(projectDir));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function canonicalizeJobForgeEntity(type, input, projectDir = resolveProjectDir()) {
|
|
52
|
+
return canonicalizeEntity(type, input, jobForgeCanonProfile(projectDir));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function compareJobForgeCanon(type, left, right, projectDir = resolveProjectDir()) {
|
|
56
|
+
return compareCanon(type, left, right, jobForgeCanonProfile(projectDir));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function jobForgeUrlKey(url, projectDir = resolveProjectDir()) {
|
|
60
|
+
return canonicalizeJobForgeUrl(url, projectDir).key;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function jobForgeCompanyRoleKey(company, role, projectDir = resolveProjectDir()) {
|
|
64
|
+
return canonicalizeJobForgeCompanyRole(company, role, projectDir).key;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function jobForgeApplicationSubject(company, role, projectDir = resolveProjectDir()) {
|
|
68
|
+
const companyKey = canonicalizeJobForgeCompany(company, projectDir).key.slice('company:'.length);
|
|
69
|
+
const roleKey = canonicalizeJobForgeRole(role, projectDir).key.slice('role:'.length);
|
|
70
|
+
return `application:${companyKey}:${roleKey}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function legacyCompanyRoleKey(company, role) {
|
|
74
|
+
return `company-role:${legacySlugPart(company)}:${legacySlugPart(role)}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function legacyUrlKey(url) {
|
|
78
|
+
return `url:${String(url || '').trim()}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function legacySlugPart(value) {
|
|
82
|
+
const slug = String(value || 'unknown')
|
|
83
|
+
.toLowerCase()
|
|
84
|
+
.replace(/&/g, ' and ')
|
|
85
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
86
|
+
.replace(/^-+|-+$/g, '');
|
|
87
|
+
return slug || 'unknown';
|
|
88
|
+
}
|
package/lib/jobforge-index.mjs
CHANGED
|
@@ -6,8 +6,15 @@ import {
|
|
|
6
6
|
loadIndexConfig,
|
|
7
7
|
parseJson,
|
|
8
8
|
queryIndex,
|
|
9
|
+
recordId,
|
|
9
10
|
verifyIndex,
|
|
10
11
|
} from '@razroo/iso-index';
|
|
12
|
+
import {
|
|
13
|
+
jobForgeCompanyRoleKey,
|
|
14
|
+
jobForgeUrlKey,
|
|
15
|
+
legacyCompanyRoleKey,
|
|
16
|
+
legacyUrlKey,
|
|
17
|
+
} from './jobforge-canon.mjs';
|
|
11
18
|
|
|
12
19
|
export const INDEX_FILE = '.jobforge-index.json';
|
|
13
20
|
export const INDEX_CONFIG_FILE = 'templates/index.json';
|
|
@@ -35,7 +42,7 @@ export function readJobForgeIndexConfig(projectDir = resolveProjectDir()) {
|
|
|
35
42
|
|
|
36
43
|
export function buildJobForgeIndex(options = {}, projectDir = resolveProjectDir()) {
|
|
37
44
|
const config = readJobForgeIndexConfig(projectDir);
|
|
38
|
-
const index = buildIndex(config, { root: projectDir });
|
|
45
|
+
const index = canonicalizeJobForgeIndex(buildIndex(config, { root: projectDir }), projectDir);
|
|
39
46
|
const out = options.out || jobForgeIndexPath(projectDir);
|
|
40
47
|
if (options.write !== false) {
|
|
41
48
|
writeFileSync(out, `${JSON.stringify(index, null, 2)}\n`, 'utf8');
|
|
@@ -90,3 +97,72 @@ export function jobForgeIndexSummary(projectDir = resolveProjectDir()) {
|
|
|
90
97
|
configHash: index.configHash,
|
|
91
98
|
};
|
|
92
99
|
}
|
|
100
|
+
|
|
101
|
+
function canonicalizeJobForgeIndex(index, projectDir) {
|
|
102
|
+
const records = (index.records || []).map((record) => canonicalizeJobForgeIndexRecord(record, projectDir));
|
|
103
|
+
records.sort(compareRecords);
|
|
104
|
+
return {
|
|
105
|
+
...index,
|
|
106
|
+
records,
|
|
107
|
+
stats: {
|
|
108
|
+
...(index.stats || {}),
|
|
109
|
+
records: records.length,
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function canonicalizeJobForgeIndexRecord(record, projectDir) {
|
|
115
|
+
const key = canonicalIndexKey(record, projectDir);
|
|
116
|
+
if (key === record.key) return record;
|
|
117
|
+
const updated = { ...record, key };
|
|
118
|
+
return { ...updated, id: recordId(updated) };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function canonicalIndexKey(record, projectDir) {
|
|
122
|
+
if (isCompanyRoleRecord(record)) {
|
|
123
|
+
const { company, role } = companyRoleFields(record);
|
|
124
|
+
if (company && role) return safeCompanyRoleKey(company, role, projectDir);
|
|
125
|
+
}
|
|
126
|
+
if (isUrlRecord(record)) {
|
|
127
|
+
const url = record.fields?.url;
|
|
128
|
+
if (url) return safeUrlKey(url, projectDir);
|
|
129
|
+
}
|
|
130
|
+
return record.key;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function isCompanyRoleRecord(record) {
|
|
134
|
+
return record.kind === 'jobforge.application' || record.kind === 'jobforge.tracker-addition';
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function companyRoleFields(record) {
|
|
138
|
+
const fields = record.fields || {};
|
|
139
|
+
return {
|
|
140
|
+
company: fields.company || fields.Company,
|
|
141
|
+
role: fields.role || fields.Role,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function isUrlRecord(record) {
|
|
146
|
+
return record.kind === 'jobforge.report.url' || record.kind === 'jobforge.pipeline.url' || record.kind === 'jobforge.scan.url';
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function safeCompanyRoleKey(company, role, projectDir) {
|
|
150
|
+
try {
|
|
151
|
+
return jobForgeCompanyRoleKey(company, role, projectDir);
|
|
152
|
+
} catch {
|
|
153
|
+
return legacyCompanyRoleKey(company, role);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function safeUrlKey(url, projectDir) {
|
|
158
|
+
try {
|
|
159
|
+
return jobForgeUrlKey(url, projectDir);
|
|
160
|
+
} catch {
|
|
161
|
+
return legacyUrlKey(url);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function compareRecords(a, b) {
|
|
166
|
+
return `${a.kind}\0${a.key}\0${a.source?.path || ''}\0${a.source?.line || ''}\0${a.id}`
|
|
167
|
+
.localeCompare(`${b.kind}\0${b.key}\0${b.source?.path || ''}\0${b.source?.line || ''}\0${b.id}`);
|
|
168
|
+
}
|
package/lib/jobforge-ledger.mjs
CHANGED
|
@@ -8,6 +8,16 @@ import {
|
|
|
8
8
|
readLedger,
|
|
9
9
|
verifyLedger,
|
|
10
10
|
} from '@razroo/iso-ledger';
|
|
11
|
+
import {
|
|
12
|
+
jobForgeApplicationSubject,
|
|
13
|
+
jobForgeCompanyRoleKey,
|
|
14
|
+
jobForgeUrlKey,
|
|
15
|
+
legacyCompanyRoleKey,
|
|
16
|
+
legacySlugPart,
|
|
17
|
+
legacyUrlKey,
|
|
18
|
+
} from './jobforge-canon.mjs';
|
|
19
|
+
|
|
20
|
+
export { legacyCompanyRoleKey, legacyUrlKey };
|
|
11
21
|
|
|
12
22
|
export const LEDGER_DIR = '.jobforge-ledger';
|
|
13
23
|
export const LEDGER_FILE = 'events.jsonl';
|
|
@@ -89,7 +99,7 @@ export function recordTrackerMergeResult(addition, options = {}) {
|
|
|
89
99
|
|
|
90
100
|
export function buildApplicationEvent(type, app, options = {}) {
|
|
91
101
|
const projectDir = resolveProjectDir(options.projectDir);
|
|
92
|
-
const key = companyRoleKey(app.company, app.role);
|
|
102
|
+
const key = companyRoleKey(app.company, app.role, projectDir);
|
|
93
103
|
const sourceFile = options.sourceFile ? relativePath(projectDir, options.sourceFile) : '';
|
|
94
104
|
const idempotencyParts = [
|
|
95
105
|
options.idempotencyPrefix || type,
|
|
@@ -104,7 +114,7 @@ export function buildApplicationEvent(type, app, options = {}) {
|
|
|
104
114
|
return {
|
|
105
115
|
type,
|
|
106
116
|
key,
|
|
107
|
-
subject: applicationSubject(app.company, app.role),
|
|
117
|
+
subject: applicationSubject(app.company, app.role, projectDir),
|
|
108
118
|
idempotencyKey: idempotencyParts.join(':'),
|
|
109
119
|
data: compactObject({
|
|
110
120
|
num: numberOrString(app.num),
|
|
@@ -128,7 +138,7 @@ export function buildApplicationEvent(type, app, options = {}) {
|
|
|
128
138
|
|
|
129
139
|
export function buildPipelineEvent(item, options = {}) {
|
|
130
140
|
const projectDir = resolveProjectDir(options.projectDir);
|
|
131
|
-
const key = item.url ? urlKey(item.url) : `pipeline:${item.lineNumber || 'unknown'}`;
|
|
141
|
+
const key = item.url ? urlKey(item.url, projectDir) : `pipeline:${item.lineNumber || 'unknown'}`;
|
|
132
142
|
const sourceFile = options.sourceFile ? relativePath(projectDir, options.sourceFile) : 'data/pipeline.md';
|
|
133
143
|
const state = item.checked ? 'processed' : 'pending';
|
|
134
144
|
|
|
@@ -154,25 +164,33 @@ export function buildPipelineEvent(item, options = {}) {
|
|
|
154
164
|
};
|
|
155
165
|
}
|
|
156
166
|
|
|
157
|
-
export function companyRoleKey(company, role) {
|
|
158
|
-
|
|
167
|
+
export function companyRoleKey(company, role, projectDir = resolveProjectDir()) {
|
|
168
|
+
try {
|
|
169
|
+
return jobForgeCompanyRoleKey(company, role, projectDir);
|
|
170
|
+
} catch {
|
|
171
|
+
return legacyCompanyRoleKey(company, role);
|
|
172
|
+
}
|
|
159
173
|
}
|
|
160
174
|
|
|
161
|
-
export function applicationSubject(company, role) {
|
|
162
|
-
|
|
175
|
+
export function applicationSubject(company, role, projectDir = resolveProjectDir()) {
|
|
176
|
+
try {
|
|
177
|
+
return jobForgeApplicationSubject(company, role, projectDir);
|
|
178
|
+
} catch {
|
|
179
|
+
const key = legacyCompanyRoleKey(company, role).slice('company-role:'.length);
|
|
180
|
+
return `application:${key}`;
|
|
181
|
+
}
|
|
163
182
|
}
|
|
164
183
|
|
|
165
|
-
export function urlKey(url) {
|
|
166
|
-
|
|
184
|
+
export function urlKey(url, projectDir = resolveProjectDir()) {
|
|
185
|
+
try {
|
|
186
|
+
return jobForgeUrlKey(url, projectDir);
|
|
187
|
+
} catch {
|
|
188
|
+
return legacyUrlKey(url);
|
|
189
|
+
}
|
|
167
190
|
}
|
|
168
191
|
|
|
169
192
|
export function slugPart(value) {
|
|
170
|
-
|
|
171
|
-
.toLowerCase()
|
|
172
|
-
.replace(/&/g, ' and ')
|
|
173
|
-
.replace(/[^a-z0-9]+/g, '-')
|
|
174
|
-
.replace(/^-+|-+$/g, '');
|
|
175
|
-
return slug || 'unknown';
|
|
193
|
+
return legacySlugPart(value);
|
|
176
194
|
}
|
|
177
195
|
|
|
178
196
|
function relativePath(projectDir, value) {
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import {
|
|
4
|
+
loadMigrationConfig,
|
|
5
|
+
parseJson,
|
|
6
|
+
runMigrations,
|
|
7
|
+
} from '@razroo/iso-migrate';
|
|
8
|
+
|
|
9
|
+
export const MIGRATION_CONFIG_FILE = 'templates/migrations.json';
|
|
10
|
+
|
|
11
|
+
export function resolveProjectDir(projectDir = process.env.JOB_FORGE_PROJECT || process.cwd()) {
|
|
12
|
+
return projectDir;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function jobForgeMigrationConfigPath(projectDir = resolveProjectDir()) {
|
|
16
|
+
return process.env.JOB_FORGE_MIGRATIONS_CONFIG || join(projectDir, MIGRATION_CONFIG_FILE);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function migrationConfigExists(projectDir = resolveProjectDir()) {
|
|
20
|
+
return existsSync(jobForgeMigrationConfigPath(projectDir));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function readJobForgeMigrationConfig(projectDir = resolveProjectDir()) {
|
|
24
|
+
const path = jobForgeMigrationConfigPath(projectDir);
|
|
25
|
+
return loadMigrationConfig(parseJson(readFileSync(path, 'utf8'), path));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function runJobForgeMigrations(options = {}, projectDir = resolveProjectDir()) {
|
|
29
|
+
return runMigrations(readJobForgeMigrationConfig(projectDir), {
|
|
30
|
+
root: options.root || projectDir,
|
|
31
|
+
dryRun: options.dryRun,
|
|
32
|
+
});
|
|
33
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "job-forge",
|
|
3
|
-
"version": "2.14.
|
|
3
|
+
"version": "2.14.27",
|
|
4
4
|
"description": "AI-powered job search pipeline built on opencode",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -55,6 +55,14 @@
|
|
|
55
55
|
"index:has": "node bin/job-forge.mjs index:has",
|
|
56
56
|
"index:verify": "node bin/job-forge.mjs index:verify",
|
|
57
57
|
"index:explain": "node bin/job-forge.mjs index:explain",
|
|
58
|
+
"canon:normalize": "node bin/job-forge.mjs canon:normalize",
|
|
59
|
+
"canon:key": "node bin/job-forge.mjs canon:key",
|
|
60
|
+
"canon:compare": "node bin/job-forge.mjs canon:compare",
|
|
61
|
+
"canon:explain": "node bin/job-forge.mjs canon:explain",
|
|
62
|
+
"migrate:plan": "node bin/job-forge.mjs migrate:plan",
|
|
63
|
+
"migrate:apply": "node bin/job-forge.mjs migrate:apply",
|
|
64
|
+
"migrate:check": "node bin/job-forge.mjs migrate:check",
|
|
65
|
+
"migrate:explain": "node bin/job-forge.mjs migrate:explain",
|
|
58
66
|
"plan": "iso plan .",
|
|
59
67
|
"lint:agentmd": "agentmd lint iso/instructions.md",
|
|
60
68
|
"lint:modes": "isolint lint modes/",
|
|
@@ -123,11 +131,13 @@
|
|
|
123
131
|
"dependencies": {
|
|
124
132
|
"@razroo/iso-capabilities": "^0.1.0",
|
|
125
133
|
"@razroo/iso-cache": "^0.1.0",
|
|
134
|
+
"@razroo/iso-canon": "^0.1.0",
|
|
126
135
|
"@razroo/iso-context": "^0.1.0",
|
|
127
136
|
"@razroo/iso-contract": "^0.1.0",
|
|
128
137
|
"@razroo/iso-guard": "^0.1.0",
|
|
129
138
|
"@razroo/iso-index": "^0.1.0",
|
|
130
139
|
"@razroo/iso-ledger": "^0.1.0",
|
|
140
|
+
"@razroo/iso-migrate": "^0.1.0",
|
|
131
141
|
"@razroo/iso-orchestrator": "^0.1.0",
|
|
132
142
|
"@razroo/iso-trace": "^0.4.0",
|
|
133
143
|
"playwright": "^1.58.1"
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { relative } from 'path';
|
|
4
|
+
import {
|
|
5
|
+
formatCanonResult,
|
|
6
|
+
formatCompareResult,
|
|
7
|
+
formatConfigSummary,
|
|
8
|
+
} from '@razroo/iso-canon';
|
|
9
|
+
import { PROJECT_DIR } from '../tracker-lib.mjs';
|
|
10
|
+
import {
|
|
11
|
+
canonicalizeJobForgeEntity,
|
|
12
|
+
compareJobForgeCanon,
|
|
13
|
+
jobForgeCanonConfigPath,
|
|
14
|
+
jobForgeCanonProfile,
|
|
15
|
+
} from '../lib/jobforge-canon.mjs';
|
|
16
|
+
|
|
17
|
+
const USAGE = `job-forge canon - deterministic identity keys for JobForge
|
|
18
|
+
|
|
19
|
+
Usage:
|
|
20
|
+
job-forge canon:normalize <url|company|role> <value> [--json]
|
|
21
|
+
job-forge canon:normalize company-role --company <name> --role <title> [--json]
|
|
22
|
+
job-forge canon:key <url|company|role> <value> [--json]
|
|
23
|
+
job-forge canon:key company-role --company <name> --role <title> [--json]
|
|
24
|
+
job-forge canon:compare <url|company|role> <left> <right> [--json]
|
|
25
|
+
job-forge canon:compare company-role --left-company <name> --left-role <title> --right-company <name> --right-role <title> [--json]
|
|
26
|
+
job-forge canon:explain [--json]
|
|
27
|
+
job-forge canon:path
|
|
28
|
+
|
|
29
|
+
The policy is templates/canon.json. These commands are local, model-free, and
|
|
30
|
+
use the same keys that JobForge ledger/cache helpers use internally.`;
|
|
31
|
+
|
|
32
|
+
const [cmd = 'help', ...rawArgs] = process.argv.slice(2);
|
|
33
|
+
const opts = parseArgs(rawArgs);
|
|
34
|
+
|
|
35
|
+
if (opts.help || cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
36
|
+
console.log(USAGE);
|
|
37
|
+
process.exit(0);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
if (cmd === 'path') {
|
|
42
|
+
console.log(jobForgeCanonConfigPath(PROJECT_DIR));
|
|
43
|
+
} else if (cmd === 'normalize') {
|
|
44
|
+
normalize(opts, false);
|
|
45
|
+
} else if (cmd === 'key') {
|
|
46
|
+
normalize(opts, true);
|
|
47
|
+
} else if (cmd === 'compare') {
|
|
48
|
+
compare(opts);
|
|
49
|
+
} else if (cmd === 'explain') {
|
|
50
|
+
explain(opts);
|
|
51
|
+
} else {
|
|
52
|
+
console.error(`unknown canon command "${cmd}"\n`);
|
|
53
|
+
console.error(USAGE);
|
|
54
|
+
process.exit(2);
|
|
55
|
+
}
|
|
56
|
+
} catch (error) {
|
|
57
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function parseArgs(args) {
|
|
62
|
+
const opts = {
|
|
63
|
+
json: false,
|
|
64
|
+
help: false,
|
|
65
|
+
values: [],
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
for (let i = 0; i < args.length; i++) {
|
|
69
|
+
const arg = args[i];
|
|
70
|
+
if (arg === '--json') {
|
|
71
|
+
opts.json = true;
|
|
72
|
+
} else if (arg === '--company') {
|
|
73
|
+
opts.company = valueAfter(args, ++i, '--company');
|
|
74
|
+
} else if (arg.startsWith('--company=')) {
|
|
75
|
+
opts.company = arg.slice('--company='.length);
|
|
76
|
+
} else if (arg === '--role') {
|
|
77
|
+
opts.role = valueAfter(args, ++i, '--role');
|
|
78
|
+
} else if (arg.startsWith('--role=')) {
|
|
79
|
+
opts.role = arg.slice('--role='.length);
|
|
80
|
+
} else if (arg === '--left-company') {
|
|
81
|
+
opts.leftCompany = valueAfter(args, ++i, '--left-company');
|
|
82
|
+
} else if (arg.startsWith('--left-company=')) {
|
|
83
|
+
opts.leftCompany = arg.slice('--left-company='.length);
|
|
84
|
+
} else if (arg === '--left-role') {
|
|
85
|
+
opts.leftRole = valueAfter(args, ++i, '--left-role');
|
|
86
|
+
} else if (arg.startsWith('--left-role=')) {
|
|
87
|
+
opts.leftRole = arg.slice('--left-role='.length);
|
|
88
|
+
} else if (arg === '--right-company') {
|
|
89
|
+
opts.rightCompany = valueAfter(args, ++i, '--right-company');
|
|
90
|
+
} else if (arg.startsWith('--right-company=')) {
|
|
91
|
+
opts.rightCompany = arg.slice('--right-company='.length);
|
|
92
|
+
} else if (arg === '--right-role') {
|
|
93
|
+
opts.rightRole = valueAfter(args, ++i, '--right-role');
|
|
94
|
+
} else if (arg.startsWith('--right-role=')) {
|
|
95
|
+
opts.rightRole = arg.slice('--right-role='.length);
|
|
96
|
+
} else if (arg === '--help' || arg === '-h') {
|
|
97
|
+
opts.help = true;
|
|
98
|
+
} else if (arg.startsWith('--')) {
|
|
99
|
+
throw new Error(`unknown flag "${arg}"`);
|
|
100
|
+
} else {
|
|
101
|
+
opts.values.push(arg);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return opts;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function normalize(opts, keyOnly) {
|
|
109
|
+
const type = parseType(opts.values.shift(), keyOnly ? 'key' : 'normalize');
|
|
110
|
+
const input = normalizeInput(type, opts);
|
|
111
|
+
const result = canonicalizeJobForgeEntity(type, input, PROJECT_DIR);
|
|
112
|
+
if (opts.json) {
|
|
113
|
+
console.log(JSON.stringify(result, null, 2));
|
|
114
|
+
} else if (keyOnly) {
|
|
115
|
+
console.log(result.key);
|
|
116
|
+
} else {
|
|
117
|
+
console.log(formatCanonResult(result));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function compare(opts) {
|
|
122
|
+
const type = parseType(opts.values.shift(), 'compare');
|
|
123
|
+
const [left, right] = compareInputs(type, opts);
|
|
124
|
+
const result = compareJobForgeCanon(type, left, right, PROJECT_DIR);
|
|
125
|
+
if (opts.json) {
|
|
126
|
+
console.log(JSON.stringify(result, null, 2));
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
console.log(formatCompareResult(result));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function explain(opts) {
|
|
133
|
+
const profile = jobForgeCanonProfile(PROJECT_DIR);
|
|
134
|
+
if (opts.json) {
|
|
135
|
+
console.log(JSON.stringify(profile, null, 2));
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
console.log(`config: ${relativePath(jobForgeCanonConfigPath(PROJECT_DIR))}`);
|
|
139
|
+
console.log(formatConfigSummary({ version: 1, profiles: [profile] }));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function normalizeInput(type, opts) {
|
|
143
|
+
if (type === 'company-role') {
|
|
144
|
+
if (!opts.company || !opts.role) throw new Error('company-role requires --company and --role');
|
|
145
|
+
return { company: opts.company, role: opts.role };
|
|
146
|
+
}
|
|
147
|
+
if (opts.values.length !== 1) throw new Error(`${type}: provide exactly one value; quote values containing spaces`);
|
|
148
|
+
return opts.values[0];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function compareInputs(type, opts) {
|
|
152
|
+
if (type === 'company-role') {
|
|
153
|
+
if (!opts.leftCompany || !opts.leftRole || !opts.rightCompany || !opts.rightRole) {
|
|
154
|
+
throw new Error('company-role compare requires --left-company, --left-role, --right-company, and --right-role');
|
|
155
|
+
}
|
|
156
|
+
return [
|
|
157
|
+
{ company: opts.leftCompany, role: opts.leftRole },
|
|
158
|
+
{ company: opts.rightCompany, role: opts.rightRole },
|
|
159
|
+
];
|
|
160
|
+
}
|
|
161
|
+
if (opts.values.length !== 2) throw new Error(`${type}: provide exactly two values; quote values containing spaces`);
|
|
162
|
+
return [opts.values[0], opts.values[1]];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function parseType(value, command) {
|
|
166
|
+
if (value === 'url' || value === 'company' || value === 'role' || value === 'company-role') return value;
|
|
167
|
+
throw new Error(`${command}: expected type url, company, role, or company-role`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function valueAfter(values, index, flag) {
|
|
171
|
+
const value = values[index];
|
|
172
|
+
if (!value || value.startsWith('--')) throw new Error(`${flag} requires a value`);
|
|
173
|
+
return value;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function relativePath(path) {
|
|
177
|
+
return relative(PROJECT_DIR, path) || '.';
|
|
178
|
+
}
|
package/scripts/ledger.mjs
CHANGED
|
@@ -16,6 +16,8 @@ import {
|
|
|
16
16
|
jobForgeLedgerPath,
|
|
17
17
|
jobForgeLedgerSummary,
|
|
18
18
|
ledgerExists,
|
|
19
|
+
legacyCompanyRoleKey,
|
|
20
|
+
legacyUrlKey,
|
|
19
21
|
readJobForgeLedger,
|
|
20
22
|
urlKey,
|
|
21
23
|
verifyJobForgeLedger,
|
|
@@ -201,8 +203,9 @@ function verify(opts) {
|
|
|
201
203
|
}
|
|
202
204
|
|
|
203
205
|
function has(opts) {
|
|
204
|
-
const filters =
|
|
205
|
-
const
|
|
206
|
+
const filters = queryFilterCandidates(opts);
|
|
207
|
+
const ledger = readJobForgeLedger(PROJECT_DIR);
|
|
208
|
+
const events = uniqueEvents(filters.flatMap((filter) => queryEvents(ledger, filter)));
|
|
206
209
|
if (opts.json) {
|
|
207
210
|
console.log(JSON.stringify({ match: events.length > 0, count: events.length, filters }, null, 2));
|
|
208
211
|
} else if (events.length > 0) {
|
|
@@ -237,6 +240,28 @@ function queryFilters(opts) {
|
|
|
237
240
|
return filters;
|
|
238
241
|
}
|
|
239
242
|
|
|
243
|
+
function queryFilterCandidates(opts) {
|
|
244
|
+
const primary = queryFilters(opts);
|
|
245
|
+
const candidates = [primary];
|
|
246
|
+
const legacy = { ...primary };
|
|
247
|
+
if (opts.url) legacy.key = legacyUrlKey(opts.url);
|
|
248
|
+
if (opts.company || opts.role) legacy.key = legacyCompanyRoleKey(opts.company, opts.role);
|
|
249
|
+
if (legacy.key && legacy.key !== primary.key) candidates.push(legacy);
|
|
250
|
+
return candidates;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function uniqueEvents(events) {
|
|
254
|
+
const seen = new Set();
|
|
255
|
+
const out = [];
|
|
256
|
+
for (const event of events) {
|
|
257
|
+
const id = event.id || event.idempotencyKey || JSON.stringify(event);
|
|
258
|
+
if (seen.has(id)) continue;
|
|
259
|
+
seen.add(id);
|
|
260
|
+
out.push(event);
|
|
261
|
+
}
|
|
262
|
+
return out;
|
|
263
|
+
}
|
|
264
|
+
|
|
240
265
|
function collectProjectEvents() {
|
|
241
266
|
const events = [];
|
|
242
267
|
const { entries } = readAllEntries();
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { relative } from 'path';
|
|
4
|
+
import {
|
|
5
|
+
formatConfigSummary,
|
|
6
|
+
formatMigrationResult,
|
|
7
|
+
} from '@razroo/iso-migrate';
|
|
8
|
+
import { PROJECT_DIR } from '../tracker-lib.mjs';
|
|
9
|
+
import {
|
|
10
|
+
jobForgeMigrationConfigPath,
|
|
11
|
+
readJobForgeMigrationConfig,
|
|
12
|
+
runJobForgeMigrations,
|
|
13
|
+
} from '../lib/jobforge-migrate.mjs';
|
|
14
|
+
|
|
15
|
+
const USAGE = `job-forge migrate - deterministic consumer-project migrations
|
|
16
|
+
|
|
17
|
+
Usage:
|
|
18
|
+
job-forge migrate:plan [--json]
|
|
19
|
+
job-forge migrate:apply [--json]
|
|
20
|
+
job-forge migrate:check [--json]
|
|
21
|
+
job-forge migrate:explain [--json]
|
|
22
|
+
job-forge migrate:path
|
|
23
|
+
|
|
24
|
+
The policy is templates/migrations.json. Sync applies these migrations
|
|
25
|
+
automatically unless JOB_FORGE_SKIP_MIGRATIONS=1 is set.`;
|
|
26
|
+
|
|
27
|
+
const [cmd = 'help', ...rawArgs] = process.argv.slice(2);
|
|
28
|
+
const opts = parseArgs(rawArgs);
|
|
29
|
+
|
|
30
|
+
if (opts.help || cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
31
|
+
console.log(USAGE);
|
|
32
|
+
process.exit(0);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
if (cmd === 'path') {
|
|
37
|
+
console.log(jobForgeMigrationConfigPath(PROJECT_DIR));
|
|
38
|
+
} else if (cmd === 'plan') {
|
|
39
|
+
run('plan', opts);
|
|
40
|
+
} else if (cmd === 'apply') {
|
|
41
|
+
run('apply', opts);
|
|
42
|
+
} else if (cmd === 'check') {
|
|
43
|
+
const result = run('check', opts);
|
|
44
|
+
process.exit(result.changed ? 1 : 0);
|
|
45
|
+
} else if (cmd === 'explain') {
|
|
46
|
+
explain(opts);
|
|
47
|
+
} else {
|
|
48
|
+
console.error(`unknown migrate command "${cmd}"\n`);
|
|
49
|
+
console.error(USAGE);
|
|
50
|
+
process.exit(2);
|
|
51
|
+
}
|
|
52
|
+
} catch (error) {
|
|
53
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function parseArgs(args) {
|
|
58
|
+
const opts = { json: false, help: false };
|
|
59
|
+
for (const arg of args) {
|
|
60
|
+
if (arg === '--json') opts.json = true;
|
|
61
|
+
else if (arg === '--help' || arg === '-h') opts.help = true;
|
|
62
|
+
else throw new Error(`unknown flag "${arg}"`);
|
|
63
|
+
}
|
|
64
|
+
return opts;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function run(mode, opts) {
|
|
68
|
+
const result = runJobForgeMigrations({ dryRun: mode !== 'apply' }, PROJECT_DIR);
|
|
69
|
+
if (opts.json) {
|
|
70
|
+
console.log(JSON.stringify(result, null, 2));
|
|
71
|
+
} else {
|
|
72
|
+
console.log(formatMigrationResult(result, mode));
|
|
73
|
+
}
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function explain(opts) {
|
|
78
|
+
const config = readJobForgeMigrationConfig(PROJECT_DIR);
|
|
79
|
+
if (opts.json) {
|
|
80
|
+
console.log(JSON.stringify(config, null, 2));
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
console.log(`config: ${relativePath(jobForgeMigrationConfigPath(PROJECT_DIR))}`);
|
|
84
|
+
console.log(formatConfigSummary(config));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function relativePath(path) {
|
|
88
|
+
return relative(PROJECT_DIR, path) || '.';
|
|
89
|
+
}
|