@xera-ai/core 0.1.3 → 0.1.5

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.
Files changed (39) hide show
  1. package/package.json +3 -3
  2. package/src/adapter/types.ts +62 -0
  3. package/src/artifact/hash.ts +15 -0
  4. package/src/artifact/meta.ts +40 -0
  5. package/src/artifact/paths.ts +62 -0
  6. package/src/artifact/status.ts +55 -0
  7. package/src/auth/encrypt.ts +40 -0
  8. package/src/auth/key.ts +15 -0
  9. package/src/auth/refresh.ts +31 -0
  10. package/src/auth/state.ts +32 -0
  11. package/src/bin-internal/exec.ts +111 -0
  12. package/src/bin-internal/fetch.ts +71 -0
  13. package/src/bin-internal/index.ts +39 -0
  14. package/src/bin-internal/lint.ts +12 -0
  15. package/src/bin-internal/normalize.ts +20 -0
  16. package/src/bin-internal/post.ts +35 -0
  17. package/src/bin-internal/promote.ts +12 -0
  18. package/src/bin-internal/report.ts +47 -0
  19. package/src/bin-internal/status-cmd.ts +13 -0
  20. package/src/bin-internal/typecheck.ts +12 -0
  21. package/src/bin-internal/unlock.ts +18 -0
  22. package/src/bin-internal/validate-feature.ts +14 -0
  23. package/src/classifier/aggregate.ts +26 -0
  24. package/src/classifier/history.ts +27 -0
  25. package/src/classifier/types.ts +25 -0
  26. package/src/config/define.ts +2 -0
  27. package/src/config/load.ts +14 -0
  28. package/src/config/schema.ts +74 -0
  29. package/src/index.ts +19 -0
  30. package/src/jira/client.ts +20 -0
  31. package/src/jira/fields.ts +21 -0
  32. package/src/jira/mcp-backend.ts +40 -0
  33. package/src/jira/rest-backend.ts +79 -0
  34. package/src/jira/retry.ts +24 -0
  35. package/src/jira/types.ts +21 -0
  36. package/src/lock/file-lock.ts +57 -0
  37. package/src/logging/ndjson-logger.ts +25 -0
  38. package/src/reporter/jira-comment.ts +31 -0
  39. package/src/reporter/status-writer.ts +37 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xera-ai/core",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -19,14 +19,14 @@
19
19
  "bin": {
20
20
  "xera-internal": "./dist/bin/internal.js"
21
21
  },
22
- "files": ["dist", "bin"],
22
+ "files": ["dist", "src", "bin"],
23
23
  "scripts": {
24
24
  "build": "bun build ./src/index.ts ./bin/internal.ts --outdir ./dist --target bun --external @playwright/test --external @xera-ai/web --external zod",
25
25
  "typecheck": "tsc --noEmit"
26
26
  },
27
27
  "dependencies": {
28
28
  "zod": "3.23.8",
29
- "@xera-ai/web": "0.1.2",
29
+ "@xera-ai/web": "^0.1.5",
30
30
  "@playwright/test": "1.48.0"
31
31
  }
32
32
  }
@@ -0,0 +1,62 @@
1
+ import type { XeraConfig } from '../config/schema';
2
+ import type { Classification } from '../artifact/status';
3
+
4
+ export interface GenerateInput {
5
+ ticketDir: string;
6
+ feature: string;
7
+ story: string;
8
+ config: XeraConfig;
9
+ }
10
+
11
+ export interface GenerateResult {
12
+ artifacts: string[];
13
+ warnings: string[];
14
+ }
15
+
16
+ export interface ExecuteInput {
17
+ ticketDir: string;
18
+ config: XeraConfig;
19
+ runId: string;
20
+ env: string;
21
+ }
22
+
23
+ export interface ScenarioResult {
24
+ name: string;
25
+ outcome: 'PASS' | 'FAIL' | 'SKIPPED';
26
+ failure?: {
27
+ step?: string;
28
+ errorMessage?: string;
29
+ domSnapshotAtFailure?: string;
30
+ networkAtFailure?: Array<{ method: string; url: string; status: number }>;
31
+ consoleAtFailure?: string[];
32
+ screenshotPath?: string;
33
+ };
34
+ }
35
+
36
+ export interface RunResult {
37
+ runId: string;
38
+ outcome: 'PASS' | 'FAIL';
39
+ scenarios: ScenarioResult[];
40
+ artifactsDir: string;
41
+ rawReportPath: string;
42
+ normalizedReportPath: string;
43
+ }
44
+
45
+ export interface ClassifyContext {
46
+ history: Array<{ ts: string; result: 'PASS' | 'FAIL'; class: Classification }>;
47
+ storyHashChanged: boolean;
48
+ specHashChanged: boolean;
49
+ }
50
+
51
+ export interface DoctorReport {
52
+ ok: boolean;
53
+ checks: Array<{ name: string; ok: boolean; message?: string }>;
54
+ }
55
+
56
+ export interface TestAdapter {
57
+ readonly id: string;
58
+ generate(input: GenerateInput): Promise<GenerateResult>;
59
+ execute(input: ExecuteInput): Promise<RunResult>;
60
+ classify?(run: RunResult, ctx: ClassifyContext): Partial<{ class: Classification; rationale: string }>;
61
+ doctor(): Promise<DoctorReport>;
62
+ }
@@ -0,0 +1,15 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+
4
+ export function hashString(s: string): string {
5
+ return `sha256:${createHash('sha256').update(s).digest('hex')}`;
6
+ }
7
+
8
+ export function hashFile(path: string): string {
9
+ return hashString(readFileSync(path, 'utf8'));
10
+ }
11
+
12
+ export function hashFileIfExists(path: string): string | null {
13
+ if (!existsSync(path)) return null;
14
+ return hashFile(path);
15
+ }
@@ -0,0 +1,40 @@
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import { z } from 'zod';
4
+
5
+ export const MetaJsonSchema = z.object({
6
+ ticket: z.string(),
7
+ adapter: z.string(),
8
+ xera_version: z.string(),
9
+ prompts_version: z.string(),
10
+ fetched_at: z.string().optional(),
11
+ story_hash: z.string().optional(),
12
+ feature_generated_at: z.string().optional(),
13
+ feature_generated_from_story_hash: z.string().optional(),
14
+ feature_hash: z.string().optional(),
15
+ script_generated_at: z.string().optional(),
16
+ script_generated_from_feature_hash: z.string().optional(),
17
+ script_warnings: z.array(z.string()).optional(),
18
+ });
19
+
20
+ export type MetaJson = z.infer<typeof MetaJsonSchema>;
21
+
22
+ export function readMeta(path: string): MetaJson | null {
23
+ if (!existsSync(path)) return null;
24
+ return MetaJsonSchema.parse(JSON.parse(readFileSync(path, 'utf8')));
25
+ }
26
+
27
+ export function writeMeta(path: string, meta: MetaJson): void {
28
+ mkdirSync(dirname(path), { recursive: true });
29
+ writeFileSync(path, JSON.stringify(meta, null, 2));
30
+ }
31
+
32
+ export function updateMeta(path: string, patch: Partial<MetaJson>): MetaJson {
33
+ const existing = readMeta(path);
34
+ if (!existing) {
35
+ throw new Error(`meta.json not found at ${path}; cannot update`);
36
+ }
37
+ const next = { ...existing, ...patch };
38
+ writeMeta(path, next);
39
+ return next;
40
+ }
@@ -0,0 +1,62 @@
1
+ import { join } from 'node:path';
2
+
3
+ const TICKET_RE = /^[A-Z][A-Z0-9_]*-\d+$|^SAMPLE-\d+$/;
4
+
5
+ export interface RunPaths {
6
+ runDir: string;
7
+ reportJsonPath: string;
8
+ tracePath: string;
9
+ normalizedPath: string;
10
+ screenshotsDir: string;
11
+ videoDir: string;
12
+ }
13
+
14
+ export interface ArtifactPaths {
15
+ ticketDir: string;
16
+ storyPath: string;
17
+ featurePath: string;
18
+ specPath: string;
19
+ pageObjectsDir: string;
20
+ runsDir: string;
21
+ metaPath: string;
22
+ statusPath: string;
23
+ logPath: string;
24
+ lockPath: string;
25
+ authDir: string;
26
+ runPath: (runId: string) => RunPaths;
27
+ }
28
+
29
+ export function resolveArtifactPaths(repoRoot: string, ticket: string): ArtifactPaths {
30
+ if (!TICKET_RE.test(ticket)) {
31
+ throw new Error(`Invalid ticket key: "${ticket}" (expected e.g. JIRA-123 or SAMPLE-001)`);
32
+ }
33
+ const ticketDir = join(repoRoot, '.xera', ticket);
34
+ return {
35
+ ticketDir,
36
+ storyPath: join(ticketDir, 'story.md'),
37
+ featurePath: join(ticketDir, 'test.feature'),
38
+ specPath: join(ticketDir, 'spec.ts'),
39
+ pageObjectsDir: join(ticketDir, 'page-objects'),
40
+ runsDir: join(ticketDir, 'runs'),
41
+ metaPath: join(ticketDir, 'meta.json'),
42
+ statusPath: join(ticketDir, 'status.json'),
43
+ logPath: join(ticketDir, 'xera.log'),
44
+ lockPath: join(ticketDir, '.lock'),
45
+ authDir: join(repoRoot, '.xera', '.auth'),
46
+ runPath: (runId: string) => {
47
+ const runDir = join(ticketDir, 'runs', runId);
48
+ return {
49
+ runDir,
50
+ reportJsonPath: join(runDir, 'report.json'),
51
+ tracePath: join(runDir, 'trace.zip'),
52
+ normalizedPath: join(runDir, 'normalized.json'),
53
+ screenshotsDir: join(runDir, 'screenshots'),
54
+ videoDir: join(runDir, 'videos'),
55
+ };
56
+ },
57
+ };
58
+ }
59
+
60
+ export function generateRunId(now: Date = new Date()): string {
61
+ return now.toISOString().replace(/[:.]/g, '-').replace('Z', '');
62
+ }
@@ -0,0 +1,55 @@
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import { z } from 'zod';
4
+
5
+ const ClassificationEnum = z.enum(['PASS', 'REAL_BUG', 'SELECTOR_DRIFT', 'FLAKY', 'TEST_BUG']);
6
+ const ResultEnum = z.enum(['PASS', 'FAIL']);
7
+ const ConfidenceEnum = z.enum(['low', 'medium', 'high']);
8
+
9
+ export const HistoryEntrySchema = z.object({
10
+ ts: z.string(),
11
+ result: ResultEnum,
12
+ class: ClassificationEnum,
13
+ });
14
+
15
+ export const StatusJsonSchema = z.object({
16
+ ticket: z.string(),
17
+ lastRun: z.string(),
18
+ result: ResultEnum,
19
+ classification: ClassificationEnum,
20
+ confidence: ConfidenceEnum,
21
+ scenarios: z.object({
22
+ total: z.number().int().nonnegative(),
23
+ passed: z.number().int().nonnegative(),
24
+ failed: z.number().int().nonnegative(),
25
+ skipped: z.number().int().nonnegative(),
26
+ }),
27
+ history: z.array(HistoryEntrySchema).default([]),
28
+ last_jira_comment_id: z.string().optional(),
29
+ });
30
+
31
+ export type StatusJson = z.infer<typeof StatusJsonSchema>;
32
+ export type HistoryEntry = z.infer<typeof HistoryEntrySchema>;
33
+ export type Classification = z.infer<typeof ClassificationEnum>;
34
+
35
+ const HISTORY_CAP = 20;
36
+
37
+ export function readStatus(path: string): StatusJson | null {
38
+ if (!existsSync(path)) return null;
39
+ return StatusJsonSchema.parse(JSON.parse(readFileSync(path, 'utf8')));
40
+ }
41
+
42
+ export function writeStatus(path: string, status: StatusJson): void {
43
+ mkdirSync(dirname(path), { recursive: true });
44
+ writeFileSync(path, JSON.stringify(status, null, 2));
45
+ }
46
+
47
+ export function appendHistory(path: string, entry: HistoryEntry): StatusJson {
48
+ const s = readStatus(path);
49
+ if (!s) {
50
+ throw new Error(`status.json not found at ${path}`);
51
+ }
52
+ s.history = [entry, ...s.history].slice(0, HISTORY_CAP);
53
+ writeStatus(path, s);
54
+ return s;
55
+ }
@@ -0,0 +1,40 @@
1
+ import { randomBytes, createCipheriv, createDecipheriv } from 'node:crypto';
2
+
3
+ const ALGO = 'aes-256-gcm';
4
+ const KEY_LEN = 32; // bytes (256 bits)
5
+ const IV_LEN = 12; // recommended for GCM
6
+ const TAG_LEN = 16;
7
+ const VERSION = 'v1';
8
+
9
+ export function generateKey(): string {
10
+ return randomBytes(KEY_LEN).toString('hex');
11
+ }
12
+
13
+ function keyToBuf(key: string): Buffer {
14
+ const buf = Buffer.from(key, 'hex');
15
+ if (buf.length !== KEY_LEN) throw new Error(`Key must be ${KEY_LEN} bytes (got ${buf.length})`);
16
+ return buf;
17
+ }
18
+
19
+ export function encrypt(plaintext: string, keyHex: string): string {
20
+ const key = keyToBuf(keyHex);
21
+ const iv = randomBytes(IV_LEN);
22
+ const cipher = createCipheriv(ALGO, key, iv);
23
+ const ct = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
24
+ const tag = cipher.getAuthTag();
25
+ return `${VERSION}:${iv.toString('base64')}:${tag.toString('base64')}:${ct.toString('base64')}`;
26
+ }
27
+
28
+ export function decrypt(ciphertext: string, keyHex: string): string {
29
+ const [version, ivB64, tagB64, ctB64] = ciphertext.split(':');
30
+ if (version !== VERSION) throw new Error(`Unsupported ciphertext version: ${version}`);
31
+ if (!ivB64 || !tagB64 || !ctB64) throw new Error('Malformed ciphertext');
32
+ const key = keyToBuf(keyHex);
33
+ const iv = Buffer.from(ivB64, 'base64');
34
+ const tag = Buffer.from(tagB64, 'base64');
35
+ const ct = Buffer.from(ctB64, 'base64');
36
+ if (tag.length !== TAG_LEN) throw new Error('Bad auth tag length');
37
+ const decipher = createDecipheriv(ALGO, key, iv);
38
+ decipher.setAuthTag(tag);
39
+ return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8');
40
+ }
@@ -0,0 +1,15 @@
1
+ export const AUTH_KEY_ENV = 'XERA_AUTH_KEY';
2
+
3
+ export function resolveAuthKey(): string {
4
+ const key = process.env[AUTH_KEY_ENV];
5
+ if (!key) {
6
+ throw new Error(
7
+ `${AUTH_KEY_ENV} not set. It is auto-generated by \`xera init\` and saved to .env. ` +
8
+ `If you deleted .env, regenerate it by running \`xera init --update\` — note that any cached auth state will be invalidated.`,
9
+ );
10
+ }
11
+ if (!/^[0-9a-f]{64}$/i.test(key)) {
12
+ throw new Error(`${AUTH_KEY_ENV} must be a 64-character hex string (32 bytes).`);
13
+ }
14
+ return key;
15
+ }
@@ -0,0 +1,31 @@
1
+ import type { AuthStateEntry } from './state';
2
+ export type { AuthStateEntry } from './state';
3
+
4
+ const RE = /^(\d+)([hms])$/;
5
+
6
+ export function parseDuration(d: string): number {
7
+ const m = RE.exec(d);
8
+ if (!m) throw new Error(`Bad duration "${d}" — expected e.g. "8h", "30m", "45s"`);
9
+ const n = Number(m[1]);
10
+ const unit = m[2]!;
11
+ if (unit === 'h') return n * 3600 * 1000;
12
+ if (unit === 'm') return n * 60 * 1000;
13
+ return n * 1000;
14
+ }
15
+
16
+ export interface RefreshPolicy { ttl: string; refreshBuffer: string; }
17
+
18
+ export function needsRefresh(
19
+ entry: AuthStateEntry | null,
20
+ policy: RefreshPolicy,
21
+ now: Date = new Date(),
22
+ ): boolean {
23
+ if (!entry) return true;
24
+ const ttlMs = parseDuration(policy.ttl);
25
+ const bufMs = parseDuration(policy.refreshBuffer);
26
+ const createdAt = new Date(entry.created_at).getTime();
27
+ if (now.getTime() - createdAt > ttlMs) return true;
28
+ const expiresAt = new Date(entry.expires_at).getTime();
29
+ if (expiresAt - now.getTime() < bufMs) return true;
30
+ return false;
31
+ }
@@ -0,0 +1,32 @@
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { z } from 'zod';
4
+ import { encrypt, decrypt } from './encrypt';
5
+ import { resolveAuthKey } from './key';
6
+
7
+ export const AuthStateEntrySchema = z.object({
8
+ role: z.string(),
9
+ strategy: z.enum(['storageState', 'apiToken']),
10
+ created_at: z.string(),
11
+ expires_at: z.string(),
12
+ payload: z.record(z.string(), z.unknown()),
13
+ });
14
+ export type AuthStateEntry = z.infer<typeof AuthStateEntrySchema>;
15
+
16
+ function pathFor(authDir: string, role: string): string {
17
+ return join(authDir, `${role}.json`);
18
+ }
19
+
20
+ export function writeAuthState(authDir: string, entry: AuthStateEntry): void {
21
+ mkdirSync(authDir, { recursive: true });
22
+ const ct = encrypt(JSON.stringify(entry), resolveAuthKey());
23
+ writeFileSync(pathFor(authDir, entry.role), ct);
24
+ }
25
+
26
+ export function readAuthState(authDir: string, role: string): AuthStateEntry | null {
27
+ const p = pathFor(authDir, role);
28
+ if (!existsSync(p)) return null;
29
+ const txt = readFileSync(p, 'utf8');
30
+ const plain = decrypt(txt, resolveAuthKey());
31
+ return AuthStateEntrySchema.parse(JSON.parse(plain));
32
+ }
@@ -0,0 +1,111 @@
1
+ import { resolveArtifactPaths, generateRunId } from '../artifact/paths';
2
+ import { acquireLock, releaseLock, isLockStale, readLock, forceUnlock } from '../lock/file-lock';
3
+ import { NdjsonLogger } from '../logging/ndjson-logger';
4
+ import { loadConfig } from '../config/load';
5
+ import { readAuthState } from '../auth/state';
6
+ import { needsRefresh } from '../auth/refresh';
7
+ import { stagePlaywrightState, runAuthSetup, runPlaywright } from '@xera-ai/web';
8
+ import { chromium } from '@playwright/test';
9
+ import { writeFileSync, mkdirSync, existsSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+
12
+ export async function execCmd(argv: string[]): Promise<number> {
13
+ const ticket = argv[0];
14
+ if (!ticket) { console.error('[xera:exec] usage: exec <TICKET>'); return 1; }
15
+ const cwd = process.cwd();
16
+ const config = await loadConfig(cwd);
17
+ const paths = resolveArtifactPaths(cwd, ticket);
18
+ const runId = generateRunId();
19
+ const log = new NdjsonLogger(paths.logPath);
20
+
21
+ // Acquire lock
22
+ if (!acquireLock(paths.lockPath, runId)) {
23
+ if (isLockStale(paths.lockPath)) {
24
+ console.error(`[xera:exec] stale lock detected; force unlocking. Run \`xera-internal unlock ${ticket}\` to clear manually.`);
25
+ forceUnlock(paths.lockPath);
26
+ acquireLock(paths.lockPath, runId);
27
+ } else {
28
+ const existing = readLock(paths.lockPath);
29
+ console.error(`[xera:exec] another run in progress (PID ${existing?.pid} on ${existing?.hostname}, started ${existing?.started_at}). Wait or run \`xera-internal unlock ${ticket}\`.`);
30
+ return 1;
31
+ }
32
+ }
33
+
34
+ const t0 = Date.now();
35
+ try {
36
+ // Auth refresh per role declared in xera.config.ts
37
+ if (config.web.auth.strategy === 'storageState' && config.web.auth.setupScript) {
38
+ const browser = await chromium.launch();
39
+ try {
40
+ for (const [roleName, roleCreds] of Object.entries(config.web.auth.roles)) {
41
+ const entry = readAuthState(paths.authDir, roleName);
42
+ if (needsRefresh(entry, { ttl: config.web.auth.ttl, refreshBuffer: config.web.auth.refreshBuffer })) {
43
+ const email = process.env[roleCreds.envEmail];
44
+ const password = process.env[roleCreds.envPassword];
45
+ if (!email || !password) {
46
+ console.error(`[xera:exec] missing env ${roleCreds.envEmail} or ${roleCreds.envPassword} for role "${roleName}"`);
47
+ return 1;
48
+ }
49
+ await runAuthSetup({
50
+ role: roleName,
51
+ creds: { email, password },
52
+ setupScriptPath: join(cwd, config.web.auth.setupScript),
53
+ authDir: paths.authDir,
54
+ browser,
55
+ });
56
+ log.log({ step: 'auth-refresh', role: roleName });
57
+ }
58
+ }
59
+ } finally {
60
+ await browser.close();
61
+ }
62
+ }
63
+
64
+ // Stage Playwright storageState files for declared roles
65
+ const stagedRoles: Record<string, string> = {};
66
+ if (config.web.auth.strategy === 'storageState') {
67
+ for (const roleName of Object.keys(config.web.auth.roles)) {
68
+ if (readAuthState(paths.authDir, roleName)) {
69
+ stagedRoles[roleName] = stagePlaywrightState(paths.authDir, roleName);
70
+ }
71
+ }
72
+ }
73
+
74
+ // Generate per-run playwright.config.ts if not present at ticketDir
75
+ const cfgPath = join(paths.ticketDir, 'playwright.config.ts');
76
+ if (!existsSync(cfgPath)) {
77
+ writeFileSync(cfgPath, renderPlaywrightConfig({
78
+ baseUrl: config.web.baseUrl[config.web.defaultEnv]!,
79
+ storageStatePathPerRole: stagedRoles,
80
+ }));
81
+ }
82
+
83
+ const runDir = paths.runPath(runId).runDir;
84
+ mkdirSync(runDir, { recursive: true });
85
+
86
+ log.log({ step: 'exec.start', runId });
87
+ const r = await runPlaywright({ specPath: paths.specPath, configPath: cfgPath, outputDir: runDir });
88
+ log.log({ step: 'exec.done', runId, exit: r.exitCode, ms: Date.now() - t0 });
89
+
90
+ console.log(`[xera:exec] runId=${runId} outcome=${r.outcome}`);
91
+ // Exit 3 means "test failed" (expected vs infra error)
92
+ return r.outcome === 'PASS' ? 0 : 3;
93
+ } finally {
94
+ releaseLock(paths.lockPath);
95
+ }
96
+ }
97
+
98
+ function renderPlaywrightConfig(opts: { baseUrl: string; storageStatePathPerRole: Record<string, string> }): string {
99
+ const projects = Object.entries(opts.storageStatePathPerRole).map(
100
+ ([role, path]) => ` { name: '${role}', use: { ...devices['Desktop Chromium'], storageState: '${path}' } }`,
101
+ );
102
+ if (projects.length === 0) projects.push(` { name: 'default', use: { ...devices['Desktop Chromium'] } }`);
103
+ return `import { defineConfig, devices } from '@playwright/test';
104
+ export default defineConfig({
105
+ use: { baseURL: '${opts.baseUrl}', trace: 'on' },
106
+ projects: [
107
+ ${projects.join(',\n')}
108
+ ],
109
+ });
110
+ `;
111
+ }
@@ -0,0 +1,71 @@
1
+ import { writeFileSync, mkdirSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import { loadConfig } from '../config/load';
4
+ import { resolveArtifactPaths } from '../artifact/paths';
5
+ import { hashString } from '../artifact/hash';
6
+ import { writeMeta, readMeta } from '../artifact/meta';
7
+ import { createJiraClient } from '../jira/client';
8
+ import type { JiraTicket } from '../jira/types';
9
+
10
+ export interface FetchCmdOpts { cwd?: string; }
11
+
12
+ export async function fetchCmd(argv: string[], opts: FetchCmdOpts = {}): Promise<number> {
13
+ const cwd = opts.cwd ?? process.cwd();
14
+ const ticket = argv[0];
15
+ if (!ticket) {
16
+ console.error('[xera:fetch] usage: xera-internal fetch <TICKET>');
17
+ return 1;
18
+ }
19
+ const config = await loadConfig(cwd);
20
+ const paths = resolveArtifactPaths(cwd, ticket);
21
+
22
+ // Test injection: skip real Jira when XERA_TEST_JIRA env is set.
23
+ let t: JiraTicket;
24
+ if (process.env.XERA_TEST_JIRA) {
25
+ t = JSON.parse(process.env.XERA_TEST_JIRA) as JiraTicket;
26
+ } else {
27
+ const client = await createJiraClient({
28
+ baseUrl: config.jira.baseUrl,
29
+ preferMcp: true,
30
+ ...(process.env.JIRA_EMAIL && process.env.JIRA_API_TOKEN
31
+ ? { rest: { email: process.env.JIRA_EMAIL, apiToken: process.env.JIRA_API_TOKEN } }
32
+ : {}),
33
+ });
34
+ const fieldMap = config.jira.fields.acceptanceCriteria !== undefined
35
+ ? { story: config.jira.fields.story, acceptanceCriteria: config.jira.fields.acceptanceCriteria }
36
+ : { story: config.jira.fields.story };
37
+ t = await client.fetchTicket(ticket, fieldMap);
38
+ }
39
+
40
+ const story = renderStory(t);
41
+ mkdirSync(dirname(paths.storyPath), { recursive: true });
42
+ writeFileSync(paths.storyPath, story);
43
+
44
+ const existing = readMeta(paths.metaPath);
45
+ writeMeta(paths.metaPath, {
46
+ ticket,
47
+ adapter: 'web',
48
+ xera_version: '0.1.0',
49
+ prompts_version: '1.0.0',
50
+ ...(existing ?? {}),
51
+ // Re-stamp the just-fetched fields:
52
+ story_hash: hashString(story),
53
+ fetched_at: new Date().toISOString(),
54
+ });
55
+
56
+ console.log(`[xera:fetch] wrote ${paths.storyPath}`);
57
+ return 0;
58
+ }
59
+
60
+ function renderStory(t: JiraTicket): string {
61
+ const lines: string[] = [];
62
+ lines.push(`# ${t.key}: ${t.summary}`, '');
63
+ lines.push(`## Story`, '', t.story.trim(), '');
64
+ if (t.acceptanceCriteria && t.acceptanceCriteria.trim()) {
65
+ lines.push(`## Acceptance Criteria`, '', t.acceptanceCriteria.trim(), '');
66
+ }
67
+ if (t.attachments.length > 0) {
68
+ lines.push(`## Attachments`, '', ...t.attachments.map(a => `- [${a.filename}](${a.url})`), '');
69
+ }
70
+ return lines.join('\n');
71
+ }
@@ -0,0 +1,39 @@
1
+ import { fetchCmd } from './fetch';
2
+ import { validateFeatureCmd } from './validate-feature';
3
+ import { typecheckCmd } from './typecheck';
4
+ import { lintCmd } from './lint';
5
+ import { execCmd } from './exec';
6
+ import { normalizeCmd } from './normalize';
7
+ import { reportCmd } from './report';
8
+ import { postCmd } from './post';
9
+ import { statusCmd } from './status-cmd';
10
+ import { unlockCmd } from './unlock';
11
+ import { promoteCmd } from './promote';
12
+
13
+ const COMMANDS: Record<string, (argv: string[]) => Promise<number>> = {
14
+ fetch: fetchCmd,
15
+ 'validate-feature': validateFeatureCmd,
16
+ typecheck: typecheckCmd,
17
+ lint: lintCmd,
18
+ exec: execCmd,
19
+ normalize: normalizeCmd,
20
+ report: reportCmd,
21
+ post: postCmd,
22
+ status: statusCmd,
23
+ unlock: unlockCmd,
24
+ promote: promoteCmd,
25
+ };
26
+
27
+ export async function run(argv: string[]): Promise<number> {
28
+ const [cmd, ...rest] = argv;
29
+ if (!cmd || !COMMANDS[cmd]) {
30
+ console.error(`Usage: xera-internal <command> [args...]\nCommands: ${Object.keys(COMMANDS).join(', ')}`);
31
+ return 1;
32
+ }
33
+ try {
34
+ return await COMMANDS[cmd]!(rest);
35
+ } catch (err) {
36
+ console.error(`[xera:${cmd}] failed: ${(err as Error).message}`);
37
+ return 4;
38
+ }
39
+ }
@@ -0,0 +1,12 @@
1
+ import { resolveArtifactPaths } from '../artifact/paths';
2
+ import { lintTicket } from '@xera-ai/web';
3
+
4
+ export async function lintCmd(argv: string[]): Promise<number> {
5
+ const ticket = argv[0];
6
+ if (!ticket) { console.error('[xera:lint] usage: lint <TICKET>'); return 1; }
7
+ const paths = resolveArtifactPaths(process.cwd(), ticket);
8
+ const r = await lintTicket(paths.ticketDir);
9
+ if (r.ok) { console.log('[xera:lint] ok'); return 0; }
10
+ for (const w of r.warnings) console.error(`[xera:lint] ${w.file}:${w.line} [${w.rule}] ${w.message}`);
11
+ return 2;
12
+ }
@@ -0,0 +1,20 @@
1
+ import { resolveArtifactPaths } from '../artifact/paths';
2
+ import { normalizeRun } from '@xera-ai/web';
3
+ import { readdirSync, existsSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+
6
+ export async function normalizeCmd(argv: string[]): Promise<number> {
7
+ const ticket = argv[0];
8
+ if (!ticket) { console.error('[xera:normalize] usage: normalize <TICKET> [--run=<runId>]'); return 1; }
9
+ const paths = resolveArtifactPaths(process.cwd(), ticket);
10
+ const runArg = argv.find(a => a.startsWith('--run='));
11
+ const runId = runArg
12
+ ? runArg.split('=')[1]!
13
+ : readdirSync(paths.runsDir).filter(n => !n.startsWith('.')).sort().pop()!;
14
+ if (!runId) { console.error('[xera:normalize] no run found'); return 1; }
15
+ const runDir = join(paths.runsDir, runId);
16
+ if (!existsSync(runDir)) { console.error(`[xera:normalize] runs/${runId} missing`); return 1; }
17
+ const r = await normalizeRun({ runId, runDir });
18
+ console.log(`[xera:normalize] wrote normalized.json (scrubbed_fields_count=${r.scrubbed_fields_count})`);
19
+ return 0;
20
+ }
@@ -0,0 +1,35 @@
1
+ import { readFileSync, existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { resolveArtifactPaths } from '../artifact/paths';
4
+ import { loadConfig } from '../config/load';
5
+ import { readStatus, writeStatus } from '../artifact/status';
6
+ import { createJiraClient } from '../jira/client';
7
+
8
+ export async function postCmd(argv: string[]): Promise<number> {
9
+ const ticket = argv[0];
10
+ if (!ticket) { console.error('[xera:post] usage: post <TICKET>'); return 1; }
11
+ const cwd = process.cwd();
12
+ const config = await loadConfig(cwd);
13
+ if (!config.reporting.postToJira) {
14
+ console.log('[xera:post] postToJira disabled in config; skipping');
15
+ return 0;
16
+ }
17
+ const paths = resolveArtifactPaths(cwd, ticket);
18
+ const draftPath = join(paths.ticketDir, 'jira-comment.draft.md');
19
+ if (!existsSync(draftPath)) { console.error(`[xera:post] no draft at ${draftPath}; run \`xera-internal report\` first.`); return 1; }
20
+ const body = readFileSync(draftPath, 'utf8');
21
+
22
+ const client = await createJiraClient({
23
+ baseUrl: config.jira.baseUrl,
24
+ preferMcp: true,
25
+ ...(process.env.JIRA_EMAIL && process.env.JIRA_API_TOKEN
26
+ ? { rest: { email: process.env.JIRA_EMAIL, apiToken: process.env.JIRA_API_TOKEN } }
27
+ : {}),
28
+ });
29
+ const r = await client.postComment(ticket, body);
30
+ console.log(`[xera:post] posted comment id=${r.id}`);
31
+
32
+ const s = readStatus(paths.statusPath);
33
+ if (s) writeStatus(paths.statusPath, { ...s, last_jira_comment_id: r.id });
34
+ return 0;
35
+ }