airgen-cli 0.22.0 → 0.23.1
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.
|
@@ -1,4 +1,10 @@
|
|
|
1
|
+
import { writeFileSync } from "node:fs";
|
|
1
2
|
import { output, printTable, isJsonMode } from "../output.js";
|
|
3
|
+
function refFromRequirementId(id) {
|
|
4
|
+
// Composite IDs are tenant:project:REF
|
|
5
|
+
const parts = id.split(":");
|
|
6
|
+
return parts[parts.length - 1];
|
|
7
|
+
}
|
|
2
8
|
export function registerBaselineCommands(program, client) {
|
|
3
9
|
const cmd = program.command("baselines").alias("bl").description("Baseline snapshots");
|
|
4
10
|
cmd
|
|
@@ -52,4 +58,200 @@ export function registerBaselineCommands(program, client) {
|
|
|
52
58
|
const data = await client.get(`/baselines/${tenant}/${project}/compare`, { from: opts.from, to: opts.to });
|
|
53
59
|
output(data);
|
|
54
60
|
});
|
|
61
|
+
cmd
|
|
62
|
+
.command("show")
|
|
63
|
+
.description("Show baseline contents (requirements with full text, rationale, tags)")
|
|
64
|
+
.argument("<tenant>", "Tenant slug")
|
|
65
|
+
.argument("<project>", "Project slug")
|
|
66
|
+
.argument("<baseline-ref>", "Baseline ref")
|
|
67
|
+
.option("--ref <pattern>", "Filter to requirements whose ref matches this substring")
|
|
68
|
+
.action(async (tenant, project, baselineRef, opts) => {
|
|
69
|
+
const snap = await client.get(`/baselines/${tenant}/${project}/${encodeURIComponent(baselineRef)}`);
|
|
70
|
+
let reqs = snap.requirementVersions ?? [];
|
|
71
|
+
if (opts.ref) {
|
|
72
|
+
const needle = opts.ref;
|
|
73
|
+
reqs = reqs.filter(r => refFromRequirementId(r.requirementId).includes(needle));
|
|
74
|
+
}
|
|
75
|
+
if (isJsonMode()) {
|
|
76
|
+
output({ baseline: snap.baseline, requirementVersions: reqs });
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
console.log(`Baseline: ${snap.baseline.ref ?? snap.baseline.id}`);
|
|
80
|
+
console.log(`Label: ${snap.baseline.label ?? ""}`);
|
|
81
|
+
console.log(`Created: ${snap.baseline.createdAt ?? ""}`);
|
|
82
|
+
console.log(`Requirements: ${reqs.length}\n`);
|
|
83
|
+
for (const r of reqs) {
|
|
84
|
+
const ref = refFromRequirementId(r.requirementId);
|
|
85
|
+
console.log(`── ${ref} (v${r.versionNumber}) ──`);
|
|
86
|
+
console.log(`text: ${r.text}`);
|
|
87
|
+
if (r.pattern)
|
|
88
|
+
console.log(`pattern: ${r.pattern}`);
|
|
89
|
+
if (r.verification)
|
|
90
|
+
console.log(`verify: ${r.verification}`);
|
|
91
|
+
if (r.rationale)
|
|
92
|
+
console.log(`rationale: ${r.rationale}`);
|
|
93
|
+
if (r.complianceStatus)
|
|
94
|
+
console.log(`compliance: ${r.complianceStatus}`);
|
|
95
|
+
if (r.tags?.length)
|
|
96
|
+
console.log(`tags: ${r.tags.join(", ")}`);
|
|
97
|
+
console.log("");
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
cmd
|
|
101
|
+
.command("export")
|
|
102
|
+
.description("Export full baseline snapshot as JSON")
|
|
103
|
+
.argument("<tenant>", "Tenant slug")
|
|
104
|
+
.argument("<project>", "Project slug")
|
|
105
|
+
.argument("<baseline-ref>", "Baseline ref")
|
|
106
|
+
.option("-o, --output <file>", "Write JSON to file instead of stdout")
|
|
107
|
+
.action(async (tenant, project, baselineRef, opts) => {
|
|
108
|
+
const snap = await client.get(`/baselines/${tenant}/${project}/${encodeURIComponent(baselineRef)}`);
|
|
109
|
+
const json = JSON.stringify(snap, null, 2);
|
|
110
|
+
if (opts.output) {
|
|
111
|
+
writeFileSync(opts.output, json);
|
|
112
|
+
console.error(`Wrote ${opts.output} (${(json.length / 1024).toFixed(1)} KB, ${snap.requirementVersions?.length ?? 0} requirements)`);
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
process.stdout.write(json + "\n");
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
cmd
|
|
119
|
+
.command("restore")
|
|
120
|
+
.description("Restore requirements from a baseline (PATCHes live requirements with snapshot values). Joins on immutable internal id, so survives reqs reassign.")
|
|
121
|
+
.argument("<tenant>", "Tenant slug")
|
|
122
|
+
.argument("<project>", "Project slug")
|
|
123
|
+
.argument("<baseline-ref>", "Baseline ref")
|
|
124
|
+
.option("--ref <pattern>", "Restore only requirements whose original ref includes this substring")
|
|
125
|
+
.option("--fields <list>", "Comma-separated fields to restore (default: rationale,text,pattern,verification,complianceStatus,complianceRationale,tags)", "rationale,text,pattern,verification,complianceStatus,complianceRationale,tags")
|
|
126
|
+
.option("--dry-run", "Print what would be restored without making changes")
|
|
127
|
+
.option("--yes", "Skip confirmation prompt")
|
|
128
|
+
.option("--match-by-text", "Fall back to text-content matching for requirements not found by id (e.g. recreated entities)")
|
|
129
|
+
.option("--strict", "Exit non-zero if any requirement fails to restore")
|
|
130
|
+
.action(async (tenant, project, baselineRef, opts) => {
|
|
131
|
+
const snap = await client.get(`/baselines/${tenant}/${project}/${encodeURIComponent(baselineRef)}`);
|
|
132
|
+
let reqs = snap.requirementVersions ?? [];
|
|
133
|
+
if (opts.ref) {
|
|
134
|
+
const needle = opts.ref;
|
|
135
|
+
reqs = reqs.filter(r => refFromRequirementId(r.requirementId).includes(needle));
|
|
136
|
+
}
|
|
137
|
+
const fields = opts.fields.split(",").map(f => f.trim()).filter(Boolean);
|
|
138
|
+
const allowed = new Set(["text", "pattern", "verification", "rationale", "complianceStatus", "complianceRationale", "tags"]);
|
|
139
|
+
const invalid = fields.filter(f => !allowed.has(f));
|
|
140
|
+
if (invalid.length > 0) {
|
|
141
|
+
console.error(`Invalid fields: ${invalid.join(", ")}`);
|
|
142
|
+
console.error(`Allowed: ${[...allowed].join(", ")}`);
|
|
143
|
+
process.exit(1);
|
|
144
|
+
}
|
|
145
|
+
console.error(`Baseline: ${snap.baseline.ref ?? snap.baseline.id}`);
|
|
146
|
+
console.error(`Label: ${snap.baseline.label ?? ""}`);
|
|
147
|
+
console.error(`Created: ${snap.baseline.createdAt ?? ""}`);
|
|
148
|
+
console.error(`Requirements to restore: ${reqs.length}`);
|
|
149
|
+
console.error(`Fields: ${fields.join(", ")}`);
|
|
150
|
+
console.error(`Join key: immutable id (survives reqs reassign)${opts.matchByText ? " + text-match fallback" : ""}`);
|
|
151
|
+
console.error("");
|
|
152
|
+
// Optionally fetch current requirements for text-match fallback
|
|
153
|
+
let textIndex = null;
|
|
154
|
+
if (opts.matchByText) {
|
|
155
|
+
console.error("Fetching current project requirements for text-match fallback...");
|
|
156
|
+
const allCurrent = [];
|
|
157
|
+
let page = 1;
|
|
158
|
+
const limit = 200;
|
|
159
|
+
while (true) {
|
|
160
|
+
const pageData = await client.get(`/requirements/${tenant}/${project}`, { page: String(page), limit: String(limit) });
|
|
161
|
+
const items = pageData.data ?? [];
|
|
162
|
+
allCurrent.push(...items);
|
|
163
|
+
if (page >= (pageData.meta?.totalPages ?? 1))
|
|
164
|
+
break;
|
|
165
|
+
page++;
|
|
166
|
+
}
|
|
167
|
+
textIndex = new Map();
|
|
168
|
+
for (const c of allCurrent) {
|
|
169
|
+
if (c.text && c.ref) {
|
|
170
|
+
textIndex.set(c.text.trim(), c.ref);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
console.error(`Indexed ${textIndex.size} current requirements by text.\n`);
|
|
174
|
+
}
|
|
175
|
+
if (opts.dryRun) {
|
|
176
|
+
for (const r of reqs) {
|
|
177
|
+
const origRef = refFromRequirementId(r.requirementId);
|
|
178
|
+
const summary = fields.map(f => {
|
|
179
|
+
const v = r[f];
|
|
180
|
+
if (v == null)
|
|
181
|
+
return `${f}=(none)`;
|
|
182
|
+
const s = typeof v === "string" ? v : JSON.stringify(v);
|
|
183
|
+
return `${f}=${s.length > 40 ? s.slice(0, 40) + "…" : s}`;
|
|
184
|
+
}).join(" | ");
|
|
185
|
+
console.log(`[dry-run] ${origRef}: ${summary}`);
|
|
186
|
+
}
|
|
187
|
+
console.error(`\n[dry-run] Would restore ${reqs.length} requirements. Re-run without --dry-run to apply.`);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (!opts.yes) {
|
|
191
|
+
console.error(`This will OVERWRITE current values for ${reqs.length} requirements.`);
|
|
192
|
+
console.error("Pass --yes to proceed, or --dry-run to preview.");
|
|
193
|
+
process.exit(1);
|
|
194
|
+
}
|
|
195
|
+
let ok = 0;
|
|
196
|
+
let failed = 0;
|
|
197
|
+
let skipped = 0;
|
|
198
|
+
let textMatched = 0;
|
|
199
|
+
const failures = [];
|
|
200
|
+
for (const r of reqs) {
|
|
201
|
+
const origRef = refFromRequirementId(r.requirementId);
|
|
202
|
+
const body = {};
|
|
203
|
+
for (const f of fields) {
|
|
204
|
+
const v = r[f];
|
|
205
|
+
if (v !== undefined)
|
|
206
|
+
body[f] = v;
|
|
207
|
+
}
|
|
208
|
+
// Skip requirements where the baseline has no values for any of the requested fields.
|
|
209
|
+
// (e.g. restoring --fields tags on a requirement that had no tags at baseline time)
|
|
210
|
+
if (Object.keys(body).length === 0) {
|
|
211
|
+
skipped++;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
// Primary: PATCH by immutable composite id (the baseline's requirementId).
|
|
215
|
+
// Backend resolver matches r.id, which is set on creation and never mutates.
|
|
216
|
+
try {
|
|
217
|
+
await client.patch(`/requirements/${tenant}/${project}/${encodeURIComponent(r.requirementId)}`, body);
|
|
218
|
+
ok++;
|
|
219
|
+
if (ok % 25 === 0)
|
|
220
|
+
console.error(` restored ${ok}/${reqs.length}…`);
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
catch (err) {
|
|
224
|
+
// Fall through to text-match fallback if enabled
|
|
225
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
226
|
+
if (textIndex && r.text) {
|
|
227
|
+
const liveRef = textIndex.get(r.text.trim());
|
|
228
|
+
if (liveRef) {
|
|
229
|
+
try {
|
|
230
|
+
await client.patch(`/requirements/${tenant}/${project}/${encodeURIComponent(liveRef)}`, body);
|
|
231
|
+
ok++;
|
|
232
|
+
textMatched++;
|
|
233
|
+
console.error(` text-matched ${origRef} -> ${liveRef}`);
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
catch (err2) {
|
|
237
|
+
failed++;
|
|
238
|
+
failures.push({ ref: origRef, reason: `text-match to ${liveRef}: ${err2 instanceof Error ? err2.message : String(err2)}` });
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
failed++;
|
|
244
|
+
failures.push({ ref: origRef, reason: errMsg });
|
|
245
|
+
console.error(` FAILED ${origRef}: ${errMsg}`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
console.error(`\nDone. Restored ${ok}${textMatched > 0 ? ` (${textMatched} via text-match)` : ""}, skipped ${skipped} (no baseline values for requested fields), failed ${failed}.`);
|
|
249
|
+
if (failed > 0 && !opts.matchByText) {
|
|
250
|
+
console.error(`\nHint: re-run with --match-by-text to attempt content-based recovery for the ${failed} failures.`);
|
|
251
|
+
}
|
|
252
|
+
if (failed > 0 && opts.strict) {
|
|
253
|
+
console.error("\n--strict: exiting non-zero due to failures.");
|
|
254
|
+
process.exit(2);
|
|
255
|
+
}
|
|
256
|
+
});
|
|
55
257
|
}
|
|
@@ -42,12 +42,18 @@ export function registerImportExportCommands(program, client) {
|
|
|
42
42
|
const exp = program.command("export").description("Export data");
|
|
43
43
|
exp
|
|
44
44
|
.command("requirements")
|
|
45
|
-
.description("Export requirements as markdown or JSON")
|
|
45
|
+
.description("Export requirements as markdown or JSON (current state or from a baseline)")
|
|
46
46
|
.argument("<tenant>", "Tenant slug")
|
|
47
47
|
.argument("<project>", "Project slug")
|
|
48
48
|
.option("--document <slug>", "Export specific document as markdown")
|
|
49
49
|
.option("--format <fmt>", "Format: json, markdown", "json")
|
|
50
|
+
.option("--baseline <ref>", "Export requirement state captured in a baseline (instead of current)")
|
|
50
51
|
.action(async (tenant, project, opts) => {
|
|
52
|
+
if (opts.baseline) {
|
|
53
|
+
const snap = await client.get(`/baselines/${tenant}/${project}/${encodeURIComponent(opts.baseline)}`);
|
|
54
|
+
output({ baseline: snap.baseline, requirements: snap.requirementVersions ?? [] });
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
51
57
|
if (opts.document && opts.format === "markdown") {
|
|
52
58
|
const data = await client.get(`/markdown/${tenant}/${project}/${opts.document}/content`);
|
|
53
59
|
if (isJsonMode()) {
|
|
@@ -221,12 +221,16 @@ export function registerRequirementCommands(program, client) {
|
|
|
221
221
|
else if (opts.tags) {
|
|
222
222
|
body.tags = opts.tags.split(",").map(t => t.trim());
|
|
223
223
|
}
|
|
224
|
-
await client.patch(`/requirements/${tenant}/${project}/${resolvedId}`, body);
|
|
224
|
+
const result = await client.patch(`/requirements/${tenant}/${project}/${resolvedId}`, body);
|
|
225
|
+
const warnings = result?.warnings ?? [];
|
|
225
226
|
if (isJsonMode()) {
|
|
226
|
-
output({ ok: true });
|
|
227
|
+
output({ ok: true, warnings });
|
|
227
228
|
}
|
|
228
229
|
else {
|
|
229
230
|
console.log("Requirement updated.");
|
|
231
|
+
for (const w of warnings) {
|
|
232
|
+
console.error(` warning: ${w}`);
|
|
233
|
+
}
|
|
230
234
|
}
|
|
231
235
|
});
|
|
232
236
|
cmd
|