workspec 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -0
- package/bin/workspec.js +289 -0
- package/index.js +10 -0
- package/package.json +30 -0
- package/v2.0.schema.json +775 -0
- package/workspec-migrate-v1-to-v2.js +714 -0
- package/workspec-validator.js +1812 -0
package/README.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# WorkSpec (CLI + Validator)
|
|
2
|
+
|
|
3
|
+
This package provides:
|
|
4
|
+
|
|
5
|
+
- A programmatic WorkSpec v2.0 validator (`validate()`) that emits RFC 7807 Problem Details
|
|
6
|
+
- A `workspec` CLI with `validate`, `migrate`, and `format` commands
|
|
7
|
+
|
|
8
|
+
## CLI
|
|
9
|
+
|
|
10
|
+
Validate:
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
workspec validate path/to/file.workspec.json
|
|
14
|
+
workspec validate path/to/file.workspec.json --json
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Migrate v1 → v2:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
workspec migrate legacy.json --out migrated.workspec.json --schema
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Format JSON:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
workspec format file.json --write
|
|
27
|
+
```
|
|
28
|
+
|
package/bin/workspec.js
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
|
|
7
|
+
const validator = require(path.join(__dirname, '..', 'workspec-validator.js'));
|
|
8
|
+
const migrator = require(path.join(__dirname, '..', 'workspec-migrate-v1-to-v2.js'));
|
|
9
|
+
|
|
10
|
+
function printHelp(exitCode = 0) {
|
|
11
|
+
const lines = [
|
|
12
|
+
'workspec - WorkSpec v2.0 CLI',
|
|
13
|
+
'',
|
|
14
|
+
'Usage:',
|
|
15
|
+
' workspec validate <file.workspec.json> [--json]',
|
|
16
|
+
' workspec migrate <file.json> --out <output.json> [--schema]',
|
|
17
|
+
' workspec format <file.json> [--write] [--out <output.json>]',
|
|
18
|
+
'',
|
|
19
|
+
'Commands:',
|
|
20
|
+
' validate Validate a WorkSpec document (RFC 7807 output model).',
|
|
21
|
+
' migrate Migrate WorkSpec v1.0 -> v2.0.',
|
|
22
|
+
' format Pretty-print JSON (2-space).',
|
|
23
|
+
'',
|
|
24
|
+
'Flags:',
|
|
25
|
+
' --json Print machine-readable problems JSON (validate only).',
|
|
26
|
+
' --out <path> Output path (migrate/format).',
|
|
27
|
+
' --write Write output (format only; defaults to stdout).',
|
|
28
|
+
' --schema Add top-level $schema on migrate (default: off).',
|
|
29
|
+
' -h, --help Show help.',
|
|
30
|
+
''
|
|
31
|
+
];
|
|
32
|
+
process.stdout.write(lines.join('\n') + '\n');
|
|
33
|
+
process.exitCode = exitCode;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function readStdin() {
|
|
37
|
+
return new Promise((resolve, reject) => {
|
|
38
|
+
let data = '';
|
|
39
|
+
process.stdin.setEncoding('utf8');
|
|
40
|
+
process.stdin.on('data', (chunk) => { data += chunk; });
|
|
41
|
+
process.stdin.on('end', () => resolve(data));
|
|
42
|
+
process.stdin.on('error', reject);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function parseArgs(argv) {
|
|
47
|
+
const result = {
|
|
48
|
+
command: null,
|
|
49
|
+
positionals: [],
|
|
50
|
+
flags: {}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const args = [...argv];
|
|
54
|
+
result.command = args.shift() || null;
|
|
55
|
+
|
|
56
|
+
while (args.length > 0) {
|
|
57
|
+
const arg = args.shift();
|
|
58
|
+
if (arg === '--') {
|
|
59
|
+
result.positionals.push(...args);
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
if (arg === '--help' || arg === '-h') {
|
|
63
|
+
result.flags.help = true;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (arg === '--json') {
|
|
67
|
+
result.flags.json = true;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (arg === '--write') {
|
|
71
|
+
result.flags.write = true;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (arg === '--schema') {
|
|
75
|
+
result.flags.schema = true;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (arg === '--out') {
|
|
79
|
+
result.flags.out = args.shift() || '';
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (arg.startsWith('--')) {
|
|
83
|
+
result.flags.unknown = (result.flags.unknown || []).concat([arg]);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
result.positionals.push(arg);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return result;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function resolvePath(inputPath) {
|
|
93
|
+
if (!inputPath) return '';
|
|
94
|
+
if (path.isAbsolute(inputPath)) return inputPath;
|
|
95
|
+
return path.resolve(process.cwd(), inputPath);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function toPrettyJson(value) {
|
|
99
|
+
return JSON.stringify(value, null, 2) + '\n';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function hasErrors(problems) {
|
|
103
|
+
return problems.some((p) => p && p.severity === 'error');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function readInput(filePath) {
|
|
107
|
+
if (!filePath || filePath === '-') {
|
|
108
|
+
return readStdin();
|
|
109
|
+
}
|
|
110
|
+
return fs.readFileSync(resolvePath(filePath), 'utf8');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function handleValidate(filePath, flags) {
|
|
114
|
+
if (!filePath) {
|
|
115
|
+
process.stderr.write('Missing file path.\n');
|
|
116
|
+
printHelp(2);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let raw;
|
|
121
|
+
try {
|
|
122
|
+
raw = await readInput(filePath);
|
|
123
|
+
} catch (error) {
|
|
124
|
+
process.stderr.write(`Failed to read input: ${error.message}\n`);
|
|
125
|
+
process.exitCode = 2;
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
let parsed;
|
|
130
|
+
try {
|
|
131
|
+
parsed = JSON.parse(raw);
|
|
132
|
+
} catch (error) {
|
|
133
|
+
process.stderr.write(`Invalid JSON: ${error.message}\n`);
|
|
134
|
+
process.exitCode = 2;
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const result = validator.validate(parsed);
|
|
139
|
+
const problems = Array.isArray(result?.problems) ? result.problems : [];
|
|
140
|
+
|
|
141
|
+
if (flags.json) {
|
|
142
|
+
process.stdout.write(toPrettyJson(problems));
|
|
143
|
+
} else {
|
|
144
|
+
for (const problem of problems) {
|
|
145
|
+
const severity = problem.severity || 'error';
|
|
146
|
+
const metricId = problem.metric_id || 'system.error';
|
|
147
|
+
const instance = problem.instance || '/';
|
|
148
|
+
const detail = problem.detail || problem.title || metricId;
|
|
149
|
+
process.stdout.write(`${filePath}:${instance} - ${severity}: ${detail} (${metricId})\n`);
|
|
150
|
+
if (Array.isArray(problem.suggestions) && problem.suggestions.length > 0) {
|
|
151
|
+
for (const suggestion of problem.suggestions) {
|
|
152
|
+
process.stdout.write(` - ${suggestion}\n`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const counts = problems.reduce((acc, p) => {
|
|
157
|
+
const s = p && p.severity ? p.severity : 'error';
|
|
158
|
+
acc[s] = (acc[s] || 0) + 1;
|
|
159
|
+
return acc;
|
|
160
|
+
}, {});
|
|
161
|
+
|
|
162
|
+
const total = problems.length;
|
|
163
|
+
if (total === 0) {
|
|
164
|
+
process.stdout.write('✓ No problems found\n');
|
|
165
|
+
} else {
|
|
166
|
+
const e = counts.error || 0;
|
|
167
|
+
const w = counts.warning || 0;
|
|
168
|
+
const i = counts.info || 0;
|
|
169
|
+
process.stdout.write(`\n✖ ${total} problems (${e} errors, ${w} warnings, ${i} info)\n`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
process.exitCode = hasErrors(problems) ? 1 : 0;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function handleMigrate(filePath, flags) {
|
|
177
|
+
if (!filePath) {
|
|
178
|
+
process.stderr.write('Missing file path.\n');
|
|
179
|
+
printHelp(2);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (!flags.out) {
|
|
184
|
+
process.stderr.write('Missing --out <path>.\n');
|
|
185
|
+
printHelp(2);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const inputPath = resolvePath(filePath);
|
|
190
|
+
const outPath = resolvePath(flags.out);
|
|
191
|
+
|
|
192
|
+
let parsed;
|
|
193
|
+
try {
|
|
194
|
+
parsed = JSON.parse(fs.readFileSync(inputPath, 'utf8'));
|
|
195
|
+
} catch (error) {
|
|
196
|
+
process.stderr.write(`Failed to read/parse JSON: ${error.message}\n`);
|
|
197
|
+
process.exitCode = 2;
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
let migrated;
|
|
202
|
+
try {
|
|
203
|
+
migrated = migrator.migrate(parsed, {
|
|
204
|
+
addSchema: Boolean(flags.schema),
|
|
205
|
+
defaultCurrency: 'USD',
|
|
206
|
+
defaultLocale: 'en-US',
|
|
207
|
+
defaultTimezone: 'UTC'
|
|
208
|
+
});
|
|
209
|
+
} catch (error) {
|
|
210
|
+
process.stderr.write(`Migration failed: ${error.message}\n`);
|
|
211
|
+
process.exitCode = 1;
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
fs.writeFileSync(outPath, toPrettyJson(migrated), 'utf8');
|
|
216
|
+
process.stdout.write(`✓ Migrated to ${outPath}\n`);
|
|
217
|
+
process.exitCode = 0;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function handleFormat(filePath, flags) {
|
|
221
|
+
if (!filePath) {
|
|
222
|
+
process.stderr.write('Missing file path.\n');
|
|
223
|
+
printHelp(2);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const inputPath = resolvePath(filePath);
|
|
228
|
+
const outPath = flags.out ? resolvePath(flags.out) : inputPath;
|
|
229
|
+
|
|
230
|
+
let parsed;
|
|
231
|
+
try {
|
|
232
|
+
parsed = JSON.parse(fs.readFileSync(inputPath, 'utf8'));
|
|
233
|
+
} catch (error) {
|
|
234
|
+
process.stderr.write(`Failed to read/parse JSON: ${error.message}\n`);
|
|
235
|
+
process.exitCode = 2;
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const formatted = toPrettyJson(parsed);
|
|
240
|
+
|
|
241
|
+
if (flags.write || flags.out) {
|
|
242
|
+
fs.writeFileSync(outPath, formatted, 'utf8');
|
|
243
|
+
process.stdout.write(`✓ Wrote ${outPath}\n`);
|
|
244
|
+
} else {
|
|
245
|
+
process.stdout.write(formatted);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
process.exitCode = 0;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function main() {
|
|
252
|
+
const { command, positionals, flags } = parseArgs(process.argv.slice(2));
|
|
253
|
+
|
|
254
|
+
if (command === '--help' || command === '-h') {
|
|
255
|
+
printHelp(0);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (flags.help || !command) {
|
|
260
|
+
printHelp(flags.help ? 0 : 2);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (Array.isArray(flags.unknown) && flags.unknown.length > 0) {
|
|
265
|
+
process.stderr.write(`Unknown flags: ${flags.unknown.join(', ')}\n`);
|
|
266
|
+
process.exitCode = 2;
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
switch (command) {
|
|
271
|
+
case 'validate':
|
|
272
|
+
await handleValidate(positionals[0], flags);
|
|
273
|
+
return;
|
|
274
|
+
case 'migrate':
|
|
275
|
+
await handleMigrate(positionals[0], flags);
|
|
276
|
+
return;
|
|
277
|
+
case 'format':
|
|
278
|
+
await handleFormat(positionals[0], flags);
|
|
279
|
+
return;
|
|
280
|
+
default:
|
|
281
|
+
process.stderr.write(`Unknown command: ${command}\n`);
|
|
282
|
+
printHelp(2);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
main().catch((error) => {
|
|
287
|
+
process.stderr.write(`Unexpected error: ${error && error.message ? error.message : String(error)}\n`);
|
|
288
|
+
process.exitCode = 1;
|
|
289
|
+
});
|
package/index.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "workspec",
|
|
3
|
+
"private": false,
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"description": "WorkSpec v2.0 validator and CLI (workspec validate/migrate/format).",
|
|
6
|
+
"license": "AGPL-3.0",
|
|
7
|
+
"main": "index.js",
|
|
8
|
+
"bin": {
|
|
9
|
+
"workspec": "bin/workspec.js"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"check:sync": "node scripts/check-sync.js",
|
|
13
|
+
"sync:web": "node scripts/sync-web-assets.js",
|
|
14
|
+
"test": "node scripts/self-test.js"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"bin/",
|
|
18
|
+
"index.js",
|
|
19
|
+
"workspec-validator.js",
|
|
20
|
+
"workspec-migrate-v1-to-v2.js",
|
|
21
|
+
"v2.0.schema.json",
|
|
22
|
+
"README.md"
|
|
23
|
+
],
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=18"
|
|
26
|
+
},
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public"
|
|
29
|
+
}
|
|
30
|
+
}
|