@gh-symphony/cli 0.0.16 → 0.0.18

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.
@@ -0,0 +1,362 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/github/client.ts
4
+ var DEFAULT_API_URL = "https://api.github.com/graphql";
5
+ var REST_API_URL = "https://api.github.com";
6
+ var GitHubApiError = class extends Error {
7
+ constructor(message, status) {
8
+ super(message);
9
+ this.status = status;
10
+ this.name = "GitHubApiError";
11
+ }
12
+ };
13
+ var GitHubScopeError = class extends GitHubApiError {
14
+ constructor(message, requiredScopes, currentScopes) {
15
+ super(message);
16
+ this.requiredScopes = requiredScopes;
17
+ this.currentScopes = currentScopes;
18
+ this.name = "GitHubScopeError";
19
+ }
20
+ };
21
+ function createClient(token, options) {
22
+ return {
23
+ token,
24
+ apiUrl: options?.apiUrl ?? DEFAULT_API_URL,
25
+ fetchImpl: options?.fetchImpl ?? fetch
26
+ };
27
+ }
28
+ async function validateToken(client) {
29
+ const restUrl = client.apiUrl.replace("/graphql", "");
30
+ const baseUrl = restUrl === client.apiUrl ? REST_API_URL : restUrl;
31
+ const response = await client.fetchImpl(`${baseUrl}/user`, {
32
+ headers: {
33
+ authorization: `Bearer ${client.token}`,
34
+ accept: "application/vnd.github+json"
35
+ }
36
+ });
37
+ if (!response.ok) {
38
+ if (response.status === 401) {
39
+ throw new GitHubApiError("Invalid token: authentication failed.", 401);
40
+ }
41
+ throw new GitHubApiError(
42
+ `GitHub API error: ${response.status} ${response.statusText}`,
43
+ response.status
44
+ );
45
+ }
46
+ const scopes = response.headers.get("x-oauth-scopes")?.split(",").map((s) => s.trim()).filter(Boolean) ?? [];
47
+ const user = await response.json();
48
+ return {
49
+ login: user.login,
50
+ name: user.name,
51
+ scopes
52
+ };
53
+ }
54
+ function checkRequiredScopes(scopes) {
55
+ const required = ["repo", "read:org", "project"];
56
+ const normalizedScopes = scopes.map((s) => s.toLowerCase());
57
+ const missing = required.filter((r) => !normalizedScopes.includes(r));
58
+ return { valid: missing.length === 0, missing };
59
+ }
60
+ async function listUserProjects(client) {
61
+ const data = await graphql(
62
+ client,
63
+ VIEWER_PROJECTS_QUERY
64
+ );
65
+ const projects = [];
66
+ for (const node of data.viewer.projectsV2?.nodes ?? []) {
67
+ if (!node) continue;
68
+ projects.push(
69
+ normalizeProjectSummary(node, {
70
+ login: data.viewer.login,
71
+ type: "User"
72
+ })
73
+ );
74
+ }
75
+ for (const orgNode of data.viewer.organizations?.nodes ?? []) {
76
+ if (!orgNode) continue;
77
+ for (const projNode of orgNode.projectsV2?.nodes ?? []) {
78
+ if (!projNode) continue;
79
+ projects.push(
80
+ normalizeProjectSummary(projNode, {
81
+ login: orgNode.login,
82
+ type: "Organization"
83
+ })
84
+ );
85
+ }
86
+ }
87
+ return projects;
88
+ }
89
+ function normalizeProjectSummary(node, owner) {
90
+ return {
91
+ id: node.id,
92
+ title: node.title,
93
+ shortDescription: node.shortDescription ?? "",
94
+ url: node.url,
95
+ openItemCount: node.items?.totalCount ?? 0,
96
+ owner
97
+ };
98
+ }
99
+ async function getProjectDetail(client, projectId) {
100
+ const data = await graphql(
101
+ client,
102
+ PROJECT_DETAIL_QUERY,
103
+ { projectId }
104
+ );
105
+ const project = data.node;
106
+ if (!project || project.__typename !== "ProjectV2") {
107
+ throw new GitHubApiError(`Project not found: ${projectId}`);
108
+ }
109
+ const statusFields = [];
110
+ const textFields = [];
111
+ for (const field of project.fields?.nodes ?? []) {
112
+ if (!field) continue;
113
+ if (field.__typename === "ProjectV2SingleSelectField") {
114
+ statusFields.push({
115
+ id: field.id,
116
+ name: field.name,
117
+ options: (field.options ?? []).map((opt) => ({
118
+ id: opt.id,
119
+ name: opt.name,
120
+ description: opt.description ?? null,
121
+ color: opt.color ?? null
122
+ }))
123
+ });
124
+ } else if (field.__typename === "ProjectV2Field" && field.dataType) {
125
+ textFields.push({
126
+ id: field.id,
127
+ name: field.name,
128
+ dataType: field.dataType
129
+ });
130
+ }
131
+ }
132
+ const repoMap = /* @__PURE__ */ new Map();
133
+ let cursor = null;
134
+ let hasMore = true;
135
+ for (const item of project.items?.nodes ?? []) {
136
+ const repo = item?.content?.repository;
137
+ if (!repo) continue;
138
+ const key = `${repo.owner.login}/${repo.name}`;
139
+ if (!repoMap.has(key)) {
140
+ repoMap.set(key, {
141
+ owner: repo.owner.login,
142
+ name: repo.name,
143
+ url: repo.url,
144
+ cloneUrl: repo.url.endsWith(".git") ? repo.url : `${repo.url}.git`
145
+ });
146
+ }
147
+ }
148
+ hasMore = project.items?.pageInfo?.hasNextPage ?? false;
149
+ cursor = project.items?.pageInfo?.endCursor ?? null;
150
+ while (hasMore && cursor) {
151
+ const pageData = await graphql(
152
+ client,
153
+ PROJECT_ITEMS_PAGE_QUERY,
154
+ { projectId, cursor }
155
+ );
156
+ const items = pageData.node?.items;
157
+ if (!items) break;
158
+ for (const item of items.nodes ?? []) {
159
+ const repo = item?.content?.repository;
160
+ if (!repo) continue;
161
+ const key = `${repo.owner.login}/${repo.name}`;
162
+ if (!repoMap.has(key)) {
163
+ repoMap.set(key, {
164
+ owner: repo.owner.login,
165
+ name: repo.name,
166
+ url: repo.url,
167
+ cloneUrl: repo.url.endsWith(".git") ? repo.url : `${repo.url}.git`
168
+ });
169
+ }
170
+ }
171
+ hasMore = items.pageInfo?.hasNextPage ?? false;
172
+ cursor = items.pageInfo?.endCursor ?? null;
173
+ }
174
+ return {
175
+ id: project.id,
176
+ title: project.title,
177
+ url: project.url,
178
+ statusFields,
179
+ textFields,
180
+ linkedRepositories: [...repoMap.values()]
181
+ };
182
+ }
183
+ async function graphql(client, query, variables) {
184
+ const response = await client.fetchImpl(client.apiUrl, {
185
+ method: "POST",
186
+ headers: {
187
+ "content-type": "application/json",
188
+ authorization: `Bearer ${client.token}`
189
+ },
190
+ body: JSON.stringify({ query, variables })
191
+ });
192
+ if (!response.ok) {
193
+ const text = await response.text().catch(() => "");
194
+ throw new GitHubApiError(
195
+ `GitHub GraphQL request failed: ${response.status} ${response.statusText}. ${text}`,
196
+ response.status
197
+ );
198
+ }
199
+ const payload = await response.json();
200
+ if (payload.errors?.length) {
201
+ const scopeMessages = payload.errors.map((e) => e.message).filter((m) => m.includes("has not been granted the required scopes"));
202
+ if (scopeMessages.length > 0) {
203
+ const requiredScopes = /* @__PURE__ */ new Set();
204
+ let currentScopes = [];
205
+ for (const msg of scopeMessages) {
206
+ for (const match of msg.matchAll(
207
+ /requires one of the following scopes: \['([^']+)'\]/g
208
+ )) {
209
+ requiredScopes.add(match[1]);
210
+ }
211
+ if (currentScopes.length === 0) {
212
+ const currMatch = /has only been granted the: \[([^\]]+)\]/.exec(msg);
213
+ if (currMatch) {
214
+ currentScopes = currMatch[1].split(",").map((s) => s.trim().replace(/'/g, "")).filter(Boolean);
215
+ }
216
+ }
217
+ }
218
+ throw new GitHubScopeError(
219
+ "Token is missing required GitHub scopes.",
220
+ [...requiredScopes],
221
+ currentScopes
222
+ );
223
+ }
224
+ throw new GitHubApiError(
225
+ `GraphQL errors: ${payload.errors.map((e) => e.message).join("; ")}`
226
+ );
227
+ }
228
+ if (!payload.data) {
229
+ throw new GitHubApiError("GraphQL response missing data.");
230
+ }
231
+ return payload.data;
232
+ }
233
+ var VIEWER_PROJECTS_QUERY = `
234
+ query ViewerProjects {
235
+ viewer {
236
+ login
237
+ projectsV2(first: 50) {
238
+ nodes {
239
+ id
240
+ title
241
+ shortDescription
242
+ url
243
+ items { totalCount }
244
+ }
245
+ }
246
+ organizations(first: 20) {
247
+ nodes {
248
+ login
249
+ projectsV2(first: 50) {
250
+ nodes {
251
+ id
252
+ title
253
+ shortDescription
254
+ url
255
+ items { totalCount }
256
+ }
257
+ }
258
+ }
259
+ }
260
+ }
261
+ }
262
+ `;
263
+ var PROJECT_DETAIL_QUERY = `
264
+ query ProjectDetail($projectId: ID!) {
265
+ node(id: $projectId) {
266
+ __typename
267
+ ... on ProjectV2 {
268
+ id
269
+ title
270
+ url
271
+ fields(first: 50) {
272
+ nodes {
273
+ __typename
274
+ ... on ProjectV2SingleSelectField {
275
+ id
276
+ name
277
+ options {
278
+ id
279
+ name
280
+ description
281
+ color
282
+ }
283
+ }
284
+ ... on ProjectV2Field {
285
+ id
286
+ name
287
+ dataType
288
+ }
289
+ }
290
+ }
291
+ items(first: 100) {
292
+ nodes {
293
+ content {
294
+ __typename
295
+ ... on Issue {
296
+ repository {
297
+ name
298
+ url
299
+ owner { login }
300
+ }
301
+ }
302
+ ... on PullRequest {
303
+ repository {
304
+ name
305
+ url
306
+ owner { login }
307
+ }
308
+ }
309
+ }
310
+ }
311
+ pageInfo {
312
+ endCursor
313
+ hasNextPage
314
+ }
315
+ }
316
+ }
317
+ }
318
+ }
319
+ `;
320
+ var PROJECT_ITEMS_PAGE_QUERY = `
321
+ query ProjectItemsPage($projectId: ID!, $cursor: String) {
322
+ node(id: $projectId) {
323
+ ... on ProjectV2 {
324
+ items(first: 100, after: $cursor) {
325
+ nodes {
326
+ content {
327
+ __typename
328
+ ... on Issue {
329
+ repository {
330
+ name
331
+ url
332
+ owner { login }
333
+ }
334
+ }
335
+ ... on PullRequest {
336
+ repository {
337
+ name
338
+ url
339
+ owner { login }
340
+ }
341
+ }
342
+ }
343
+ }
344
+ pageInfo {
345
+ endCursor
346
+ hasNextPage
347
+ }
348
+ }
349
+ }
350
+ }
351
+ }
352
+ `;
353
+
354
+ export {
355
+ GitHubApiError,
356
+ GitHubScopeError,
357
+ createClient,
358
+ validateToken,
359
+ checkRequiredScopes,
360
+ listUserProjects,
361
+ getProjectDetail
362
+ };
@@ -2,7 +2,7 @@
2
2
 
3
3
  // src/github/gh-auth.ts
4
4
  import { execFileSync, spawnSync } from "child_process";
5
- var REQUIRED_SCOPES = ["repo", "read:org", "project"];
5
+ var REQUIRED_GH_SCOPES = ["repo", "read:org", "project"];
6
6
  var GhAuthError = class extends Error {
7
7
  constructor(code, message) {
8
8
  super(message);
@@ -47,7 +47,7 @@ function checkGhScopes(opts) {
47
47
  return { valid: true, missing: [], scopes: [] };
48
48
  }
49
49
  const normalized = scopes.map((scope) => scope.toLowerCase());
50
- const missing = REQUIRED_SCOPES.filter(
50
+ const missing = REQUIRED_GH_SCOPES.filter(
51
51
  (scope) => !normalized.includes(scope)
52
52
  );
53
53
  return {
@@ -124,7 +124,11 @@ function parseScopes(output) {
124
124
  }
125
125
 
126
126
  export {
127
+ REQUIRED_GH_SCOPES,
127
128
  GhAuthError,
129
+ checkGhInstalled,
130
+ checkGhAuthenticated,
131
+ checkGhScopes,
128
132
  getGhToken,
129
133
  ensureGhAuth
130
134
  };
@@ -0,0 +1,146 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ loadGlobalConfig,
4
+ loadProjectConfig
5
+ } from "./chunk-ROGRTUFI.js";
6
+
7
+ // src/project-selection.ts
8
+ import * as p from "@clack/prompts";
9
+ function isInteractiveTerminal() {
10
+ return process.stdin.isTTY === true && process.stdout.isTTY === true;
11
+ }
12
+ function explicitProjectRequiredMessage() {
13
+ return "Multiple projects are configured. Re-run with --project-id in non-interactive environments.\n";
14
+ }
15
+ async function inspectManagedProjectSelection(input) {
16
+ if (input.requestedProjectId) {
17
+ const projectConfig = await loadProjectConfig(
18
+ input.configDir,
19
+ input.requestedProjectId
20
+ );
21
+ if (!projectConfig) {
22
+ return {
23
+ kind: "requested_project_missing",
24
+ projectId: input.requestedProjectId,
25
+ message: `Project "${input.requestedProjectId}" is not configured. Run 'gh-symphony project add' or choose an existing project.`
26
+ };
27
+ }
28
+ return {
29
+ kind: "resolved",
30
+ projectId: input.requestedProjectId,
31
+ projectConfig
32
+ };
33
+ }
34
+ const global = await loadGlobalConfig(input.configDir);
35
+ if (!global) {
36
+ return {
37
+ kind: "missing_global_config",
38
+ message: "No CLI configuration found. Run 'gh-symphony project add' first."
39
+ };
40
+ }
41
+ const projectIds = global.projects ?? [];
42
+ if (projectIds.length === 0) {
43
+ return {
44
+ kind: "no_projects",
45
+ message: "No managed projects are configured. Run 'gh-symphony project add' first."
46
+ };
47
+ }
48
+ if (projectIds.length > 1 && !isInteractiveTerminal()) {
49
+ return {
50
+ kind: "multiple_projects_require_selection",
51
+ message: explicitProjectRequiredMessage().trimEnd()
52
+ };
53
+ }
54
+ if (global.activeProject) {
55
+ const projectConfig = await loadProjectConfig(
56
+ input.configDir,
57
+ global.activeProject
58
+ );
59
+ if (!projectConfig) {
60
+ return {
61
+ kind: "active_project_missing",
62
+ projectId: global.activeProject,
63
+ message: `Active project "${global.activeProject}" is configured in config.json but its project config is missing. Re-run 'gh-symphony project add' or 'gh-symphony project switch'.`
64
+ };
65
+ }
66
+ return {
67
+ kind: "resolved",
68
+ projectId: global.activeProject,
69
+ projectConfig
70
+ };
71
+ }
72
+ if (projectIds.length === 1) {
73
+ const projectId = projectIds[0];
74
+ const projectConfig = await loadProjectConfig(input.configDir, projectId);
75
+ if (!projectConfig) {
76
+ return {
77
+ kind: "configured_project_missing",
78
+ projectId,
79
+ message: `Configured project "${projectId}" is missing its project config file. Re-run 'gh-symphony project add'.`
80
+ };
81
+ }
82
+ return {
83
+ kind: "resolved",
84
+ projectId,
85
+ projectConfig
86
+ };
87
+ }
88
+ return {
89
+ kind: "multiple_projects_require_selection",
90
+ message: "Multiple projects are configured and no active project is set. Run 'gh-symphony project switch' or re-run with --project-id."
91
+ };
92
+ }
93
+ async function resolveManagedProjectConfig(input) {
94
+ if (input.requestedProjectId) {
95
+ return loadProjectConfig(input.configDir, input.requestedProjectId);
96
+ }
97
+ const global = await loadGlobalConfig(input.configDir);
98
+ const projectIds = global?.projects ?? [];
99
+ if (projectIds.length === 0) {
100
+ return null;
101
+ }
102
+ if (projectIds.length === 1) {
103
+ return loadProjectConfig(input.configDir, projectIds[0]);
104
+ }
105
+ if (!isInteractiveTerminal()) {
106
+ process.stderr.write(explicitProjectRequiredMessage());
107
+ process.exitCode = 1;
108
+ return null;
109
+ }
110
+ const projects = await Promise.all(
111
+ projectIds.map(async (projectId) => ({
112
+ projectId,
113
+ config: await loadProjectConfig(input.configDir, projectId)
114
+ }))
115
+ );
116
+ const selected = await p.select({
117
+ message: "Select a project:",
118
+ options: projects.map(({ projectId, config }) => ({
119
+ value: projectId,
120
+ label: config?.displayName ?? config?.slug ?? projectId,
121
+ hint: projectId === global?.activeProject ? "current" : config && config.displayName && config.displayName !== projectId ? projectId : void 0
122
+ })),
123
+ maxItems: 10
124
+ });
125
+ if (p.isCancel(selected)) {
126
+ p.cancel("Cancelled.");
127
+ process.exitCode = 130;
128
+ return null;
129
+ }
130
+ return loadProjectConfig(input.configDir, selected);
131
+ }
132
+ function handleMissingManagedProjectConfig() {
133
+ if (process.exitCode) {
134
+ return;
135
+ }
136
+ process.stderr.write(
137
+ "No project configured. Run 'gh-symphony project add' first.\n"
138
+ );
139
+ process.exitCode = 1;
140
+ }
141
+
142
+ export {
143
+ inspectManagedProjectSelection,
144
+ resolveManagedProjectConfig,
145
+ handleMissingManagedProjectConfig
146
+ };