airgen-cli 0.1.0 → 0.1.2
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/dist/client.d.ts +4 -0
- package/dist/client.js +51 -5
- package/dist/commands/implementation.js +19 -16
- package/dist/commands/requirements.js +10 -6
- package/dist/resolve.d.ts +8 -0
- package/dist/resolve.js +41 -0
- package/package.json +1 -1
package/dist/client.d.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* AIRGen HTTP API client with JWT authentication.
|
|
3
3
|
*
|
|
4
4
|
* Handles login, token caching, and automatic refresh.
|
|
5
|
+
* Persists session tokens to ~/.airgen-session for reuse across CLI invocations.
|
|
5
6
|
*/
|
|
6
7
|
export interface ClientConfig {
|
|
7
8
|
apiUrl: string;
|
|
@@ -13,6 +14,9 @@ export declare class AirgenClient {
|
|
|
13
14
|
private auth;
|
|
14
15
|
private loginPromise;
|
|
15
16
|
constructor(config: ClientConfig);
|
|
17
|
+
private loadSession;
|
|
18
|
+
private saveSession;
|
|
19
|
+
private clearSession;
|
|
16
20
|
/** The base API URL this client is configured for. */
|
|
17
21
|
get apiUrl(): string;
|
|
18
22
|
get<T = unknown>(path: string, query?: Record<string, string | number | undefined>): Promise<T>;
|
package/dist/client.js
CHANGED
|
@@ -2,7 +2,12 @@
|
|
|
2
2
|
* AIRGen HTTP API client with JWT authentication.
|
|
3
3
|
*
|
|
4
4
|
* Handles login, token caching, and automatic refresh.
|
|
5
|
+
* Persists session tokens to ~/.airgen-session for reuse across CLI invocations.
|
|
5
6
|
*/
|
|
7
|
+
import { readFileSync, writeFileSync, unlinkSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
const SESSION_FILE = join(homedir(), ".airgen-session");
|
|
6
11
|
export class AirgenClient {
|
|
7
12
|
config;
|
|
8
13
|
auth = {
|
|
@@ -13,6 +18,44 @@ export class AirgenClient {
|
|
|
13
18
|
loginPromise = null;
|
|
14
19
|
constructor(config) {
|
|
15
20
|
this.config = config;
|
|
21
|
+
this.loadSession();
|
|
22
|
+
}
|
|
23
|
+
loadSession() {
|
|
24
|
+
try {
|
|
25
|
+
const raw = readFileSync(SESSION_FILE, "utf-8");
|
|
26
|
+
const session = JSON.parse(raw);
|
|
27
|
+
// Only reuse session if it matches current config
|
|
28
|
+
if (session.apiUrl === this.config.apiUrl && session.email === this.config.email) {
|
|
29
|
+
this.auth = {
|
|
30
|
+
accessToken: session.accessToken,
|
|
31
|
+
refreshToken: session.refreshToken,
|
|
32
|
+
tokenExpiresAt: session.tokenExpiresAt,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// No session file or invalid — start fresh
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
saveSession() {
|
|
41
|
+
try {
|
|
42
|
+
writeFileSync(SESSION_FILE, JSON.stringify({
|
|
43
|
+
apiUrl: this.config.apiUrl,
|
|
44
|
+
email: this.config.email,
|
|
45
|
+
accessToken: this.auth.accessToken,
|
|
46
|
+
refreshToken: this.auth.refreshToken,
|
|
47
|
+
tokenExpiresAt: this.auth.tokenExpiresAt,
|
|
48
|
+
}), { mode: 0o600 });
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// Non-fatal — session just won't persist
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
clearSession() {
|
|
55
|
+
try {
|
|
56
|
+
unlinkSync(SESSION_FILE);
|
|
57
|
+
}
|
|
58
|
+
catch { /* ignore */ }
|
|
16
59
|
}
|
|
17
60
|
/** The base API URL this client is configured for. */
|
|
18
61
|
get apiUrl() {
|
|
@@ -53,7 +96,7 @@ export class AirgenClient {
|
|
|
53
96
|
headers["Authorization"] = `Bearer ${this.auth.accessToken}`;
|
|
54
97
|
}
|
|
55
98
|
if (this.auth.refreshToken) {
|
|
56
|
-
headers["Cookie"] = `
|
|
99
|
+
headers["Cookie"] = `refreshToken=${this.auth.refreshToken}`;
|
|
57
100
|
}
|
|
58
101
|
const url = `${this.config.apiUrl}${path}`;
|
|
59
102
|
const res = await globalThis.fetch(url, { method: "POST", headers, body });
|
|
@@ -105,7 +148,7 @@ export class AirgenClient {
|
|
|
105
148
|
headers["Content-Type"] = "application/json";
|
|
106
149
|
}
|
|
107
150
|
if (this.auth.refreshToken) {
|
|
108
|
-
headers["Cookie"] = `
|
|
151
|
+
headers["Cookie"] = `refreshToken=${this.auth.refreshToken}`;
|
|
109
152
|
}
|
|
110
153
|
const url = `${this.config.apiUrl}${path}`;
|
|
111
154
|
try {
|
|
@@ -194,11 +237,12 @@ export class AirgenClient {
|
|
|
194
237
|
this.auth.accessToken = data.token;
|
|
195
238
|
this.auth.tokenExpiresAt = decodeTokenExpiry(data.token);
|
|
196
239
|
this.auth.refreshToken = extractRefreshToken(res);
|
|
240
|
+
this.saveSession();
|
|
197
241
|
}
|
|
198
242
|
async refresh() {
|
|
199
243
|
const headers = {};
|
|
200
244
|
if (this.auth.refreshToken) {
|
|
201
|
-
headers["Cookie"] = `
|
|
245
|
+
headers["Cookie"] = `refreshToken=${this.auth.refreshToken}`;
|
|
202
246
|
}
|
|
203
247
|
const res = await globalThis.fetch(`${this.config.apiUrl}/auth/refresh`, {
|
|
204
248
|
method: "POST",
|
|
@@ -207,6 +251,7 @@ export class AirgenClient {
|
|
|
207
251
|
if (!res.ok) {
|
|
208
252
|
// Refresh failed — clear state and re-login
|
|
209
253
|
this.auth = { accessToken: null, refreshToken: null, tokenExpiresAt: 0 };
|
|
254
|
+
this.clearSession();
|
|
210
255
|
await this.login();
|
|
211
256
|
return;
|
|
212
257
|
}
|
|
@@ -217,6 +262,7 @@ export class AirgenClient {
|
|
|
217
262
|
if (newRefresh) {
|
|
218
263
|
this.auth.refreshToken = newRefresh;
|
|
219
264
|
}
|
|
265
|
+
this.saveSession();
|
|
220
266
|
}
|
|
221
267
|
}
|
|
222
268
|
// ── Error class ───────────────────────────────────────────────
|
|
@@ -248,8 +294,8 @@ function extractRefreshToken(res) {
|
|
|
248
294
|
const setCookie = res.headers.get("set-cookie");
|
|
249
295
|
if (!setCookie)
|
|
250
296
|
return null;
|
|
251
|
-
// Parse
|
|
252
|
-
const match = setCookie.match(/
|
|
297
|
+
// Parse refreshToken cookie value
|
|
298
|
+
const match = setCookie.match(/refreshToken=([^;]+)/);
|
|
253
299
|
return match ? match[1] : null;
|
|
254
300
|
}
|
|
255
301
|
function buildQueryString(params) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { output } from "../output.js";
|
|
2
|
+
import { resolveRequirementId } from "../resolve.js";
|
|
2
3
|
export function registerImplementationCommands(program, client) {
|
|
3
4
|
const cmd = program.command("impl").description("Implementation tracking");
|
|
4
5
|
cmd
|
|
@@ -6,12 +7,12 @@ export function registerImplementationCommands(program, client) {
|
|
|
6
7
|
.description("Set implementation status on a requirement")
|
|
7
8
|
.argument("<tenant>", "Tenant slug")
|
|
8
9
|
.argument("<project>", "Project slug")
|
|
9
|
-
.argument("<requirement
|
|
10
|
+
.argument("<requirement>", "Requirement ref, ID, or hashId")
|
|
10
11
|
.requiredOption("--status <s>", "Status: not_started, in_progress, implemented, verified, blocked")
|
|
11
12
|
.option("--notes <text>", "Notes")
|
|
12
|
-
.action(async (tenant, project,
|
|
13
|
-
|
|
14
|
-
const data = await client.patch(`/requirements/${tenant}/${project}/${
|
|
13
|
+
.action(async (tenant, project, requirement, opts) => {
|
|
14
|
+
const resolvedId = await resolveRequirementId(client, tenant, project, requirement);
|
|
15
|
+
const data = await client.patch(`/requirements/${tenant}/${project}/${resolvedId}`, {
|
|
15
16
|
attributes: {
|
|
16
17
|
implementationStatus: opts.status,
|
|
17
18
|
implementationNotes: opts.notes,
|
|
@@ -24,17 +25,18 @@ export function registerImplementationCommands(program, client) {
|
|
|
24
25
|
.description("Link a code artifact to a requirement")
|
|
25
26
|
.argument("<tenant>", "Tenant slug")
|
|
26
27
|
.argument("<project>", "Project slug")
|
|
27
|
-
.argument("<requirement
|
|
28
|
+
.argument("<requirement>", "Requirement ref, ID, or hashId")
|
|
28
29
|
.requiredOption("--type <type>", "Artifact type: file, commit, pr, issue, test, url")
|
|
29
30
|
.requiredOption("--path <path>", "Artifact path/reference")
|
|
30
31
|
.option("--label <text>", "Label")
|
|
31
32
|
.option("--line <n>", "Line number (for file type)")
|
|
32
|
-
.action(async (tenant, project,
|
|
33
|
+
.action(async (tenant, project, requirement, opts) => {
|
|
34
|
+
const resolvedId = await resolveRequirementId(client, tenant, project, requirement);
|
|
33
35
|
// Fetch current requirement to get existing artifacts
|
|
34
|
-
const reqData = await client.get(`/requirements/${tenant}/${project}`, { page: "1", limit: "
|
|
35
|
-
const req = (reqData.data ?? []).find(r => r.id ===
|
|
36
|
+
const reqData = await client.get(`/requirements/${tenant}/${project}`, { page: "1", limit: "100" });
|
|
37
|
+
const req = (reqData.data ?? []).find(r => r.id === resolvedId);
|
|
36
38
|
if (!req) {
|
|
37
|
-
console.error(`Requirement ${
|
|
39
|
+
console.error(`Requirement ${requirement} not found.`);
|
|
38
40
|
process.exit(1);
|
|
39
41
|
}
|
|
40
42
|
const attrs = req.attributes ?? {};
|
|
@@ -45,7 +47,7 @@ export function registerImplementationCommands(program, client) {
|
|
|
45
47
|
if (opts.line)
|
|
46
48
|
newArtifact.line = parseInt(opts.line, 10);
|
|
47
49
|
artifacts.push(newArtifact);
|
|
48
|
-
await client.patch(`/requirements/${tenant}/${project}/${
|
|
50
|
+
await client.patch(`/requirements/${tenant}/${project}/${resolvedId}`, {
|
|
49
51
|
attributes: { ...attrs, artifacts },
|
|
50
52
|
});
|
|
51
53
|
console.log("Artifact linked.");
|
|
@@ -55,20 +57,21 @@ export function registerImplementationCommands(program, client) {
|
|
|
55
57
|
.description("Remove an artifact link from a requirement")
|
|
56
58
|
.argument("<tenant>", "Tenant slug")
|
|
57
59
|
.argument("<project>", "Project slug")
|
|
58
|
-
.argument("<requirement
|
|
60
|
+
.argument("<requirement>", "Requirement ref, ID, or hashId")
|
|
59
61
|
.requiredOption("--type <type>", "Artifact type")
|
|
60
62
|
.requiredOption("--path <path>", "Artifact path")
|
|
61
|
-
.action(async (tenant, project,
|
|
62
|
-
const
|
|
63
|
-
const
|
|
63
|
+
.action(async (tenant, project, requirement, opts) => {
|
|
64
|
+
const resolvedId = await resolveRequirementId(client, tenant, project, requirement);
|
|
65
|
+
const reqData = await client.get(`/requirements/${tenant}/${project}`, { page: "1", limit: "100" });
|
|
66
|
+
const req = (reqData.data ?? []).find(r => r.id === resolvedId);
|
|
64
67
|
if (!req) {
|
|
65
|
-
console.error(`Requirement ${
|
|
68
|
+
console.error(`Requirement ${requirement} not found.`);
|
|
66
69
|
process.exit(1);
|
|
67
70
|
}
|
|
68
71
|
const attrs = req.attributes ?? {};
|
|
69
72
|
const artifacts = attrs.artifacts ?? [];
|
|
70
73
|
const filtered = artifacts.filter(a => !(a.type === opts.type && a.path === opts.path));
|
|
71
|
-
await client.patch(`/requirements/${tenant}/${project}/${
|
|
74
|
+
await client.patch(`/requirements/${tenant}/${project}/${resolvedId}`, {
|
|
72
75
|
attributes: { ...attrs, artifacts: filtered },
|
|
73
76
|
});
|
|
74
77
|
console.log("Artifact unlinked.");
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { output, printTable, isJsonMode, truncate } from "../output.js";
|
|
2
|
+
import { resolveRequirementId } from "../resolve.js";
|
|
2
3
|
export function registerRequirementCommands(program, client) {
|
|
3
4
|
const cmd = program.command("requirements").alias("reqs").description("Manage requirements");
|
|
4
5
|
cmd
|
|
@@ -96,7 +97,7 @@ export function registerRequirementCommands(program, client) {
|
|
|
96
97
|
.description("Update a requirement")
|
|
97
98
|
.argument("<tenant>", "Tenant slug")
|
|
98
99
|
.argument("<project>", "Project slug")
|
|
99
|
-
.argument("<id>", "Requirement ID")
|
|
100
|
+
.argument("<id>", "Requirement ref, ID, or hashId")
|
|
100
101
|
.option("--text <text>", "Requirement text")
|
|
101
102
|
.option("--pattern <p>", "Pattern")
|
|
102
103
|
.option("--verification <v>", "Verification method")
|
|
@@ -105,6 +106,7 @@ export function registerRequirementCommands(program, client) {
|
|
|
105
106
|
.option("--section <id>", "Section ID")
|
|
106
107
|
.option("--tags <tags>", "Comma-separated tags")
|
|
107
108
|
.action(async (tenant, project, id, opts) => {
|
|
109
|
+
const resolvedId = await resolveRequirementId(client, tenant, project, id);
|
|
108
110
|
const body = {};
|
|
109
111
|
if (opts.text)
|
|
110
112
|
body.text = opts.text;
|
|
@@ -120,7 +122,7 @@ export function registerRequirementCommands(program, client) {
|
|
|
120
122
|
body.sectionId = opts.section;
|
|
121
123
|
if (opts.tags)
|
|
122
124
|
body.tags = opts.tags.split(",").map(t => t.trim());
|
|
123
|
-
await client.patch(`/requirements/${tenant}/${project}/${
|
|
125
|
+
await client.patch(`/requirements/${tenant}/${project}/${resolvedId}`, body);
|
|
124
126
|
if (isJsonMode()) {
|
|
125
127
|
output({ ok: true });
|
|
126
128
|
}
|
|
@@ -133,9 +135,10 @@ export function registerRequirementCommands(program, client) {
|
|
|
133
135
|
.description("Soft-delete a requirement")
|
|
134
136
|
.argument("<tenant>", "Tenant slug")
|
|
135
137
|
.argument("<project>", "Project slug")
|
|
136
|
-
.argument("<id>", "Requirement ID")
|
|
138
|
+
.argument("<id>", "Requirement ref, ID, or hashId")
|
|
137
139
|
.action(async (tenant, project, id) => {
|
|
138
|
-
await client
|
|
140
|
+
const resolvedId = await resolveRequirementId(client, tenant, project, id);
|
|
141
|
+
await client.delete(`/requirements/${tenant}/${project}/${resolvedId}`);
|
|
139
142
|
console.log("Requirement deleted.");
|
|
140
143
|
});
|
|
141
144
|
cmd
|
|
@@ -143,9 +146,10 @@ export function registerRequirementCommands(program, client) {
|
|
|
143
146
|
.description("Get version history of a requirement")
|
|
144
147
|
.argument("<tenant>", "Tenant slug")
|
|
145
148
|
.argument("<project>", "Project slug")
|
|
146
|
-
.argument("<id>", "Requirement ID")
|
|
149
|
+
.argument("<id>", "Requirement ref, ID, or hashId")
|
|
147
150
|
.action(async (tenant, project, id) => {
|
|
148
|
-
const
|
|
151
|
+
const resolvedId = await resolveRequirementId(client, tenant, project, id);
|
|
152
|
+
const data = await client.get(`/requirements/${tenant}/${project}/${resolvedId}/history`);
|
|
149
153
|
output(data);
|
|
150
154
|
});
|
|
151
155
|
cmd
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve any requirement identifier to its full ID.
|
|
3
|
+
*
|
|
4
|
+
* Accepts: full ID, ref, short ID (REQ-XXX), or hashId.
|
|
5
|
+
* Returns the full colon-separated ID (tenant:project:REQ-XXX).
|
|
6
|
+
*/
|
|
7
|
+
import type { AirgenClient } from "./client.js";
|
|
8
|
+
export declare function resolveRequirementId(client: AirgenClient, tenant: string, project: string, identifier: string): Promise<string>;
|
package/dist/resolve.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve any requirement identifier to its full ID.
|
|
3
|
+
*
|
|
4
|
+
* Accepts: full ID, ref, short ID (REQ-XXX), or hashId.
|
|
5
|
+
* Returns the full colon-separated ID (tenant:project:REQ-XXX).
|
|
6
|
+
*/
|
|
7
|
+
export async function resolveRequirementId(client, tenant, project, identifier) {
|
|
8
|
+
// Already a full ID (contains colons)
|
|
9
|
+
if (identifier.includes(":")) {
|
|
10
|
+
return identifier;
|
|
11
|
+
}
|
|
12
|
+
// Try as ref first (GET /requirements/{tenant}/{project}/{ref})
|
|
13
|
+
try {
|
|
14
|
+
const data = await client.get(`/requirements/${tenant}/${project}/${identifier}`);
|
|
15
|
+
if (data.record?.id)
|
|
16
|
+
return data.record.id;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
// Not found by ref — continue
|
|
20
|
+
}
|
|
21
|
+
// Search through requirements for short ID or hashId match
|
|
22
|
+
let page = 1;
|
|
23
|
+
const limit = 100;
|
|
24
|
+
while (true) {
|
|
25
|
+
const data = await client.get(`/requirements/${tenant}/${project}`, { page: String(page), limit: String(limit) });
|
|
26
|
+
const reqs = data.data ?? [];
|
|
27
|
+
for (const r of reqs) {
|
|
28
|
+
// Match by short ID (the REQ-XXX part of tenant:project:REQ-XXX)
|
|
29
|
+
if (r.id?.endsWith(`:${identifier}`))
|
|
30
|
+
return r.id;
|
|
31
|
+
// Match by hashId
|
|
32
|
+
if (r.hashId === identifier)
|
|
33
|
+
return r.id;
|
|
34
|
+
}
|
|
35
|
+
if (page >= (data.meta?.totalPages ?? 1))
|
|
36
|
+
break;
|
|
37
|
+
page++;
|
|
38
|
+
}
|
|
39
|
+
// Nothing found — return as-is and let the API error
|
|
40
|
+
return identifier;
|
|
41
|
+
}
|