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 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"] = `airgen_refresh=${this.auth.refreshToken}`;
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"] = `airgen_refresh=${this.auth.refreshToken}`;
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"] = `airgen_refresh=${this.auth.refreshToken}`;
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 airgen_refresh cookie value
252
- const match = setCookie.match(/airgen_refresh=([^;]+)/);
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-id>", "Requirement ID")
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, requirementId, opts) => {
13
- // Implementation status is stored in requirement attributes
14
- const data = await client.patch(`/requirements/${tenant}/${project}/${requirementId}`, {
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-id>", "Requirement ID")
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, requirementId, opts) => {
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: "500" });
35
- const req = (reqData.data ?? []).find(r => r.id === requirementId);
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 ${requirementId} not found.`);
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}/${requirementId}`, {
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-id>", "Requirement ID")
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, requirementId, opts) => {
62
- const reqData = await client.get(`/requirements/${tenant}/${project}`, { page: "1", limit: "500" });
63
- const req = (reqData.data ?? []).find(r => r.id === requirementId);
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 ${requirementId} not found.`);
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}/${requirementId}`, {
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}/${id}`, body);
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.delete(`/requirements/${tenant}/${project}/${id}`);
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 data = await client.get(`/requirements/${tenant}/${project}/${id}/history`);
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>;
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "airgen-cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "AIRGen CLI — requirements engineering from the command line",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",