@topogram/cli 0.3.63 → 0.3.64
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/package.json +1 -1
- package/src/adoption/plan.d.ts +6 -0
- package/src/adoption/reporting.d.ts +10 -0
- package/src/adoption/review-groups.d.ts +6 -0
- package/src/agent-brief.d.ts +3 -0
- package/src/agent-brief.js +495 -0
- package/src/agent-ops/query-builders.d.ts +26 -0
- package/src/archive/archive.d.ts +2 -0
- package/src/archive/compact.d.ts +1 -0
- package/src/archive/unarchive.d.ts +1 -0
- package/src/catalog.d.ts +10 -0
- package/src/catalog.js +62 -66
- package/src/cli/catalog-alias.d.ts +1 -0
- package/src/cli/command-parser.js +38 -0
- package/src/cli/command-parsers/core.js +102 -0
- package/src/cli/command-parsers/generator.js +39 -0
- package/src/cli/command-parsers/import.js +44 -0
- package/src/cli/command-parsers/legacy-workflow.js +21 -0
- package/src/cli/command-parsers/project.js +47 -0
- package/src/cli/command-parsers/sdlc.js +47 -0
- package/src/cli/command-parsers/shared.js +51 -0
- package/src/cli/command-parsers/template.js +48 -0
- package/src/cli/commands/agent.js +47 -0
- package/src/cli/commands/catalog.js +617 -0
- package/src/cli/commands/check.js +268 -0
- package/src/cli/commands/doctor.js +268 -0
- package/src/cli/commands/emit.js +149 -0
- package/src/cli/commands/generate.js +96 -0
- package/src/cli/commands/generator-policy.js +785 -0
- package/src/cli/commands/generator.js +443 -0
- package/src/cli/commands/import-runner.js +157 -0
- package/src/cli/commands/import.js +1734 -0
- package/src/cli/commands/inspect.js +55 -0
- package/src/cli/commands/new.js +94 -0
- package/src/cli/commands/package.js +815 -0
- package/src/cli/commands/query.js +1302 -0
- package/src/cli/commands/release-rollout.js +257 -0
- package/src/cli/commands/release-shared.js +528 -0
- package/src/cli/commands/release-status.js +429 -0
- package/src/cli/commands/release.js +107 -0
- package/src/cli/commands/sdlc.js +168 -0
- package/src/cli/commands/setup.js +76 -0
- package/src/cli/commands/source.js +291 -0
- package/src/cli/commands/template-runner.js +198 -0
- package/src/cli/commands/template.js +2145 -0
- package/src/cli/commands/trust.js +219 -0
- package/src/cli/commands/version.js +40 -0
- package/src/cli/commands/widget.js +168 -0
- package/src/cli/commands/workflow.js +63 -0
- package/src/cli/dispatcher.js +392 -0
- package/src/cli/help-dispatch.js +188 -0
- package/src/cli/help.js +296 -0
- package/src/cli/migration-guidance.js +59 -0
- package/src/cli/options.js +96 -0
- package/src/cli/output-safety.js +107 -0
- package/src/cli/path-normalization.js +29 -0
- package/src/cli.js +47 -11711
- package/src/example-implementation.d.ts +2 -0
- package/src/format.d.ts +1 -0
- package/src/generator/check.d.ts +1 -0
- package/src/generator/context/bundle.d.ts +1 -0
- package/src/generator/context/shared.d.ts +2 -0
- package/src/generator/native/parity-bundle.js +2 -1
- package/src/generator/surfaces/web/html-escape.js +22 -0
- package/src/generator/surfaces/web/react.js +10 -8
- package/src/generator/surfaces/web/sveltekit.js +7 -5
- package/src/generator/surfaces/web/vanilla.js +8 -4
- package/src/generator.d.ts +2 -0
- package/src/github-client.js +520 -0
- package/src/import/core/shared.js +20 -62
- package/src/import/extractors/api/flutter-dio.js +4 -8
- package/src/import/extractors/api/react-native-repository.js +4 -8
- package/src/import/index.d.ts +4 -0
- package/src/import/provenance.d.ts +4 -0
- package/src/new-project.js +100 -11
- package/src/npm-safety.js +79 -0
- package/src/parser.d.ts +1 -0
- package/src/path-helpers.d.ts +1 -0
- package/src/path-helpers.js +20 -0
- package/src/project-config.js +1 -0
- package/src/reconcile/docs.d.ts +8 -0
- package/src/reconcile/journeys.d.ts +1 -0
- package/src/resolver.d.ts +1 -0
- package/src/runtime-support.js +29 -0
- package/src/sdlc/adopt.d.ts +1 -0
- package/src/sdlc/check.d.ts +1 -0
- package/src/sdlc/explain.d.ts +1 -0
- package/src/sdlc/release.d.ts +1 -0
- package/src/sdlc/scaffold.d.ts +1 -0
- package/src/sdlc/transition.d.ts +1 -0
- package/src/text-helpers.d.ts +6 -0
- package/src/text-helpers.js +245 -0
- package/src/topogram-config.js +306 -0
- package/src/validator.d.ts +2 -0
- package/src/workflows/adoption/index.js +26 -0
- package/src/workflows/docs-generate.js +262 -0
- package/src/workflows/docs-scan.js +703 -0
- package/src/workflows/docs.js +15 -0
- package/src/workflows/import-app/api.js +799 -0
- package/src/workflows/import-app/db.js +538 -0
- package/src/workflows/import-app/index.js +30 -0
- package/src/workflows/import-app/shared.js +218 -0
- package/src/workflows/import-app/ui.js +443 -0
- package/src/workflows/import-app/workflow.js +159 -0
- package/src/workflows/reconcile/adoption-plan.js +742 -0
- package/src/workflows/reconcile/auth.js +692 -0
- package/src/workflows/reconcile/bundle-core.js +600 -0
- package/src/workflows/reconcile/bundle-shared.js +75 -0
- package/src/workflows/reconcile/candidate-model.js +477 -0
- package/src/workflows/reconcile/canonical-surface.js +264 -0
- package/src/workflows/reconcile/gap-report.js +333 -0
- package/src/workflows/reconcile/ids.js +6 -0
- package/src/workflows/reconcile/impacts.js +625 -0
- package/src/workflows/reconcile/index.js +7 -0
- package/src/workflows/reconcile/renderers.js +461 -0
- package/src/workflows/reconcile/summary.js +90 -0
- package/src/workflows/reconcile/workflow.js +309 -0
- package/src/workflows/shared.js +189 -0
- package/src/workflows/types.d.ts +93 -0
- package/src/workflows.d.ts +1 -0
- package/src/workflows.js +10 -7652
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import childProcess from "node:child_process";
|
|
4
|
+
|
|
5
|
+
const DEFAULT_GITHUB_API_BASE_URL = "https://api.github.com";
|
|
6
|
+
const GITHUB_REST_SCRIPT = `
|
|
7
|
+
const request = JSON.parse(process.argv[1]);
|
|
8
|
+
const base = String(request.baseUrl || "https://api.github.com").replace(/\\/+$/, "") + "/";
|
|
9
|
+
const path = String(request.path || "").replace(/^\\/+/, "");
|
|
10
|
+
const url = new URL(path, base);
|
|
11
|
+
for (const [key, value] of Object.entries(request.query || {})) {
|
|
12
|
+
if (value === null || value === undefined || value === "") continue;
|
|
13
|
+
url.searchParams.set(key, String(value));
|
|
14
|
+
}
|
|
15
|
+
function canAttachToken(urlValue) {
|
|
16
|
+
const hostname = new URL(urlValue).hostname.toLowerCase();
|
|
17
|
+
return hostname === "api.github.com" || hostname.endsWith(".github.com");
|
|
18
|
+
}
|
|
19
|
+
const headers = {
|
|
20
|
+
accept: "application/vnd.github+json",
|
|
21
|
+
"user-agent": "topogram-cli",
|
|
22
|
+
"x-github-api-version": "2022-11-28"
|
|
23
|
+
};
|
|
24
|
+
if (request.token && canAttachToken(url)) {
|
|
25
|
+
headers.authorization = "Bearer " + request.token;
|
|
26
|
+
}
|
|
27
|
+
if (process.env.TOPOGRAM_GITHUB_API_FIXTURE_ROOT) {
|
|
28
|
+
const fs = await import("node:fs");
|
|
29
|
+
const pathModule = await import("node:path");
|
|
30
|
+
const segments = path.split("/").filter(Boolean);
|
|
31
|
+
if (segments.some((segment) => segment === ".." || segment.includes("\\\\") || segment.includes("\\0"))) {
|
|
32
|
+
process.stderr.write("Unsafe GitHub API fixture path.");
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
if (process.env.TOPOGRAM_GITHUB_API_FIXTURE_LOG) {
|
|
36
|
+
fs.appendFileSync(process.env.TOPOGRAM_GITHUB_API_FIXTURE_LOG, JSON.stringify({
|
|
37
|
+
path,
|
|
38
|
+
search: url.search,
|
|
39
|
+
tokenPresent: Boolean(request.token),
|
|
40
|
+
tokenWouldAttach: Boolean(request.token && canAttachToken(url))
|
|
41
|
+
}) + "\\n", "utf8");
|
|
42
|
+
}
|
|
43
|
+
const fixturePath = pathModule.join(process.env.TOPOGRAM_GITHUB_API_FIXTURE_ROOT, ...segments) + ".json";
|
|
44
|
+
if (!fs.existsSync(fixturePath)) {
|
|
45
|
+
process.stderr.write(JSON.stringify({
|
|
46
|
+
status: 404,
|
|
47
|
+
statusText: "Not Found",
|
|
48
|
+
body: JSON.stringify({ message: "Fixture Not Found", path }),
|
|
49
|
+
url: url.toString()
|
|
50
|
+
}));
|
|
51
|
+
process.exit(2);
|
|
52
|
+
}
|
|
53
|
+
process.stdout.write(JSON.stringify({
|
|
54
|
+
status: 200,
|
|
55
|
+
body: fs.readFileSync(fixturePath, "utf8"),
|
|
56
|
+
url: url.toString()
|
|
57
|
+
}));
|
|
58
|
+
process.exit(0);
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
const response = await fetch(url, { headers });
|
|
62
|
+
const text = await response.text();
|
|
63
|
+
if (!response.ok) {
|
|
64
|
+
process.stderr.write(JSON.stringify({
|
|
65
|
+
status: response.status,
|
|
66
|
+
statusText: response.statusText,
|
|
67
|
+
body: text,
|
|
68
|
+
url: url.toString()
|
|
69
|
+
}));
|
|
70
|
+
process.exit(2);
|
|
71
|
+
}
|
|
72
|
+
process.stdout.write(JSON.stringify({
|
|
73
|
+
status: response.status,
|
|
74
|
+
body: text,
|
|
75
|
+
url: url.toString()
|
|
76
|
+
}));
|
|
77
|
+
} catch (error) {
|
|
78
|
+
process.stderr.write(error instanceof Error ? error.message : String(error));
|
|
79
|
+
process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
`;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* @typedef {Object} GitHubCliStatus
|
|
85
|
+
* @property {boolean} checked
|
|
86
|
+
* @property {boolean} available
|
|
87
|
+
* @property {boolean} authenticated
|
|
88
|
+
* @property {string|null} reason
|
|
89
|
+
*/
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* @typedef {Object} GitHubRun
|
|
93
|
+
* @property {number|string|undefined} [databaseId]
|
|
94
|
+
* @property {string|undefined} [workflowName]
|
|
95
|
+
* @property {string|undefined} [status]
|
|
96
|
+
* @property {string|undefined} [conclusion]
|
|
97
|
+
* @property {string|undefined} [headSha]
|
|
98
|
+
* @property {string|undefined} [url]
|
|
99
|
+
*/
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* @typedef {Object} GitHubJob
|
|
103
|
+
* @property {number|string|undefined} [databaseId]
|
|
104
|
+
* @property {string|undefined} [name]
|
|
105
|
+
* @property {string|undefined} [status]
|
|
106
|
+
* @property {string|undefined} [conclusion]
|
|
107
|
+
* @property {string|undefined} [url]
|
|
108
|
+
*/
|
|
109
|
+
|
|
110
|
+
export class GitHubClientError extends Error {
|
|
111
|
+
/**
|
|
112
|
+
* @param {string} message
|
|
113
|
+
* @param {{ status?: number|null, statusText?: string|null, body?: string|null, url?: string|null, command?: string|null }} [details]
|
|
114
|
+
*/
|
|
115
|
+
constructor(message, details = {}) {
|
|
116
|
+
super(message);
|
|
117
|
+
this.name = "GitHubClientError";
|
|
118
|
+
this.status = details.status ?? null;
|
|
119
|
+
this.statusText = details.statusText ?? null;
|
|
120
|
+
this.body = details.body ?? null;
|
|
121
|
+
this.url = details.url ?? null;
|
|
122
|
+
this.command = details.command ?? null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* @returns {string|null}
|
|
128
|
+
*/
|
|
129
|
+
export function githubTokenFromEnv() {
|
|
130
|
+
return process.env.GITHUB_TOKEN || process.env.GH_TOKEN || null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* @returns {string}
|
|
135
|
+
*/
|
|
136
|
+
export function githubApiBaseUrl() {
|
|
137
|
+
return process.env.TOPOGRAM_GITHUB_API_BASE_URL || DEFAULT_GITHUB_API_BASE_URL;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* @returns {boolean}
|
|
142
|
+
*/
|
|
143
|
+
function shouldUseRestApi() {
|
|
144
|
+
return Boolean(githubTokenFromEnv());
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* @param {string} path
|
|
149
|
+
* @param {{ query?: Record<string, string|number|boolean|null|undefined> }} [options]
|
|
150
|
+
* @returns {any}
|
|
151
|
+
*/
|
|
152
|
+
function githubRequestJson(path, options = {}) {
|
|
153
|
+
const result = childProcess.spawnSync(process.execPath, [
|
|
154
|
+
"--input-type=module",
|
|
155
|
+
"-e",
|
|
156
|
+
GITHUB_REST_SCRIPT,
|
|
157
|
+
JSON.stringify({
|
|
158
|
+
baseUrl: githubApiBaseUrl(),
|
|
159
|
+
path,
|
|
160
|
+
query: options.query || {},
|
|
161
|
+
token: githubTokenFromEnv() || ""
|
|
162
|
+
})
|
|
163
|
+
], {
|
|
164
|
+
encoding: "utf8",
|
|
165
|
+
env: {
|
|
166
|
+
...process.env,
|
|
167
|
+
PATH: process.env.PATH || ""
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
if (result.status !== 0) {
|
|
171
|
+
const error = parseRestError(result);
|
|
172
|
+
throw new GitHubClientError(formatRestErrorMessage(error), error);
|
|
173
|
+
}
|
|
174
|
+
/** @type {any} */
|
|
175
|
+
let payload = {};
|
|
176
|
+
try {
|
|
177
|
+
payload = JSON.parse(String(result.stdout || "{}"));
|
|
178
|
+
} catch (error) {
|
|
179
|
+
throw new GitHubClientError(`GitHub REST response was not valid JSON: ${messageFromError(error)}`);
|
|
180
|
+
}
|
|
181
|
+
const body = typeof payload.body === "string" ? payload.body : "";
|
|
182
|
+
try {
|
|
183
|
+
return body ? JSON.parse(body) : null;
|
|
184
|
+
} catch (error) {
|
|
185
|
+
throw new GitHubClientError(`GitHub REST body was not valid JSON: ${messageFromError(error)}`, {
|
|
186
|
+
body,
|
|
187
|
+
url: typeof payload.url === "string" ? payload.url : null
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* @param {ReturnType<typeof childProcess.spawnSync>} result
|
|
194
|
+
* @returns {{ status: number|null, statusText: string|null, body: string|null, url: string|null }}
|
|
195
|
+
*/
|
|
196
|
+
function parseRestError(result) {
|
|
197
|
+
if (result.status === 2) {
|
|
198
|
+
try {
|
|
199
|
+
const payload = JSON.parse(String(result.stderr || "{}"));
|
|
200
|
+
return {
|
|
201
|
+
status: Number.isFinite(payload.status) ? payload.status : null,
|
|
202
|
+
statusText: typeof payload.statusText === "string" ? payload.statusText : null,
|
|
203
|
+
body: typeof payload.body === "string" ? payload.body : null,
|
|
204
|
+
url: typeof payload.url === "string" ? payload.url : null
|
|
205
|
+
};
|
|
206
|
+
} catch {
|
|
207
|
+
// Fall through to the generic shape below.
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return {
|
|
211
|
+
status: null,
|
|
212
|
+
statusText: null,
|
|
213
|
+
body: [result.error?.message, result.stderr, result.stdout].filter(Boolean).join("\n").trim() || null,
|
|
214
|
+
url: null
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* @param {{ status: number|null, statusText: string|null, body: string|null, url: string|null }} error
|
|
220
|
+
* @returns {string}
|
|
221
|
+
*/
|
|
222
|
+
function formatRestErrorMessage(error) {
|
|
223
|
+
const status = error.status ? `${error.status}${error.statusText ? ` ${error.statusText}` : ""}` : "failed";
|
|
224
|
+
const body = String(error.body || "").trim();
|
|
225
|
+
return [`GitHub REST request ${status}.`, body.slice(0, 800)].filter(Boolean).join("\n");
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* @param {unknown} error
|
|
230
|
+
* @returns {string}
|
|
231
|
+
*/
|
|
232
|
+
function messageFromError(error) {
|
|
233
|
+
return error instanceof Error ? error.message : String(error);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* @param {string} source
|
|
238
|
+
* @returns {{ owner: string, repo: string, filePath: string, ref: string|null }}
|
|
239
|
+
*/
|
|
240
|
+
export function parseGithubCatalogSource(source) {
|
|
241
|
+
const spec = source.slice("github:".length);
|
|
242
|
+
const [pathPart, ref] = spec.split("?ref=");
|
|
243
|
+
const segments = pathPart.split("/").filter(Boolean);
|
|
244
|
+
if (segments.length < 3) {
|
|
245
|
+
throw new Error(`Invalid GitHub catalog source '${source}'. Expected github:owner/repo/path/to/catalog.json.`);
|
|
246
|
+
}
|
|
247
|
+
const [owner, repo, ...fileSegments] = segments;
|
|
248
|
+
return {
|
|
249
|
+
owner,
|
|
250
|
+
repo,
|
|
251
|
+
filePath: fileSegments.join("/"),
|
|
252
|
+
ref: ref || null
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* @param {string} source
|
|
258
|
+
* @returns {string}
|
|
259
|
+
*/
|
|
260
|
+
export function readGithubCatalogSourceText(source) {
|
|
261
|
+
const parsed = parseGithubCatalogSource(source);
|
|
262
|
+
if (shouldUseRestApi()) {
|
|
263
|
+
try {
|
|
264
|
+
const payload = githubRequestJson(`repos/${parsed.owner}/${parsed.repo}/contents/${parsed.filePath}`, {
|
|
265
|
+
query: parsed.ref ? { ref: parsed.ref } : {}
|
|
266
|
+
});
|
|
267
|
+
const content = typeof payload?.content === "string" ? payload.content : "";
|
|
268
|
+
return Buffer.from(content.replace(/\s+/g, ""), "base64").toString("utf8");
|
|
269
|
+
} catch (error) {
|
|
270
|
+
throw new Error(formatGithubCatalogError(source, error, "rest"));
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
const result = runGh(githubCatalogGhArgs(parsed));
|
|
274
|
+
if (result.status !== 0) {
|
|
275
|
+
throw new Error(formatGithubCatalogError(source, result, "gh"));
|
|
276
|
+
}
|
|
277
|
+
return Buffer.from(String(result.stdout || "").replace(/\s+/g, ""), "base64").toString("utf8");
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* @param {{ owner: string, repo: string, filePath: string, ref: string|null }} parsed
|
|
282
|
+
* @returns {string[]}
|
|
283
|
+
*/
|
|
284
|
+
function githubCatalogGhArgs(parsed) {
|
|
285
|
+
const args = ["api", `repos/${parsed.owner}/${parsed.repo}/contents/${parsed.filePath}`, "--jq", ".content"];
|
|
286
|
+
if (parsed.ref) {
|
|
287
|
+
args.splice(2, 0, "-f", `ref=${parsed.ref}`);
|
|
288
|
+
}
|
|
289
|
+
return args;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* @param {string} source
|
|
294
|
+
* @param {unknown} error
|
|
295
|
+
* @param {"rest"|"gh"} mode
|
|
296
|
+
* @returns {string}
|
|
297
|
+
*/
|
|
298
|
+
function formatGithubCatalogError(source, error, mode) {
|
|
299
|
+
const output = githubErrorOutput(error);
|
|
300
|
+
const normalized = output.toLowerCase();
|
|
301
|
+
const commandError = error && typeof error === "object" && "error" in error
|
|
302
|
+
? /** @type {{ error?: { code?: string } }} */ (error)
|
|
303
|
+
: null;
|
|
304
|
+
if (mode === "gh" && commandError?.error?.code === "ENOENT") {
|
|
305
|
+
return [
|
|
306
|
+
`GitHub CLI (gh) is required to read catalog '${source}' without GITHUB_TOKEN or GH_TOKEN.`,
|
|
307
|
+
"Install gh, set GITHUB_TOKEN or GH_TOKEN, or set TOPOGRAM_CATALOG_SOURCE to a local topograms.catalog.json file."
|
|
308
|
+
].join("\n");
|
|
309
|
+
}
|
|
310
|
+
if (/\b(401|403)\b/.test(normalized) || normalized.includes("authentication") || normalized.includes("not logged in") || normalized.includes("forbidden")) {
|
|
311
|
+
return [
|
|
312
|
+
`Authentication is required to read private catalog '${source}'.`,
|
|
313
|
+
"Set GITHUB_TOKEN or GH_TOKEN with repository read access, or run gh auth login as a local fallback.",
|
|
314
|
+
output
|
|
315
|
+
].filter(Boolean).join("\n");
|
|
316
|
+
}
|
|
317
|
+
if (/\b404\b/.test(normalized) || normalized.includes("not found")) {
|
|
318
|
+
return [
|
|
319
|
+
`Catalog source '${source}' was not found, or the current token does not have repository access.`,
|
|
320
|
+
"Check the github:owner/repo/path source and grant repository read access to the token or GitHub Actions workflow.",
|
|
321
|
+
output
|
|
322
|
+
].filter(Boolean).join("\n");
|
|
323
|
+
}
|
|
324
|
+
return [
|
|
325
|
+
`Failed to read catalog '${source}' with ${mode === "rest" ? "GitHub REST API" : "gh api"}.`,
|
|
326
|
+
"Set GITHUB_TOKEN or GH_TOKEN, or run gh auth login as a local fallback.",
|
|
327
|
+
output || "unknown error"
|
|
328
|
+
].join("\n");
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* @param {unknown} error
|
|
333
|
+
* @returns {string}
|
|
334
|
+
*/
|
|
335
|
+
function githubErrorOutput(error) {
|
|
336
|
+
if (error instanceof GitHubClientError) {
|
|
337
|
+
return [error.message, error.body].filter(Boolean).join("\n").trim();
|
|
338
|
+
}
|
|
339
|
+
if (error && typeof error === "object" && ("stdout" in error || "stderr" in error || "error" in error)) {
|
|
340
|
+
const result = /** @type {{ stdout?: string, stderr?: string, error?: Error }} */ (error);
|
|
341
|
+
return [result.error?.message, result.stderr, result.stdout].filter(Boolean).join("\n").trim();
|
|
342
|
+
}
|
|
343
|
+
return messageFromError(error);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* @param {{ repoSlug: string, branch?: string, workflowName: string, cwd?: string|null }} input
|
|
348
|
+
* @returns {GitHubRun|null}
|
|
349
|
+
*/
|
|
350
|
+
export function latestWorkflowRun(input) {
|
|
351
|
+
if (shouldUseRestApi()) {
|
|
352
|
+
const payload = githubRequestJson(`repos/${input.repoSlug}/actions/runs`, {
|
|
353
|
+
query: {
|
|
354
|
+
branch: input.branch || "main",
|
|
355
|
+
per_page: 50
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
const runs = /** @type {any[]} */ (Array.isArray(payload?.workflow_runs) ? payload.workflow_runs : []);
|
|
359
|
+
const run = runs.find((candidate) => workflowRunName(candidate) === input.workflowName) || null;
|
|
360
|
+
return run ? normalizeWorkflowRun(run, input.workflowName) : null;
|
|
361
|
+
}
|
|
362
|
+
const result = runGh([
|
|
363
|
+
"run",
|
|
364
|
+
"list",
|
|
365
|
+
"--repo",
|
|
366
|
+
input.repoSlug,
|
|
367
|
+
"--branch",
|
|
368
|
+
input.branch || "main",
|
|
369
|
+
"--workflow",
|
|
370
|
+
input.workflowName,
|
|
371
|
+
"--limit",
|
|
372
|
+
"1",
|
|
373
|
+
"--json",
|
|
374
|
+
"databaseId,workflowName,status,conclusion,headSha,url"
|
|
375
|
+
], input.cwd || undefined);
|
|
376
|
+
if (result.status !== 0) {
|
|
377
|
+
throw new GitHubClientError("GitHub CLI workflow run lookup failed.", {
|
|
378
|
+
body: githubErrorOutput(result),
|
|
379
|
+
command: "gh run list"
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
/** @type {any[]} */
|
|
383
|
+
let runs = [];
|
|
384
|
+
try {
|
|
385
|
+
runs = JSON.parse(String(result.stdout || "[]"));
|
|
386
|
+
} catch (error) {
|
|
387
|
+
throw new GitHubClientError(`GitHub CLI workflow run output was not valid JSON: ${messageFromError(error)}`, {
|
|
388
|
+
body: String(result.stdout || "")
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
return Array.isArray(runs) && runs.length > 0 ? normalizeWorkflowRun(runs[0], input.workflowName) : null;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* @param {any} run
|
|
396
|
+
* @returns {string|null}
|
|
397
|
+
*/
|
|
398
|
+
function workflowRunName(run) {
|
|
399
|
+
return typeof run?.workflowName === "string"
|
|
400
|
+
? run.workflowName
|
|
401
|
+
: typeof run?.name === "string"
|
|
402
|
+
? run.name
|
|
403
|
+
: null;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* @param {any} run
|
|
408
|
+
* @param {string} fallbackWorkflowName
|
|
409
|
+
* @returns {GitHubRun}
|
|
410
|
+
*/
|
|
411
|
+
function normalizeWorkflowRun(run, fallbackWorkflowName) {
|
|
412
|
+
return {
|
|
413
|
+
databaseId: run.databaseId ?? run.id,
|
|
414
|
+
workflowName: run.workflowName || run.name || fallbackWorkflowName,
|
|
415
|
+
status: run.status,
|
|
416
|
+
conclusion: run.conclusion,
|
|
417
|
+
headSha: run.headSha || run.head_sha,
|
|
418
|
+
url: run.html_url || run.url
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* @param {{ repoSlug: string, runId: number|string, cwd?: string|null }} input
|
|
424
|
+
* @returns {GitHubJob[]}
|
|
425
|
+
*/
|
|
426
|
+
export function workflowRunJobs(input) {
|
|
427
|
+
if (shouldUseRestApi()) {
|
|
428
|
+
const payload = githubRequestJson(`repos/${input.repoSlug}/actions/runs/${input.runId}/jobs`, {
|
|
429
|
+
query: { per_page: 100 }
|
|
430
|
+
});
|
|
431
|
+
const jobs = /** @type {any[]} */ (Array.isArray(payload?.jobs) ? payload.jobs : []);
|
|
432
|
+
return jobs.map(normalizeWorkflowJob);
|
|
433
|
+
}
|
|
434
|
+
const result = runGh([
|
|
435
|
+
"run",
|
|
436
|
+
"view",
|
|
437
|
+
String(input.runId),
|
|
438
|
+
"--repo",
|
|
439
|
+
input.repoSlug,
|
|
440
|
+
"--json",
|
|
441
|
+
"jobs"
|
|
442
|
+
], input.cwd || undefined);
|
|
443
|
+
if (result.status !== 0) {
|
|
444
|
+
throw new GitHubClientError("GitHub CLI workflow job lookup failed.", {
|
|
445
|
+
body: githubErrorOutput(result),
|
|
446
|
+
command: "gh run view"
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
/** @type {any} */
|
|
450
|
+
let payload = {};
|
|
451
|
+
try {
|
|
452
|
+
payload = JSON.parse(String(result.stdout || "{}"));
|
|
453
|
+
} catch (error) {
|
|
454
|
+
throw new GitHubClientError(`GitHub CLI workflow job output was not valid JSON: ${messageFromError(error)}`, {
|
|
455
|
+
body: String(result.stdout || "")
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
const jobs = Array.isArray(payload.jobs) ? payload.jobs : [];
|
|
459
|
+
return jobs.map(normalizeWorkflowJob);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* @param {any} job
|
|
464
|
+
* @returns {GitHubJob}
|
|
465
|
+
*/
|
|
466
|
+
function normalizeWorkflowJob(job) {
|
|
467
|
+
return {
|
|
468
|
+
databaseId: job.databaseId ?? job.id,
|
|
469
|
+
name: job.name,
|
|
470
|
+
status: job.status,
|
|
471
|
+
conclusion: job.conclusion,
|
|
472
|
+
url: job.html_url || job.url
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* @param {string[]} args
|
|
478
|
+
* @param {string} [cwd]
|
|
479
|
+
* @returns {ReturnType<typeof childProcess.spawnSync>}
|
|
480
|
+
*/
|
|
481
|
+
function runGh(args, cwd = process.cwd()) {
|
|
482
|
+
return childProcess.spawnSync("gh", args, {
|
|
483
|
+
cwd,
|
|
484
|
+
encoding: "utf8",
|
|
485
|
+
env: {
|
|
486
|
+
...process.env,
|
|
487
|
+
GH_TOKEN: process.env.GH_TOKEN || process.env.GITHUB_TOKEN || "",
|
|
488
|
+
PATH: process.env.PATH || ""
|
|
489
|
+
}
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* @param {{ checkGh?: boolean }} [options]
|
|
495
|
+
* @returns {{ githubTokenEnv: boolean, ghTokenEnv: boolean, ghCli: GitHubCliStatus }}
|
|
496
|
+
*/
|
|
497
|
+
export function githubAuthStatus(options = {}) {
|
|
498
|
+
/** @type {GitHubCliStatus} */
|
|
499
|
+
const ghCli = {
|
|
500
|
+
checked: Boolean(options.checkGh),
|
|
501
|
+
available: false,
|
|
502
|
+
authenticated: false,
|
|
503
|
+
reason: null
|
|
504
|
+
};
|
|
505
|
+
if (options.checkGh) {
|
|
506
|
+
const result = runGh(["auth", "token"]);
|
|
507
|
+
ghCli.available = result.error?.code !== "ENOENT";
|
|
508
|
+
ghCli.authenticated = result.status === 0 && Boolean(String(result.stdout || "").trim());
|
|
509
|
+
if (!ghCli.available) {
|
|
510
|
+
ghCli.reason = "GitHub CLI (gh) is not installed or not on PATH.";
|
|
511
|
+
} else if (!ghCli.authenticated) {
|
|
512
|
+
ghCli.reason = (result.stderr || result.stdout || result.error?.message || "gh auth token failed.").trim();
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
return {
|
|
516
|
+
githubTokenEnv: Boolean(process.env.GITHUB_TOKEN),
|
|
517
|
+
ghTokenEnv: Boolean(process.env.GH_TOKEN),
|
|
518
|
+
ghCli
|
|
519
|
+
};
|
|
520
|
+
}
|
|
@@ -1,6 +1,26 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
|
|
4
|
+
import { relativeTo } from "../../path-helpers.js";
|
|
5
|
+
import {
|
|
6
|
+
canonicalCandidateTerm,
|
|
7
|
+
ensureTrailingNewline,
|
|
8
|
+
idHintify,
|
|
9
|
+
pluralizeCandidateTerm,
|
|
10
|
+
slugify,
|
|
11
|
+
titleCase
|
|
12
|
+
} from "../../text-helpers.js";
|
|
13
|
+
|
|
14
|
+
export {
|
|
15
|
+
canonicalCandidateTerm,
|
|
16
|
+
ensureTrailingNewline,
|
|
17
|
+
idHintify,
|
|
18
|
+
pluralizeCandidateTerm,
|
|
19
|
+
relativeTo,
|
|
20
|
+
slugify,
|
|
21
|
+
titleCase
|
|
22
|
+
};
|
|
23
|
+
|
|
4
24
|
export const DEFAULT_IGNORED_DIRS = new Set([
|
|
5
25
|
".git",
|
|
6
26
|
".next",
|
|
@@ -13,64 +33,6 @@ export const DEFAULT_IGNORED_DIRS = new Set([
|
|
|
13
33
|
"tmp"
|
|
14
34
|
]);
|
|
15
35
|
|
|
16
|
-
export function ensureTrailingNewline(value) {
|
|
17
|
-
return value.endsWith("\n") ? value : `${value}\n`;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export function slugify(value) {
|
|
21
|
-
return String(value || "")
|
|
22
|
-
.trim()
|
|
23
|
-
.toLowerCase()
|
|
24
|
-
.replace(/[^a-z0-9]+/g, "-")
|
|
25
|
-
.replace(/^-+|-+$/g, "") || "untitled";
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export function idHintify(value) {
|
|
29
|
-
return String(value || "")
|
|
30
|
-
.trim()
|
|
31
|
-
.toLowerCase()
|
|
32
|
-
.replace(/[^a-z0-9]+/g, "_")
|
|
33
|
-
.replace(/^_+|_+$/g, "") || "untitled";
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export function canonicalCandidateTerm(value) {
|
|
37
|
-
const normalized = slugify(value);
|
|
38
|
-
if (normalized.endsWith("ies")) {
|
|
39
|
-
return `${normalized.slice(0, -3)}y`;
|
|
40
|
-
}
|
|
41
|
-
if (normalized === "status" || normalized === "stats") {
|
|
42
|
-
return normalized;
|
|
43
|
-
}
|
|
44
|
-
if (normalized.endsWith("s") && !normalized.endsWith("ss") && !normalized.endsWith("us") && !normalized.endsWith("is")) {
|
|
45
|
-
return normalized.slice(0, -1);
|
|
46
|
-
}
|
|
47
|
-
return normalized;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export function pluralizeCandidateTerm(value) {
|
|
51
|
-
const normalized = String(value || "");
|
|
52
|
-
if (!normalized) return "items";
|
|
53
|
-
const parts = normalized.split("_");
|
|
54
|
-
const last = parts.pop() || "item";
|
|
55
|
-
let plural = last;
|
|
56
|
-
if (last.endsWith("y") && !/[aeiou]y$/.test(last)) {
|
|
57
|
-
plural = `${last.slice(0, -1)}ies`;
|
|
58
|
-
} else if (/(s|x|z|ch|sh)$/.test(last)) {
|
|
59
|
-
plural = `${last}es`;
|
|
60
|
-
} else {
|
|
61
|
-
plural = `${last}s`;
|
|
62
|
-
}
|
|
63
|
-
return [...parts, plural].join("_");
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export function titleCase(value) {
|
|
67
|
-
return String(value || "")
|
|
68
|
-
.split(/[_\-\s]+/)
|
|
69
|
-
.filter(Boolean)
|
|
70
|
-
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
71
|
-
.join(" ");
|
|
72
|
-
}
|
|
73
|
-
|
|
74
36
|
export function readTextIfExists(filePath) {
|
|
75
37
|
return fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : null;
|
|
76
38
|
}
|
|
@@ -107,10 +69,6 @@ export function listFilesRecursive(rootDir, predicate = () => true, options = {}
|
|
|
107
69
|
return files.sort();
|
|
108
70
|
}
|
|
109
71
|
|
|
110
|
-
export function relativeTo(base, filePath) {
|
|
111
|
-
return path.relative(base, filePath).replaceAll(path.sep, "/");
|
|
112
|
-
}
|
|
113
|
-
|
|
114
72
|
export function importSearchRoots(paths) {
|
|
115
73
|
return [...new Set([paths.workspaceRoot, paths.topogramRoot].filter(Boolean))];
|
|
116
74
|
}
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
dedupeCandidateRecords,
|
|
4
4
|
findImportFiles,
|
|
5
5
|
makeCandidateRecord,
|
|
6
|
+
pluralizeCandidateTerm,
|
|
6
7
|
relativeTo,
|
|
7
8
|
titleCase
|
|
8
9
|
} from "../../core/shared.js";
|
|
@@ -11,16 +12,11 @@ function featureStemFromPath(filePath) {
|
|
|
11
12
|
return canonicalCandidateTerm(filePath.match(/\/features\/([^/]+)\//)?.[1] || "item");
|
|
12
13
|
}
|
|
13
14
|
|
|
14
|
-
function pluralizeStem(stem) {
|
|
15
|
-
if (stem.endsWith("s")) return stem;
|
|
16
|
-
return `${stem}s`;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
15
|
function capabilityIdFor(featureStem, methodName, httpMethod) {
|
|
20
16
|
const stem = canonicalCandidateTerm(featureStem);
|
|
21
17
|
const normalizedMethod = String(methodName || "").toLowerCase();
|
|
22
18
|
if (httpMethod === "GET") {
|
|
23
|
-
return `cap_list_${
|
|
19
|
+
return `cap_list_${pluralizeCandidateTerm(stem)}`;
|
|
24
20
|
}
|
|
25
21
|
if (httpMethod === "POST") {
|
|
26
22
|
return `cap_create_${stem}`;
|
|
@@ -59,12 +55,12 @@ function parseDatasourceFile(text, provenance, filePath, apiConfigPaths) {
|
|
|
59
55
|
const httpMethod = dioCall[1].toUpperCase();
|
|
60
56
|
const callArgs = dioCall[2];
|
|
61
57
|
const apiRef = callArgs.match(/ApiConfig\.([A-Za-z_][A-Za-z0-9_]*)/);
|
|
62
|
-
const basePath = apiConfigPaths.get(apiRef?.[1] || "") || `/${
|
|
58
|
+
const basePath = apiConfigPaths.get(apiRef?.[1] || "") || `/${pluralizeCandidateTerm(featureStem)}`;
|
|
63
59
|
const dynamicPath = callArgs.match(/"\$\{ApiConfig\.[A-Za-z_][A-Za-z0-9_]*\}\/\$\{[^}]+\}"/);
|
|
64
60
|
const path = dynamicPath ? `${basePath}/{id}` : basePath;
|
|
65
61
|
const queryParams = [...body.matchAll(/'([^']+)'\s*:\s*[^,}]+/g)]
|
|
66
62
|
.map((entry) => ({ name: entry[1], required: false, type: null }));
|
|
67
|
-
const outputFields = /^GET$/.test(httpMethod) ? [
|
|
63
|
+
const outputFields = /^GET$/.test(httpMethod) ? [pluralizeCandidateTerm(featureStem)] : [];
|
|
68
64
|
const inputFields = [...new Set([...callArgs.matchAll(/data:\s*([A-Za-z_][A-Za-z0-9_]*)/g)].map((entry) => entry[1]))];
|
|
69
65
|
capabilities.push(makeCandidateRecord({
|
|
70
66
|
kind: "capability",
|
|
@@ -3,15 +3,11 @@ import {
|
|
|
3
3
|
dedupeCandidateRecords,
|
|
4
4
|
findImportFiles,
|
|
5
5
|
makeCandidateRecord,
|
|
6
|
+
pluralizeCandidateTerm,
|
|
6
7
|
relativeTo,
|
|
7
8
|
titleCase
|
|
8
9
|
} from "../../core/shared.js";
|
|
9
10
|
|
|
10
|
-
function pluralizeStem(stem) {
|
|
11
|
-
if (stem.endsWith("s")) return stem;
|
|
12
|
-
return `${stem}s`;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
11
|
function stemFromRepoPath(filePath) {
|
|
16
12
|
return canonicalCandidateTerm(filePath.match(/\/src\/([^/]+)\//)?.[1] || "item");
|
|
17
13
|
}
|
|
@@ -22,7 +18,7 @@ function capabilityIdFor(stem, methodName, httpMethod) {
|
|
|
22
18
|
return `cap_get_${stem}`;
|
|
23
19
|
}
|
|
24
20
|
if (httpMethod === "GET") {
|
|
25
|
-
return `cap_list_${
|
|
21
|
+
return `cap_list_${pluralizeCandidateTerm(stem)}`;
|
|
26
22
|
}
|
|
27
23
|
if (httpMethod === "POST") {
|
|
28
24
|
return `cap_create_${stem}`;
|
|
@@ -39,7 +35,7 @@ function capabilityIdFor(stem, methodName, httpMethod) {
|
|
|
39
35
|
function parseRepositoryFile(text, provenance, filePath) {
|
|
40
36
|
const stem = stemFromRepoPath(filePath);
|
|
41
37
|
const baseUrlMatch = String(text || "").match(/baseUrl\s*=\s*["'`]([^"'`]+)["'`]/);
|
|
42
|
-
const basePath = baseUrlMatch?.[1] || `/${
|
|
38
|
+
const basePath = baseUrlMatch?.[1] || `/${pluralizeCandidateTerm(stem)}`;
|
|
43
39
|
const capabilities = [];
|
|
44
40
|
const methods = [...String(text || "").matchAll(/public\s+async\s+([A-Za-z_][A-Za-z0-9_]*)\(([\s\S]*?)\)\s*(?::\s*[\s\S]*?)?\s*\{/g)];
|
|
45
41
|
for (let index = 0; index < methods.length; index += 1) {
|
|
@@ -59,7 +55,7 @@ function parseRepositoryFile(text, provenance, filePath) {
|
|
|
59
55
|
...[...String(body || "").matchAll(/payload\.([A-Za-z_][A-Za-z0-9_]*)/g)].map((entry) => entry[1])
|
|
60
56
|
])].filter((field) => field !== "id");
|
|
61
57
|
const outputFields = httpMethod === "GET"
|
|
62
|
-
? (/count/.test(body) ? [
|
|
58
|
+
? (/count/.test(body) ? [pluralizeCandidateTerm(stem), "count"] : (/map\(/.test(body) ? [pluralizeCandidateTerm(stem)] : ["id", "title", "body"]))
|
|
63
59
|
: [];
|
|
64
60
|
capabilities.push(makeCandidateRecord({
|
|
65
61
|
kind: "capability",
|