@haystackeditor/cli 0.15.10 → 0.15.11

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,3 +1,22 @@
1
+ /**
2
+ * Path to a long-lived `hsk_live_*` token created by `haystack login --headless`.
3
+ * If present (or HAYSTACK_CLI_TOKEN is set), it takes priority over the
4
+ * regular OAuth credentials. CI environments use this exclusively.
5
+ *
6
+ * Why a separate file (not just an entry in credentials.json):
7
+ * 1. Different lifecycle — the OAuth flow renews + refreshes; hsk_live
8
+ * tokens are mint-once-revoke-never until explicit revocation.
9
+ * 2. Easier to commit accidentally and easier to .gitignore (one file).
10
+ * 3. Different shape — hsk_live tokens carry server-side scope that the
11
+ * OAuth token doesn't, and we don't want to model both in one schema.
12
+ */
13
+ export declare const HEADLESS_TOKEN_FILE: string;
14
+ interface HeadlessTokenFile {
15
+ token: string;
16
+ id?: string;
17
+ label?: string;
18
+ created_at?: string;
19
+ }
1
20
  export interface StoredAccount {
2
21
  github_token: string;
3
22
  created_at: string;
@@ -46,5 +65,15 @@ export declare function removeAccount(login?: string): Promise<string | null>;
46
65
  export declare function getActiveLogin(): Promise<string | null>;
47
66
  export declare function getRepoDefaultAccount(owner: string, repo: string): Promise<string | null>;
48
67
  export declare function setRepoDefaultAccount(owner: string, repo: string, login: string): Promise<void>;
68
+ /** Thrown by {@link resolveAuthContext} when no usable credential is found. */
69
+ export declare class NotLoggedInError extends Error {
70
+ constructor(message?: string);
71
+ }
72
+ /** Read the headless CLI token if one is configured. Env var wins over the
73
+ * on-disk file so CI can override without touching the home directory. */
74
+ export declare function readHeadlessToken(): Promise<HeadlessTokenFile | null>;
75
+ export declare function writeHeadlessToken(record: HeadlessTokenFile): Promise<string>;
76
+ export declare function clearHeadlessToken(): Promise<void>;
49
77
  export declare function resolveAuthContext(options?: ResolveAuthOptions): Promise<ResolvedAuthContext>;
50
78
  export declare function loadToken(options?: ResolveAuthOptions): Promise<string | null>;
79
+ export {};
@@ -4,6 +4,20 @@ import * as path from 'path';
4
4
  const CONFIG_DIR = path.join(os.homedir(), '.haystack');
5
5
  const TOKEN_FILE = path.join(CONFIG_DIR, 'credentials.json');
6
6
  const TOKEN_FILE_DISPLAY = '~/.haystack/credentials.json';
7
+ /**
8
+ * Path to a long-lived `hsk_live_*` token created by `haystack login --headless`.
9
+ * If present (or HAYSTACK_CLI_TOKEN is set), it takes priority over the
10
+ * regular OAuth credentials. CI environments use this exclusively.
11
+ *
12
+ * Why a separate file (not just an entry in credentials.json):
13
+ * 1. Different lifecycle — the OAuth flow renews + refreshes; hsk_live
14
+ * tokens are mint-once-revoke-never until explicit revocation.
15
+ * 2. Easier to commit accidentally and easier to .gitignore (one file).
16
+ * 3. Different shape — hsk_live tokens carry server-side scope that the
17
+ * OAuth token doesn't, and we don't want to model both in one schema.
18
+ */
19
+ export const HEADLESS_TOKEN_FILE = path.join(CONFIG_DIR, 'cli-token');
20
+ const HEADLESS_TOKEN_FILE_DISPLAY = '~/.haystack/cli-token';
7
21
  const GITHUB_LOGIN_PATTERN = /^[A-Za-z\d](?:[A-Za-z\d]|-(?=[A-Za-z\d])){0,38}$/;
8
22
  function emptyStore() {
9
23
  return {
@@ -337,7 +351,60 @@ export async function setRepoDefaultAccount(owner, repo, login) {
337
351
  store.repoDefaults[key] = storedLogin;
338
352
  await saveStore(store);
339
353
  }
354
+ /** Thrown by {@link resolveAuthContext} when no usable credential is found. */
355
+ export class NotLoggedInError extends Error {
356
+ constructor(message = 'Not logged in. Run `haystack login` first.') {
357
+ super(message);
358
+ this.name = 'NotLoggedInError';
359
+ }
360
+ }
361
+ /** Read the headless CLI token if one is configured. Env var wins over the
362
+ * on-disk file so CI can override without touching the home directory. */
363
+ export async function readHeadlessToken() {
364
+ const envToken = process.env.HAYSTACK_CLI_TOKEN;
365
+ if (envToken && envToken.startsWith('hsk_live_')) {
366
+ return { token: envToken, label: 'env:HAYSTACK_CLI_TOKEN' };
367
+ }
368
+ try {
369
+ const raw = await fs.readFile(HEADLESS_TOKEN_FILE, 'utf8');
370
+ const parsed = JSON.parse(raw);
371
+ if (typeof parsed.token === 'string' && parsed.token.startsWith('hsk_live_')) {
372
+ return parsed;
373
+ }
374
+ return null;
375
+ }
376
+ catch {
377
+ return null;
378
+ }
379
+ }
380
+ export async function writeHeadlessToken(record) {
381
+ await fs.mkdir(CONFIG_DIR, { recursive: true });
382
+ // 0600 so other users on a shared machine can't read the token. The file
383
+ // contains a long-lived credential — mode matters here in a way that doesn't
384
+ // for credentials.json, which can be re-minted via OAuth at any time.
385
+ await fs.writeFile(HEADLESS_TOKEN_FILE, JSON.stringify(record, null, 2) + '\n', {
386
+ mode: 0o600,
387
+ });
388
+ return HEADLESS_TOKEN_FILE_DISPLAY;
389
+ }
390
+ export async function clearHeadlessToken() {
391
+ try {
392
+ await fs.unlink(HEADLESS_TOKEN_FILE);
393
+ }
394
+ catch { /* not present is fine */ }
395
+ }
340
396
  export async function resolveAuthContext(options = {}) {
397
+ // hsk_live tokens win unconditionally. They were created explicitly by
398
+ // the operator with `haystack login --headless` (or set via env in CI)
399
+ // — that intent should not be overridden by a stale OAuth cookie.
400
+ const headless = await readHeadlessToken();
401
+ if (headless) {
402
+ return {
403
+ login: headless.label ?? 'headless',
404
+ token: headless.token,
405
+ source: 'active',
406
+ };
407
+ }
341
408
  const store = await loadCredentialsStore();
342
409
  const { preferredLogin, owner, repo } = options;
343
410
  if (preferredLogin) {
@@ -370,7 +437,7 @@ export async function resolveAuthContext(options = {}) {
370
437
  source: 'active',
371
438
  };
372
439
  }
373
- throw new Error('Not logged in. Run `haystack login` first.');
440
+ throw new NotLoggedInError();
374
441
  }
375
442
  export async function loadToken(options = {}) {
376
443
  try {
@@ -378,7 +445,8 @@ export async function loadToken(options = {}) {
378
445
  return context.token;
379
446
  }
380
447
  catch (error) {
381
- if (error instanceof Error && error.message.startsWith('Not logged in.')) {
448
+ // Classify by type, not message text (see pr-rules.yml PR007).
449
+ if (error instanceof NotLoggedInError) {
382
450
  return null;
383
451
  }
384
452
  throw error;
@@ -68,6 +68,10 @@ export interface PushAuth {
68
68
  * Without `auth`, falls back to `git push -u <remote>` using ambient creds.
69
69
  */
70
70
  export declare function pushBranch(remoteName: string, branchName: string, auth?: PushAuth): void;
71
+ /** Thrown when a git remote URL is not a recognized GitHub SSH/HTTPS URL. */
72
+ export declare class UnsupportedRemoteUrlError extends Error {
73
+ constructor(url: string);
74
+ }
71
75
  /**
72
76
  * Parse remote URL to extract owner and repo
73
77
  * Supports both SSH and HTTPS URLs
package/dist/utils/git.js CHANGED
@@ -257,6 +257,13 @@ function translatePushError(err, repoSlug) {
257
257
  // ============================================================================
258
258
  // Remote operations
259
259
  // ============================================================================
260
+ /** Thrown when a git remote URL is not a recognized GitHub SSH/HTTPS URL. */
261
+ export class UnsupportedRemoteUrlError extends Error {
262
+ constructor(url) {
263
+ super(`Unsupported remote URL format: ${url}`);
264
+ this.name = 'UnsupportedRemoteUrlError';
265
+ }
266
+ }
260
267
  /**
261
268
  * Parse remote URL to extract owner and repo
262
269
  * Supports both SSH and HTTPS URLs
@@ -285,10 +292,13 @@ export function parseRemoteUrl(remoteName = 'origin') {
285
292
  remoteName,
286
293
  };
287
294
  }
288
- throw new Error(`Unsupported remote URL format: ${url}`);
295
+ throw new UnsupportedRemoteUrlError(url);
289
296
  }
290
297
  catch (err) {
291
- if (err instanceof Error && err.message.includes('Unsupported')) {
298
+ // Propagate the "unrecognized URL" case as-is; wrap everything else (the
299
+ // `git remote get-url` command itself failing) in a friendlier message.
300
+ // Classify by type, not message text (see pr-rules.yml PR007).
301
+ if (err instanceof UnsupportedRemoteUrlError) {
292
302
  throw err;
293
303
  }
294
304
  throw new Error(`Failed to get remote URL for '${remoteName}'`);
@@ -139,7 +139,10 @@ class JsonPrompter {
139
139
  return id in this.answers;
140
140
  }
141
141
  emit(event) {
142
- process.stdout.write(JSON.stringify(event) + '\n');
142
+ // Stamp every NDJSON event with the setup schema version so agent
143
+ // consumers can pin a contract. See docs/SPEC-CLI-FIRST-CLASS.md.
144
+ const stamped = { schema_version: '1.0.0', ...event };
145
+ process.stdout.write(JSON.stringify(stamped) + '\n');
143
146
  }
144
147
  /** Lazily open stdin only when we actually need an answer the agent must send. */
145
148
  ensureStdin() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haystackeditor/cli",
3
- "version": "0.15.10",
3
+ "version": "0.15.11",
4
4
  "description": "Set up Haystack for your project — automated PR review, triage, and merge queue",
5
5
  "type": "module",
6
6
  "bin": {
@@ -10,6 +10,7 @@
10
10
  "build": "tsc && rm -rf dist/assets && cp -r src/assets dist/assets",
11
11
  "dev": "tsc --watch",
12
12
  "start": "node dist/index.js",
13
+ "check:schemas": "node scripts/check-schemas.mjs",
13
14
  "prepublishOnly": "npm run build"
14
15
  },
15
16
  "keywords": [
@@ -46,7 +47,8 @@
46
47
  "typescript": "5.9.3"
47
48
  },
48
49
  "files": [
49
- "dist"
50
+ "dist",
51
+ "schemas"
50
52
  ],
51
53
  "engines": {
52
54
  "node": ">=18"
@@ -0,0 +1,34 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://docs.haystackeditor.com/cli/schemas/pr.v1.json",
4
+ "title": "haystack pr <ref> --json",
5
+ "type": "object",
6
+ "required": ["schema_version", "owner", "repo", "prNumber", "bucket", "state"],
7
+ "properties": {
8
+ "schema_version": { "type": "string", "const": "1.0.0" },
9
+ "owner": { "type": "string" },
10
+ "repo": { "type": "string" },
11
+ "prNumber": { "type": "integer" },
12
+ "bucket": { "type": "string" },
13
+ "state": { "type": "string", "enum": ["open", "closed", "merged"] },
14
+ "title": { "type": "string" },
15
+ "headSha": { "type": "string" },
16
+ "haystackUrl": { "type": "string" },
17
+ "mergeQueue": {
18
+ "type": "object",
19
+ "properties": {
20
+ "state": { "type": "string", "enum": ["not-enrolled", "eligible", "grace", "queued", "merging", "merged", "blocked"] },
21
+ "graceEndsAt": { "type": ["string", "null"] },
22
+ "blockedReason": { "type": ["string", "null"] }
23
+ }
24
+ },
25
+ "lastTriage": {
26
+ "type": ["object", "null"],
27
+ "properties": {
28
+ "rating": { "type": "integer" },
29
+ "completedAt": { "type": "string" },
30
+ "headSha": { "type": "string" }
31
+ }
32
+ }
33
+ }
34
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://docs.haystackeditor.com/cli/schemas/setup.v1.json",
4
+ "title": "haystack setup --json event stream",
5
+ "description": "NDJSON event stream. Each line is one event stamped with schema_version.",
6
+ "type": "object",
7
+ "required": ["schema_version", "type"],
8
+ "properties": {
9
+ "schema_version": { "type": "string", "const": "1.0.0" },
10
+ "type": {
11
+ "type": "string",
12
+ "enum": ["question", "permission", "log", "progress", "result"]
13
+ },
14
+ "id": { "type": "string" },
15
+ "prompt": { "type": "string" },
16
+ "choices": { "type": "array" },
17
+ "level": { "type": "string", "enum": ["info", "warn", "error"] },
18
+ "message": { "type": "string" },
19
+ "result": { "type": "object" }
20
+ }
21
+ }
@@ -0,0 +1,50 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://docs.haystackeditor.com/cli/schemas/triage.v1.json",
4
+ "title": "haystack triage --json",
5
+ "type": "object",
6
+ "required": ["schema_version", "owner", "repo", "prNumber", "rating", "findings"],
7
+ "properties": {
8
+ "schema_version": { "type": "string", "const": "1.0.0" },
9
+ "owner": { "type": "string" },
10
+ "repo": { "type": "string" },
11
+ "prNumber": { "type": "integer" },
12
+ "rating": { "type": "integer", "minimum": 1, "maximum": 5 },
13
+ "autoFixerHandling": {
14
+ "type": "array",
15
+ "items": {
16
+ "type": "object",
17
+ "properties": {
18
+ "category": { "type": "string" },
19
+ "summary": { "type": "string" }
20
+ }
21
+ }
22
+ },
23
+ "autoFixerSkipped": {
24
+ "type": "array",
25
+ "items": {
26
+ "type": "object",
27
+ "properties": {
28
+ "category": { "type": "string" },
29
+ "summary": { "type": "string" },
30
+ "reason": { "type": "string" }
31
+ }
32
+ }
33
+ },
34
+ "findings": {
35
+ "type": "array",
36
+ "items": {
37
+ "type": "object",
38
+ "required": ["category", "summary"],
39
+ "properties": {
40
+ "category": { "type": "string" },
41
+ "summary": { "type": "string" },
42
+ "detail": { "type": "string" },
43
+ "agentFixPrompt": { "type": ["string", "null"] },
44
+ "source": { "type": ["string", "null"] },
45
+ "autoFixing": { "type": "boolean" }
46
+ }
47
+ }
48
+ }
49
+ }
50
+ }