@corelayer-ai/cli 0.5.1 → 0.5.3

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,14 +1,10 @@
1
1
  import { stdin as input, stdout as output } from "node:process";
2
2
  import { createInterface } from "node:readline/promises";
3
3
  import { CorelayerClient } from "../lib/api-client.js";
4
+ import { readFlag } from "../lib/args.js";
4
5
  import { readConfig, resolveApiUrl, resolveToken } from "../lib/config.js";
5
6
  import { fail, formatDate, printJson, printTable } from "../lib/output.js";
6
- function readFlag(args, flag) {
7
- const index = args.indexOf(flag);
8
- if (index === -1)
9
- return undefined;
10
- return args[index + 1];
11
- }
7
+ import { runIssueTickets } from "./issue-tickets.js";
12
8
  function hasFlag(args, flag) {
13
9
  return args.includes(flag);
14
10
  }
@@ -58,6 +54,7 @@ USAGE:
58
54
  SUBCOMMANDS:
59
55
  list List issues in a group
60
56
  get <id|slug> Show details for one issue
57
+ ticket <action> <id> Jira/Linear destinations, search, ensure, create, link, status
61
58
  summary Summarize issue health for a group
62
59
  diff Show issues new or changed since a timestamp
63
60
  close <id|slug> Close an issue (write operation)
@@ -80,6 +77,15 @@ OPTIONS:
80
77
  Diff: ISO 8601 timestamp or preset (1hour, 24hours, 7days, 14days, 30days)
81
78
  --duration <duration>
82
79
  Diff: human-friendly duration (e.g. 2hours, 4hours, 30m, 1d)
80
+ --provider jira|linear Ticket provider
81
+ --account <id> Connected ticket account
82
+ --project <id> Ticket project
83
+ --team <id> Linear team
84
+ --type <id> Jira issue type
85
+ --ticket <id> Selected existing ticket (link)
86
+ --request-id <uuid> Resume the same mutation or read its status
87
+ --revision <n> Jira account revision from ticket destinations
88
+ --fields <json> Required provider creation fields
83
89
  --yes Skip confirmation prompt (delete)
84
90
  --json Output as JSON
85
91
  -h, --help Print help
@@ -112,208 +118,206 @@ function parseDuration(value) {
112
118
  return Math.round(n * 24 * 60 * 60 * 1000);
113
119
  return null;
114
120
  }
115
- export async function runIssues(args, ctx) {
116
- if (args.includes("--help") || args.includes("-h")) {
117
- printHelp();
118
- return;
121
+ function resolveGroupId(args, config) {
122
+ const groupId = readFlag(args, "--group") || config.defaults?.group;
123
+ if (!groupId) {
124
+ fail("groupId is required. Use --group <groupId> or set default-group via corelayer config set default-group <groupId>");
119
125
  }
120
- const sub = args[0];
121
- const config = readConfig();
122
- const token = resolveToken();
123
- if (!token) {
124
- fail("Not authenticated. Set CORELAYER_API_KEY or run: corelayer login");
126
+ return groupId;
127
+ }
128
+ async function runGetIssue(client, commandArgs, ctx) {
129
+ const issueIdentifier = commandArgs[0];
130
+ if (!issueIdentifier) {
131
+ fail("Usage: corelayer issues get <issueId|slug>");
125
132
  }
126
- const apiUrl = resolveApiUrl(ctx.apiUrlOverride);
127
- const client = new CorelayerClient(apiUrl, token);
128
- const commandArgs = removeFlags(args.slice(1));
129
- if (sub === "list") {
130
- const groupId = readFlag(args, "--group") || config.defaults?.group;
131
- if (!groupId) {
132
- fail("groupId is required. Use --group <groupId> or set default-group via corelayer config set default-group <groupId>");
133
- }
134
- const result = await client.listIssues(groupId, {
135
- status: readFlag(args, "--status"),
136
- severity: readFlag(args, "--severity"),
137
- limit: readFlag(args, "--limit"),
138
- page: readFlag(args, "--page"),
139
- });
140
- if (ctx.json) {
141
- printJson(result);
142
- return;
143
- }
144
- const rows = result.issues.map((issue) => [
145
- issue.id,
146
- issue.slug || "-",
147
- issue.severity || "-",
148
- issue.status || "-",
149
- issue.title || "-",
150
- formatDate(issue.last_seen_at || issue.lastSeenAt),
151
- ]);
152
- printTable(["ID", "SLUG", "SEVERITY", "STATUS", "TITLE", "LAST SEEN"], rows);
153
- if (!ctx.quiet) {
154
- process.stdout.write(`Showing ${result.issues.length} of ${result.total} issues\n`);
155
- }
133
+ const issue = await client.getIssue(issueIdentifier);
134
+ if (ctx.json) {
135
+ printJson(issue);
156
136
  return;
157
137
  }
158
- if (sub === "get") {
159
- const issueIdentifier = commandArgs[0];
160
- if (!issueIdentifier) {
161
- fail("Usage: corelayer issues get <issueId|slug>");
162
- }
163
- const issue = await client.getIssue(issueIdentifier);
164
- if (ctx.json) {
165
- printJson(issue);
166
- return;
167
- }
168
- process.stdout.write(`Issue ${issue.slug || issue.id}: ${issue.title || ""}\n`);
169
- process.stdout.write(`Status: ${issue.status || "-"}\n`);
170
- process.stdout.write(`Severity: ${issue.severity || "-"}\n`);
171
- process.stdout.write(`Created: ${formatDate(issue.createdAt || issue.created_at)}\n`);
172
- process.stdout.write(`Last seen: ${formatDate(issue.lastSeenAt || issue.last_seen_at)}\n`);
173
- process.stdout.write(`Events: ${issue.event_count || 0}\n`);
174
- if (issue.whatHappened) {
175
- process.stdout.write(`\nWhat happened:\n${issue.whatHappened}\n`);
176
- }
177
- if (issue.rootCause) {
178
- process.stdout.write(`\nRoot cause:\n${issue.rootCause}\n`);
179
- }
180
- if (issue.nextSteps) {
181
- process.stdout.write(`\nNext steps:\n${issue.nextSteps}\n`);
182
- }
138
+ process.stdout.write(`Issue ${issue.slug || issue.id}: ${issue.title || ""}\n`);
139
+ process.stdout.write(`Status: ${issue.status || "-"}\n`);
140
+ process.stdout.write(`Severity: ${issue.severity || "-"}\n`);
141
+ process.stdout.write(`Created: ${formatDate(issue.createdAt || issue.created_at)}\n`);
142
+ process.stdout.write(`Last seen: ${formatDate(issue.lastSeenAt || issue.last_seen_at)}\n`);
143
+ process.stdout.write(`Events: ${issue.event_count || 0}\n`);
144
+ if (issue.whatHappened) {
145
+ process.stdout.write(`\nWhat happened:\n${issue.whatHappened}\n`);
146
+ }
147
+ if (issue.rootCause) {
148
+ process.stdout.write(`\nRoot cause:\n${issue.rootCause}\n`);
149
+ }
150
+ if (issue.nextSteps) {
151
+ process.stdout.write(`\nNext steps:\n${issue.nextSteps}\n`);
152
+ }
153
+ }
154
+ function issueRows(issues) {
155
+ return issues.map((issue) => [
156
+ issue.id,
157
+ issue.slug || "-",
158
+ issue.severity || "-",
159
+ issue.status || "-",
160
+ issue.title || "-",
161
+ formatDate(issue.last_seen_at || issue.lastSeenAt),
162
+ ]);
163
+ }
164
+ async function runListIssues({ args, client, config, ctx, }) {
165
+ const groupId = resolveGroupId(args, config);
166
+ const result = await client.listIssues(groupId, {
167
+ status: readFlag(args, "--status"),
168
+ severity: readFlag(args, "--severity"),
169
+ limit: readFlag(args, "--limit"),
170
+ page: readFlag(args, "--page"),
171
+ });
172
+ if (ctx.json) {
173
+ printJson(result);
183
174
  return;
184
175
  }
185
- if (sub === "close") {
186
- const issueIdentifier = commandArgs[0];
187
- if (!issueIdentifier) {
188
- fail('Usage: corelayer issues close <issueId|slug> [--feedback "..."]');
189
- }
190
- const feedback = readFlag(args, "--feedback");
191
- const issue = await client.getIssue(issueIdentifier);
192
- if (!issue.id) {
193
- fail("Could not resolve issue identifier to an issue ID.");
194
- }
195
- await client.closeIssue(issue.id, feedback);
196
- if (!ctx.quiet) {
197
- process.stdout.write(`Issue ${issue.slug || issue.id} closed.\n`);
198
- }
176
+ printTable(["ID", "SLUG", "SEVERITY", "STATUS", "TITLE", "LAST SEEN"], issueRows(result.issues));
177
+ if (!ctx.quiet) {
178
+ process.stdout.write(`Showing ${result.issues.length} of ${result.total} issues\n`);
179
+ }
180
+ }
181
+ async function runCloseIssue({ args, client, commandArgs, ctx, }) {
182
+ const issueIdentifier = commandArgs[0];
183
+ if (!issueIdentifier) {
184
+ fail('Usage: corelayer issues close <issueId|slug> [--feedback "..."]');
185
+ }
186
+ const feedback = readFlag(args, "--feedback");
187
+ const issue = await client.getIssue(issueIdentifier);
188
+ if (!issue.id) {
189
+ fail("Could not resolve issue identifier to an issue ID.");
190
+ }
191
+ await client.closeIssue(issue.id, feedback);
192
+ if (!ctx.quiet) {
193
+ process.stdout.write(`Issue ${issue.slug || issue.id} closed.\n`);
194
+ }
195
+ }
196
+ async function runBulkCloseIssues({ args, client, config, ctx, }) {
197
+ const groupId = resolveGroupId(args, config);
198
+ const lastSeenBefore = readFlag(args, "--last-seen-before");
199
+ const lastSeenBeforeOrOn = readFlag(args, "--last-seen-before-or-on");
200
+ if (!lastSeenBefore && !lastSeenBeforeOrOn) {
201
+ fail('Usage: corelayer issues bulk-close --group <groupId> (--last-seen-before <date|preset> | --last-seen-before-or-on <date|preset>) [--feedback "..."]');
202
+ }
203
+ const feedback = readFlag(args, "--feedback");
204
+ const result = await client.bulkIssueAction({
205
+ action: "close",
206
+ groupId,
207
+ feedback,
208
+ filters: {
209
+ status: readFlag(args, "--status") || "Open",
210
+ severity: readFlag(args, "--severity"),
211
+ lastSeenBefore,
212
+ lastSeenBeforeOrOn,
213
+ },
214
+ });
215
+ if (ctx.json) {
216
+ printJson(result);
199
217
  return;
200
218
  }
201
- if (sub === "bulk-close") {
202
- const groupId = readFlag(args, "--group") || config.defaults?.group;
203
- if (!groupId) {
204
- fail("groupId is required. Use --group <groupId> or set default-group via corelayer config set default-group <groupId>");
205
- }
206
- const lastSeenBefore = readFlag(args, "--last-seen-before");
207
- const lastSeenBeforeOrOn = readFlag(args, "--last-seen-before-or-on");
208
- if (!lastSeenBefore && !lastSeenBeforeOrOn) {
209
- fail('Usage: corelayer issues bulk-close --group <groupId> (--last-seen-before <date|preset> | --last-seen-before-or-on <date|preset>) [--feedback "..."]');
210
- }
211
- const feedback = readFlag(args, "--feedback");
212
- const result = await client.bulkIssueAction({
213
- action: "close",
214
- groupId,
215
- feedback,
216
- filters: {
217
- status: readFlag(args, "--status") || "Open",
218
- severity: readFlag(args, "--severity"),
219
- lastSeenBefore,
220
- lastSeenBeforeOrOn,
221
- },
222
- });
223
- if (ctx.json) {
224
- printJson(result);
219
+ process.stdout.write(`Bulk close complete. Closed ${result.affected} issues`);
220
+ if (result.skipped > 0) {
221
+ process.stdout.write(`, skipped ${result.skipped}`);
222
+ }
223
+ process.stdout.write(".\n");
224
+ }
225
+ async function runReopenIssue({ client, commandArgs, ctx, }) {
226
+ const issueId = commandArgs[0];
227
+ if (!issueId) {
228
+ fail("Usage: corelayer issues reopen <issueId>");
229
+ }
230
+ await client.reopenIssue(issueId);
231
+ if (!ctx.quiet) {
232
+ process.stdout.write(`Issue ${issueId} reopened.\n`);
233
+ }
234
+ }
235
+ async function runDeleteIssue({ args, client, commandArgs, ctx, }) {
236
+ const issueId = commandArgs[0];
237
+ if (!issueId) {
238
+ fail("Usage: corelayer issues delete <issueId> [--yes]");
239
+ }
240
+ const yes = hasFlag(args, "--yes");
241
+ if (!yes) {
242
+ const confirmed = await confirmDelete();
243
+ if (!confirmed) {
244
+ process.stdout.write("Aborted.\n");
225
245
  return;
226
246
  }
227
- process.stdout.write(`Bulk close complete. Closed ${result.affected} issues`);
228
- if (result.skipped > 0) {
229
- process.stdout.write(`, skipped ${result.skipped}`);
230
- }
231
- process.stdout.write(".\n");
232
- return;
233
247
  }
234
- if (sub === "reopen") {
235
- const issueId = commandArgs[0];
236
- if (!issueId) {
237
- fail("Usage: corelayer issues reopen <issueId>");
238
- }
239
- await client.reopenIssue(issueId);
240
- if (!ctx.quiet) {
241
- process.stdout.write(`Issue ${issueId} reopened.\n`);
242
- }
243
- return;
248
+ await client.deleteIssue(issueId);
249
+ if (!ctx.quiet) {
250
+ process.stdout.write(`Issue ${issueId} deleted.\n`);
244
251
  }
245
- if (sub === "delete") {
246
- const issueId = commandArgs[0];
247
- if (!issueId) {
248
- fail("Usage: corelayer issues delete <issueId> [--yes]");
249
- }
250
- const yes = hasFlag(args, "--yes");
251
- if (!yes) {
252
- const confirmed = await confirmDelete();
253
- if (!confirmed) {
254
- process.stdout.write("Aborted.\n");
255
- return;
256
- }
257
- }
258
- await client.deleteIssue(issueId);
259
- if (!ctx.quiet) {
260
- process.stdout.write(`Issue ${issueId} deleted.\n`);
261
- }
252
+ }
253
+ async function runIssueSummary({ args, client, config, }) {
254
+ const groupId = resolveGroupId(args, config);
255
+ const summary = await client.getIssueSummary(groupId);
256
+ printJson(summary);
257
+ }
258
+ function resolveDiffSince(args) {
259
+ const sinceFlag = readFlag(args, "--since");
260
+ const durationFlag = readFlag(args, "--duration");
261
+ if (!sinceFlag && !durationFlag) {
262
+ fail("One of --since <timestamp|preset> or --duration <duration> is required.\n" +
263
+ "Examples: --since 2026-06-01T00:00:00Z --since 24hours --duration 2hours");
264
+ }
265
+ if (!durationFlag) {
266
+ return sinceFlag;
267
+ }
268
+ const ms = parseDuration(durationFlag);
269
+ if (ms === null) {
270
+ fail(`Invalid --duration value: "${durationFlag}". Examples: 30m, 2hours, 4h, 1d, 7days`);
271
+ }
272
+ return new Date(Date.now() - ms).toISOString();
273
+ }
274
+ async function runDiffIssues({ args, client, config, ctx, }) {
275
+ const groupId = resolveGroupId(args, config);
276
+ const result = await client.diffIssues(groupId, resolveDiffSince(args), {
277
+ limit: readFlag(args, "--limit"),
278
+ page: readFlag(args, "--page"),
279
+ });
280
+ if (ctx.json) {
281
+ printJson(result);
262
282
  return;
263
283
  }
264
- if (sub === "summary") {
265
- const groupId = readFlag(args, "--group") || config.defaults?.group;
266
- if (!groupId) {
267
- fail("groupId is required. Use --group <groupId> or set default-group via corelayer config set default-group <groupId>");
268
- }
269
- const summary = await client.getIssueSummary(groupId);
270
- printJson(summary);
284
+ process.stdout.write(`DIFF SINCE ${result.since}\n`);
285
+ printTable(["ID", "SLUG", "SEVERITY", "STATUS", "TITLE", "LAST SEEN"], issueRows(result.issues));
286
+ if (!ctx.quiet) {
287
+ process.stdout.write(`${result.total} issue${result.total === 1 ? "" : "s"} changed since ${result.since}\n`);
288
+ }
289
+ }
290
+ const ISSUE_COMMAND_HANDLERS = {
291
+ "bulk-close": runBulkCloseIssues,
292
+ close: runCloseIssue,
293
+ delete: runDeleteIssue,
294
+ diff: runDiffIssues,
295
+ get: ({ client, commandArgs, ctx }) => runGetIssue(client, commandArgs, ctx),
296
+ list: runListIssues,
297
+ reopen: runReopenIssue,
298
+ summary: runIssueSummary,
299
+ };
300
+ export async function runIssues(args, ctx) {
301
+ if (args.includes("--help") || args.includes("-h")) {
302
+ printHelp();
271
303
  return;
272
304
  }
273
- if (sub === "diff") {
274
- const groupId = readFlag(args, "--group") || config.defaults?.group;
275
- if (!groupId) {
276
- fail("groupId is required. Use --group <groupId> or set default-group via corelayer config set default-group <groupId>");
277
- }
278
- const sinceFlag = readFlag(args, "--since");
279
- const durationFlag = readFlag(args, "--duration");
280
- if (!sinceFlag && !durationFlag) {
281
- fail("One of --since <timestamp|preset> or --duration <duration> is required.\n" +
282
- "Examples: --since 2026-06-01T00:00:00Z --since 24hours --duration 2hours");
283
- }
284
- let sinceValue;
285
- if (durationFlag) {
286
- const ms = parseDuration(durationFlag);
287
- if (ms === null) {
288
- fail(`Invalid --duration value: "${durationFlag}". Examples: 30m, 2hours, 4h, 1d, 7days`);
289
- }
290
- sinceValue = new Date(Date.now() - ms).toISOString();
291
- }
292
- else {
293
- sinceValue = sinceFlag;
294
- }
295
- const result = await client.diffIssues(groupId, sinceValue, {
296
- limit: readFlag(args, "--limit"),
297
- page: readFlag(args, "--page"),
298
- });
299
- if (ctx.json) {
300
- printJson(result);
301
- return;
302
- }
303
- process.stdout.write(`DIFF SINCE ${result.since}\n`);
304
- const rows = result.issues.map((issue) => [
305
- issue.id,
306
- issue.slug || "-",
307
- issue.severity || "-",
308
- issue.status || "-",
309
- issue.title || "-",
310
- formatDate(issue.last_seen_at || issue.lastSeenAt),
311
- ]);
312
- printTable(["ID", "SLUG", "SEVERITY", "STATUS", "TITLE", "LAST SEEN"], rows);
313
- if (!ctx.quiet) {
314
- process.stdout.write(`${result.total} issue${result.total === 1 ? "" : "s"} changed since ${result.since}\n`);
315
- }
305
+ const sub = args[0];
306
+ const config = readConfig();
307
+ const token = resolveToken();
308
+ if (!token) {
309
+ fail("Not authenticated. Set CORELAYER_API_KEY or run: corelayer login");
310
+ }
311
+ const apiUrl = resolveApiUrl(ctx.apiUrlOverride);
312
+ const client = new CorelayerClient(apiUrl, token);
313
+ if (sub === "ticket") {
314
+ await runIssueTickets(args.slice(1), client, config, ctx);
316
315
  return;
317
316
  }
318
- fail("Usage: corelayer issues <list|get|close|bulk-close|reopen|delete|summary|diff> [options]");
317
+ const commandArgs = removeFlags(args.slice(1));
318
+ const handler = ISSUE_COMMAND_HANDLERS[sub];
319
+ if (!handler) {
320
+ fail("Usage: corelayer issues <list|get|close|bulk-close|reopen|delete|summary|diff> [options]");
321
+ }
322
+ await handler({ args, client, commandArgs, config, ctx });
319
323
  }
@@ -1,12 +1,7 @@
1
+ import { readFlag } from "../lib/args.js";
1
2
  import { loginWithBrowser, loginWithCodeDirect } from "../lib/auth.js";
2
3
  import { updateConfig } from "../lib/config.js";
3
4
  import { fail } from "../lib/output.js";
4
- function readFlag(args, flag) {
5
- const index = args.indexOf(flag);
6
- if (index === -1)
7
- return undefined;
8
- return args[index + 1];
9
- }
10
5
  function hasFlag(args, flag) {
11
6
  return args.includes(flag);
12
7
  }
@@ -1,11 +1,14 @@
1
- import { clearToken } from "../lib/config.js";
1
+ import { CorelayerClient } from "../lib/api-client.js";
2
+ import { clearToken, readConfig } from "../lib/config.js";
2
3
  function printHelp() {
3
- process.stdout.write(`corelayer logout - Sign out and clear local credentials
4
+ process.stdout
5
+ .write(`corelayer logout - Revoke the saved credential and clear it locally
4
6
 
5
7
  USAGE:
6
8
  corelayer logout
7
9
 
8
10
  OPTIONS:
11
+ --local-only Clear the saved credential without server revocation
9
12
  -h, --help Print help
10
13
  `);
11
14
  }
@@ -14,8 +17,23 @@ export async function runLogout(args, ctx) {
14
17
  printHelp();
15
18
  return;
16
19
  }
20
+ const config = readConfig();
21
+ if (config.token && !args.includes("--local-only")) {
22
+ try {
23
+ await new CorelayerClient(config.apiUrl, config.token).logout();
24
+ }
25
+ catch (error) {
26
+ const message = error instanceof Error ? error.message : "Request failed";
27
+ throw new Error(`Could not revoke the saved credential: ${message}. Local credentials were kept. Retry, or use --local-only to clear them without revocation.`);
28
+ }
29
+ }
17
30
  clearToken();
31
+ if (process.env.CORELAYER_API_KEY) {
32
+ process.stderr.write("CORELAYER_API_KEY is still set. Unset it to stop using that credential.\n");
33
+ }
18
34
  if (!ctx.quiet) {
19
- process.stdout.write("Logged out.\n");
35
+ process.stdout.write(args.includes("--local-only")
36
+ ? "Local credentials cleared; the server credential was not revoked.\n"
37
+ : "Logged out.\n");
20
38
  }
21
39
  }
@@ -1,10 +1,12 @@
1
- import { execSync } from "node:child_process";
1
+ import { execFileSync } from "node:child_process";
2
2
  import { CorelayerClient } from "../lib/api-client.js";
3
- import { resolveToken, resolveApiUrl } from "../lib/config.js";
3
+ import { readFlag } from "../lib/args.js";
4
+ import { resolveApiUrl, resolveToken } from "../lib/config.js";
4
5
  import { fail, printJson } from "../lib/output.js";
5
6
  const MAX_DIFF_CHARS = 12_000;
6
7
  function printHelp() {
7
- process.stdout.write(`corelayer preflight - Check a change against org memory before you push
8
+ process.stdout
9
+ .write(`corelayer preflight - Check a change against org memory before you push
8
10
 
9
11
  Surfaces failure modes and things to double-check that your org has hit before for
10
12
  changes like this one. Run inside a git repo with no arguments and it derives the PR
@@ -28,15 +30,9 @@ EXAMPLES:
28
30
  corelayer preflight --description "Add Redis cache to the payments worker" --repo payments-api
29
31
  `);
30
32
  }
31
- function readFlag(args, flag) {
32
- const index = args.indexOf(flag);
33
- if (index === -1)
34
- return undefined;
35
- return args[index + 1];
36
- }
37
33
  function git(args) {
38
34
  try {
39
- return execSync(`git ${args}`, {
35
+ return execFileSync("git", args, {
40
36
  encoding: "utf8",
41
37
  stdio: ["ignore", "pipe", "ignore"],
42
38
  }).trim();
@@ -46,18 +42,23 @@ function git(args) {
46
42
  }
47
43
  }
48
44
  function detectRepo() {
49
- const url = git("config --get remote.origin.url");
45
+ const url = git(["config", "--get", "remote.origin.url"]);
50
46
  if (!url)
51
47
  return undefined;
52
48
  const match = url.replace(/\.git$/, "").match(/([^/:]+)$/);
53
49
  return match ? match[1] : undefined;
54
50
  }
55
51
  function detectBase() {
56
- const head = git("rev-parse --abbrev-ref origin/HEAD"); // e.g. origin/main
52
+ const head = git(["rev-parse", "--abbrev-ref", "origin/HEAD"]);
57
53
  if (head && head.startsWith("origin/"))
58
- return head.slice("origin/".length);
54
+ return head;
59
55
  for (const candidate of ["main", "master"]) {
60
- if (git(`rev-parse --verify ${candidate}`))
56
+ if (git([
57
+ "rev-parse",
58
+ "--verify",
59
+ "--end-of-options",
60
+ `${candidate}^{commit}`,
61
+ ]))
61
62
  return candidate;
62
63
  }
63
64
  return "main";
@@ -81,15 +82,28 @@ function printChecks(checks, noColor) {
81
82
  process.stdout.write(` ${severityColor(check.severity, noColor)} · "${check.title}" · ${where}${seen}\n`);
82
83
  });
83
84
  }
84
- export async function runPreflight(args, ctx) {
85
- if (args.includes("--help") || args.includes("-h")) {
86
- printHelp();
87
- return;
88
- }
85
+ function buildPreflightContext(args) {
89
86
  const base = readFlag(args, "--base") || detectBase();
90
- const commits = git(`log ${base}..HEAD --pretty=format:%s%n%b`) || "";
91
- const files = git(`diff --name-only ${base}...HEAD`) || "";
92
- const branch = git("rev-parse --abbrev-ref HEAD");
87
+ const baseCommit = git([
88
+ "rev-parse",
89
+ "--verify",
90
+ "--end-of-options",
91
+ `${base}^{commit}`,
92
+ ]);
93
+ const commits = baseCommit
94
+ ? git(["log", "--pretty=format:%s%n%b", `${baseCommit}..HEAD`, "--"]) || ""
95
+ : "";
96
+ const files = baseCommit
97
+ ? git([
98
+ "diff",
99
+ "--no-ext-diff",
100
+ "--no-textconv",
101
+ "--name-only",
102
+ `${baseCommit}...HEAD`,
103
+ "--",
104
+ ]) || ""
105
+ : "";
106
+ const branch = git(["rev-parse", "--abbrev-ref", "HEAD"]);
93
107
  const repo = readFlag(args, "--repo") || detectRepo();
94
108
  const title = readFlag(args, "--title") || commits.split("\n")[0]?.trim() || branch;
95
109
  let description = readFlag(args, "--description");
@@ -101,11 +115,41 @@ export async function runPreflight(args, ctx) {
101
115
  parts.push(`Changed files:\n${files.trim()}`);
102
116
  description = parts.join("\n\n");
103
117
  }
104
- if (args.includes("--diff")) {
105
- const diff = git(`diff ${base}...HEAD`);
118
+ if (args.includes("--diff") && baseCommit) {
119
+ const diff = git([
120
+ "diff",
121
+ "--no-ext-diff",
122
+ "--no-textconv",
123
+ `${baseCommit}...HEAD`,
124
+ "--",
125
+ ]);
106
126
  if (diff)
107
127
  description += `\n\nDiff:\n${diff.slice(0, MAX_DIFF_CHARS)}`;
108
128
  }
129
+ return { base, description, repo, title };
130
+ }
131
+ function printPreflightResult(result, ctx) {
132
+ if (result.degraded) {
133
+ process.stderr.write(`⚠ Preflight could not run (${result.reason ?? "unknown reason"}). This is not an all-clear.\n`);
134
+ return;
135
+ }
136
+ if (result.checks.length === 0) {
137
+ if (!ctx.quiet) {
138
+ process.stdout.write("✓ Preflight: nothing in org memory to double-check for this change.\n");
139
+ }
140
+ return;
141
+ }
142
+ if (!ctx.quiet) {
143
+ process.stdout.write(`Preflight — ${result.checks.length} thing(s) to double-check before you push:\n\n`);
144
+ }
145
+ printChecks(result.checks, ctx.noColor);
146
+ }
147
+ export async function runPreflight(args, ctx) {
148
+ if (args.includes("--help") || args.includes("-h")) {
149
+ printHelp();
150
+ return;
151
+ }
152
+ const { base, description, repo, title } = buildPreflightContext(args);
109
153
  if (!description.trim()) {
110
154
  fail(`No PR context found. Run inside a git repo with commits ahead of "${base}", or pass --description.`);
111
155
  }
@@ -124,14 +168,5 @@ export async function runPreflight(args, ctx) {
124
168
  printJson(result);
125
169
  return;
126
170
  }
127
- if (result.checks.length === 0) {
128
- if (!ctx.quiet) {
129
- process.stdout.write("✓ Preflight: nothing in org memory to double-check for this change.\n");
130
- }
131
- return;
132
- }
133
- if (!ctx.quiet) {
134
- process.stdout.write(`Preflight — ${result.checks.length} thing(s) to double-check before you push:\n\n`);
135
- }
136
- printChecks(result.checks, ctx.noColor);
171
+ printPreflightResult(result, ctx);
137
172
  }