@bridgeline-digital/hawk-app-cli 0.1.0-dev.20261020202308 → 0.1.0
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/LICENSE +7 -7
- package/README.md +770 -770
- package/agents/AGENTS.md +192 -192
- package/agents/project/custom.md +21 -21
- package/agents/project/next-mui.md +21 -21
- package/agents/project/next.md +24 -24
- package/agents/project/vanilla.md +22 -22
- package/agents/project/vite.md +19 -19
- package/bin/hawk-app.mjs +0 -0
- package/docs/bridge/README.md +1315 -1315
- package/docs/showcase/README.md +47 -47
- package/docs/showcase/functions/echo.ts +12 -12
- package/docs/showcase/functions/long-task.ts +31 -31
- package/docs/showcase/functions/showcase.ts +186 -186
- package/docs/showcase/hawk-app.json +73 -73
- package/docs/showcase/queries/assistant-prompts-by-day.json +6 -6
- package/docs/showcase/queries/assistant-prompts-by-day.sql +8 -8
- package/docs/showcase/queries/searches-by-day.json +6 -6
- package/docs/showcase/queries/searches-by-day.sql +7 -7
- package/docs/showcase/src/App.tsx +1436 -1436
- package/docs/showcase/src/main.tsx +10 -10
- package/docs/showcase/src/styles.css +71 -71
- package/docs/showcase/src/useHawkApp.ts +44 -44
- package/docs/showcase/src/vite-env.d.ts +1 -1
- package/docs/ui/README.md +166 -166
- package/package.json +1 -1
- package/schema/hawk-app.schema.json +611 -611
- package/src/accessibility.mjs +105 -105
- package/src/agents.mjs +145 -145
- package/src/aliases.mjs +30 -30
- package/src/app-icon.mjs +82 -82
- package/src/channel.mjs +116 -116
- package/src/cli.mjs +825 -825
- package/src/clients.mjs +221 -221
- package/src/config.mjs +148 -148
- package/src/create.mjs +186 -186
- package/src/credentials.mjs +56 -56
- package/src/data.mjs +169 -169
- package/src/deploy.mjs +349 -349
- package/src/dev.mjs +459 -459
- package/src/doctor.mjs +176 -176
- package/src/env.mjs +140 -140
- package/src/functions.mjs +313 -313
- package/src/login.mjs +166 -166
- package/src/logs.mjs +64 -64
- package/src/manifest.mjs +425 -425
- package/src/mcp-docs.mjs +168 -168
- package/src/mcp-hawksearch.mjs +342 -342
- package/src/mcp-reports.mjs +130 -130
- package/src/mcp-rules.mjs +109 -109
- package/src/mcp.mjs +539 -539
- package/src/platform.mjs +116 -116
- package/src/project-checks.mjs +153 -153
- package/src/queries.mjs +289 -289
- package/src/scope-check.mjs +91 -91
- package/src/secrets.mjs +86 -86
- package/src/slug-guard.mjs +80 -80
- package/src/sso.mjs +182 -182
- package/src/telemetry.mjs +50 -50
- package/src/upgrade.mjs +406 -406
- package/src/versions.mjs +102 -102
- package/templates/next/next.config.ts +13 -13
- package/templates/next/package.json +26 -26
- package/templates/next/src/lib/useHawkApp.ts +46 -46
- package/templates/next-mui/next.config.ts +13 -13
- package/templates/next-mui/package.json +30 -30
- package/templates/next-mui/src/app/layout.tsx +19 -19
- package/templates/next-mui/src/app/page.tsx +78 -78
- package/templates/next-mui/src/app/providers.tsx +10 -10
- package/templates/preact/package.json +26 -26
- package/templates/preact/src/useHawkApp.ts +44 -44
- package/templates/preact/src/vite-env.d.ts +1 -1
- package/templates/preact/tsconfig.json +17 -17
- package/templates/preact/vite.config.ts +11 -11
- package/templates/react/package.json +29 -29
- package/templates/react/src/useHawkApp.ts +44 -44
- package/templates/react/src/vite-env.d.ts +1 -1
- package/templates/react/tsconfig.json +16 -16
- package/templates/react/vite.config.ts +11 -11
- package/templates/vanilla/package.json +15 -15
package/src/deploy.mjs
CHANGED
|
@@ -1,349 +1,349 @@
|
|
|
1
|
-
import { spawn, spawnSync } from 'node:child_process';
|
|
2
|
-
import { existsSync } from 'node:fs';
|
|
3
|
-
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
4
|
-
import { dirname, join, relative, sep } from 'node:path';
|
|
5
|
-
import { buildFunction, describeConfig, describeFunctions, zipSources } from './functions.mjs';
|
|
6
|
-
import { checkAccessibility, describeAccessibility } from './accessibility.mjs';
|
|
7
|
-
import { checkBuildUrls, describeBuildUrls } from './project-checks.mjs';
|
|
8
|
-
import { askYesNo } from './slug-guard.mjs';
|
|
9
|
-
|
|
10
|
-
const CONCURRENCY = 8;
|
|
11
|
-
const RETRIES = 3;
|
|
12
|
-
const AUDIT_TIMEOUT_MS = 60_000;
|
|
13
|
-
|
|
14
|
-
/** Every file under `dir`, as forward-slash paths relative to it. Hidden files and folders are skipped. */
|
|
15
|
-
export async function listFiles(dir) {
|
|
16
|
-
const files = [];
|
|
17
|
-
async function walk(current) {
|
|
18
|
-
for (const entry of await readdir(current, { withFileTypes: true })) {
|
|
19
|
-
if (entry.name.startsWith('.')) continue;
|
|
20
|
-
const full = join(current, entry.name);
|
|
21
|
-
if (entry.isDirectory()) await walk(full);
|
|
22
|
-
else if (entry.isFile()) files.push({ path: relative(dir, full).split(sep).join('/'), size: (await stat(full)).size, full });
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
await walk(dir);
|
|
26
|
-
return files.sort((a, b) => a.path.localeCompare(b.path));
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Deploys a new version of an app. The platform reserves the next version number (v<n>), the app is built for
|
|
31
|
-
* `/<slug>/v<n>` (HAWK_APP_BASE_PATH), the platform hands out one upload URL per file (scoped to that version's
|
|
32
|
-
* folder on the CDN), the files go straight to storage, then the platform verifies them and decides who sees the
|
|
33
|
-
* version: an app not released yet shows it to Bridgeline staff at once; a released app keeps clients on their version until
|
|
34
|
-
* it's approved in HawkSearch (unless the app is set to auto-approve).
|
|
35
|
-
* `serverFiles` (the app's checked queries/ files, from the project root) go to the platform's private server bucket
|
|
36
|
-
* with their own upload URLs, never to the CDN.
|
|
37
|
-
* `functions` (checked `{ name, entry, config }` from loadFunctions) are bundled with esbuild, uploaded to the server
|
|
38
|
-
* bucket (bundle.zip, source.zip, deps.json), deployed one by one as Lambda versions, and listed on complete.
|
|
39
|
-
* `audit` runs `npm audit` of the production dependencies before a version is reserved and sends the result with
|
|
40
|
-
* complete, for reviewers. High or critical findings go to `confirmAudit` first (the CLI asks: confirmVulnerabilities).
|
|
41
|
-
*/
|
|
42
|
-
/**
|
|
43
|
-
* @param beforeReserve Runs after the local checks, just before a version number is taken (the slug check: slug-guard.mjs).
|
|
44
|
-
* @param confirmAudit Called with the audit report when it has high or critical vulnerabilities; throws to stop the deploy.
|
|
45
|
-
*/
|
|
46
|
-
export async function deploy({ client, slug, dir, alias, manifest, registerSettings, iconFile = null, storeFiles = [], build = true, serverFiles = [], functions = [], projectDir = process.cwd(), audit = true, runBuild = npmRunBuild, runAudit = npmAudit, runAccessibility = checkAccessibility, beforeReserve, confirmAudit, fetchImpl = fetch, log = console.log }) {
|
|
47
|
-
// Without a build step the folder must already be there: check before reserving a version.
|
|
48
|
-
if (!build) await checkFolder(dir, false);
|
|
49
|
-
|
|
50
|
-
// Bundle the functions first: a function that doesn't build shouldn't cost a version number.
|
|
51
|
-
const built = [];
|
|
52
|
-
if (functions.length) {
|
|
53
|
-
log(`Bundling ${describeFunctions(functions.length)}…`);
|
|
54
|
-
const sourceZip = await zipSources(projectDir);
|
|
55
|
-
for (const fn of functions) {
|
|
56
|
-
const result = await buildFunction({ projectDir, name: fn.name, entry: fn.entry, sourceZip });
|
|
57
|
-
built.push({ ...result, config: fn.config });
|
|
58
|
-
log(` ƒ ${fn.name} ${formatBytes(result.bundleBytes)} zipped, ${result.deps.length} package${result.deps.length === 1 ? '' : 's'}`);
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
const functionFiles = built.flatMap((fn) => fn.files);
|
|
62
|
-
|
|
63
|
-
// Dependencies before a version number: a developer can stop and fix them first.
|
|
64
|
-
const { serious, ...report } = audit ? await auditDependencies({ cwd: projectDir, runAudit }) : { status: 'not_checked', reason: 'skipped (--no-audit)' };
|
|
65
|
-
log(` dependencies ${describeAudit(report)}`);
|
|
66
|
-
if (confirmAudit && report.status === 'checked' && report.critical + report.high > 0) await confirmAudit({ ...report, serious });
|
|
67
|
-
|
|
68
|
-
if (beforeReserve) await beforeReserve();
|
|
69
|
-
// The main line: hawk-app.json goes first (scopes, origins, functions…), so the version carries the settings it's
|
|
70
|
-
// deployed with. For an alias, the branch's hawk-app.json goes with the version only.
|
|
71
|
-
if (!alias && registerSettings) await registerSettings();
|
|
72
|
-
const reserved = await client.reserveVersion(slug);
|
|
73
|
-
const version = reserved.version;
|
|
74
|
-
|
|
75
|
-
if (build) {
|
|
76
|
-
log(`Building v${version} of ${slug} (HAWK_APP_BASE_PATH=${reserved.basePath})…`);
|
|
77
|
-
await runBuild({ basePath: reserved.basePath });
|
|
78
|
-
await checkFolder(dir, true);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const files = await listFiles(dir);
|
|
82
|
-
if (!files.length) throw new Error(`"${dir}" is empty.`);
|
|
83
|
-
// The icon and the store screenshots go into the version too (_hawk/icon.svg, _hawk/store/1.png…): a folder of the
|
|
84
|
-
// build can't take their place.
|
|
85
|
-
if (files.some((f) => f.path.startsWith('_hawk/'))) throw new Error(`"${dir}" has a _hawk/ folder: the platform uses that path for the app's icon and store screenshots. Rename it.`);
|
|
86
|
-
if (iconFile) files.push(iconFile);
|
|
87
|
-
files.push(...storeFiles);
|
|
88
|
-
// The usual mistake of an app set up without a template: never stops the deploy (the version is already reserved).
|
|
89
|
-
const outside = reserved.basePath ? await checkBuildUrls(dir, reserved.basePath) : [];
|
|
90
|
-
if (outside.length) log(`! ${describeBuildUrls(outside, reserved.basePath)}`);
|
|
91
|
-
// axe over the built pages, for the approvers' accessibility check. Never stops the deploy.
|
|
92
|
-
const accessibility = await runAccessibility(dir);
|
|
93
|
-
log(` accessibility ${describeAccessibility(accessibility)}`);
|
|
94
|
-
const totalBytes = files.reduce((sum, f) => sum + f.size, 0);
|
|
95
|
-
const queryCount = new Set(serverFiles.map((f) => f.path.replace(/.(sql|json)$/, ''))).size;
|
|
96
|
-
const queriesNote = `${queryCount ? `, ${describeQueries(queryCount)}` : ''}${built.length ? `, ${describeFunctions(built.length)}` : ''}`;
|
|
97
|
-
log(`Uploading v${version} of ${slug}: ${files.length} files, ${formatBytes(totalBytes)} from ${dir}${queriesNote}`);
|
|
98
|
-
|
|
99
|
-
const started = await client.startUploads(
|
|
100
|
-
slug,
|
|
101
|
-
version,
|
|
102
|
-
files.map(({ path, size }) => ({ path, size })),
|
|
103
|
-
serverFiles.map(({ path, size }) => ({ path, size })),
|
|
104
|
-
functionFiles.map(({ path, size }) => ({ path, size })),
|
|
105
|
-
);
|
|
106
|
-
const byPath = new Map(files.map((f) => [f.path, f]));
|
|
107
|
-
const serverByPath = new Map(serverFiles.map((f) => [f.path, f]));
|
|
108
|
-
const functionByPath = new Map(functionFiles.map((f) => [f.path, f]));
|
|
109
|
-
if (serverFiles.length && (started.serverUploads?.length ?? 0) !== serverFiles.length) {
|
|
110
|
-
throw new Error('The platform did not accept the queries/ files. It may not support app queries yet; update it or remove queries/.');
|
|
111
|
-
}
|
|
112
|
-
if (functionFiles.length && (started.functionUploads?.length ?? 0) !== functionFiles.length) {
|
|
113
|
-
throw new Error('The platform did not accept the functions. It may not support app functions yet; update it or remove "functions".');
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
let done = 0;
|
|
117
|
-
const queue = [
|
|
118
|
-
...started.uploads.map((upload) => ({ upload, file: byPath.get(upload.path) })),
|
|
119
|
-
...(started.serverUploads ?? []).map((upload) => ({ upload, file: serverByPath.get(upload.path) })),
|
|
120
|
-
...(started.functionUploads ?? []).map((upload) => ({ upload, file: functionByPath.get(upload.path) })),
|
|
121
|
-
];
|
|
122
|
-
const total = queue.length;
|
|
123
|
-
async function worker() {
|
|
124
|
-
for (let item = queue.shift(); item; item = queue.shift()) {
|
|
125
|
-
if (!item.file) throw new Error(`The platform returned an upload for an unknown file: ${item.upload.path}`);
|
|
126
|
-
await uploadWithRetry(item.upload, item.file.data ?? (await readFile(item.file.full)), fetchImpl);
|
|
127
|
-
done += 1;
|
|
128
|
-
if (done % 25 === 0 || done === total) log(` uploaded ${done}/${total}`);
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, queue.length) }, worker));
|
|
132
|
-
|
|
133
|
-
// Each function becomes a Lambda version; the platform waits until it's ready.
|
|
134
|
-
for (const fn of built) {
|
|
135
|
-
const deployed = await client.deployFunction(slug, version, fn.name, fn.config);
|
|
136
|
-
log(` ✔ function ${fn.name} (${describeConfig(fn.config)})${deployed?.lambdaVersion ? ` Lambda version ${deployed.lambdaVersion}` : ''}`);
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
const completed = await client.completeVersion(
|
|
140
|
-
slug,
|
|
141
|
-
version,
|
|
142
|
-
files.map((f) => f.path),
|
|
143
|
-
serverFiles.map((f) => f.path),
|
|
144
|
-
built.map((fn) => fn.name),
|
|
145
|
-
report,
|
|
146
|
-
accessibility,
|
|
147
|
-
alias ? { alias, manifest } : {},
|
|
148
|
-
);
|
|
149
|
-
const n = completed.version ?? version;
|
|
150
|
-
log(`✔ Deployed v${n} of ${slug} (${completed.files ?? files.length} files${queriesNote}).${describeState(completed)}`);
|
|
151
|
-
if (completed.entryUrl) log(` ${completed.entryUrl}`);
|
|
152
|
-
if (built.length && completed.state === 'pending') log(' (Versions with functions always need review, even on auto-approve apps.)');
|
|
153
|
-
if (completed.submissionCleared) log(` ! ${submissionClearedMessage('version')}`);
|
|
154
|
-
if (completed.removedVersions?.length) log(` Removed old versions ${completed.removedVersions.map((v) => `v${v}`).join(', ')}.`);
|
|
155
|
-
if (!alias) await warnAboutScopes({ client, slug, needs: neededScopes(serverFiles, built), log });
|
|
156
|
-
return completed;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/** Said when a deploy or register replaced what was waiting for approval: the platform cleared the request. */
|
|
160
|
-
export function submissionClearedMessage(what) {
|
|
161
|
-
return `Your new ${what} cleared the waiting approval request: submit it again in HawkSearch (npx hawk-app open).`;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/** The scopes a version's own files need to run: queries need data:read, functions need functions:invoke. */
|
|
165
|
-
function neededScopes(serverFiles, built) {
|
|
166
|
-
const needs = [];
|
|
167
|
-
if (serverFiles.some((f) => f.path.startsWith('queries/'))) needs.push('data:read');
|
|
168
|
-
if (built.length) needs.push('functions:invoke');
|
|
169
|
-
return needs;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
* A version that uses queries or functions while hawk-app.json's scopes (granted or waiting for approval) lack the scope
|
|
174
|
-
* gets 403 scope_missing at run time. Say so now. Best effort: a failed status check never fails a deploy that succeeded.
|
|
175
|
-
*/
|
|
176
|
-
async function warnAboutScopes({ client, slug, needs, log }) {
|
|
177
|
-
if (!needs.length || !client.status) return;
|
|
178
|
-
let app;
|
|
179
|
-
try {
|
|
180
|
-
app = await client.status(slug);
|
|
181
|
-
} catch (error) {
|
|
182
|
-
if (error?.status === 404) log(` ! ${slug} isn't on the platform: add ${needs.join(', ')} to "scopes" in hawk-app.json and deploy again.`);
|
|
183
|
-
return;
|
|
184
|
-
}
|
|
185
|
-
const registered = new Set([...(app?.scopes ?? []), ...(app?.pendingScopes ?? [])]);
|
|
186
|
-
const missing = needs.filter((scope) => !registered.has(scope));
|
|
187
|
-
if (missing.length) {
|
|
188
|
-
log(` ! This version uses ${missing.join(' and ')}, which hawk-app.json's scopes on the platform don't include. Add ${missing.length === 1 ? 'it' : 'them'} to "scopes" in hawk-app.json and deploy again, or calls fail with 403 scope_missing.`);
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
function describeQueries(count) {
|
|
193
|
-
return `${count} quer${count === 1 ? 'y' : 'ies'}`;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
function describeState({ state, liveVersion, alias }) {
|
|
197
|
-
switch (state) {
|
|
198
|
-
case 'alias':
|
|
199
|
-
return ` ${alias} → it: only Bridgeline staff in its clients see it until it's approved. The main line (default, and the version under review) is unchanged.`;
|
|
200
|
-
case 'draft':
|
|
201
|
-
return ' Not released yet: only Bridgeline staff see it. Review it and submit it for approval in HawkSearch: npx hawk-app open.';
|
|
202
|
-
case 'pending':
|
|
203
|
-
return ` A draft: clients stay on ${liveVersion ? `v${liveVersion}` : 'the default version'} until it's approved. Submit it for approval in HawkSearch: npx hawk-app open.`;
|
|
204
|
-
case 'live':
|
|
205
|
-
return ' Auto-approved: released to clients.';
|
|
206
|
-
default:
|
|
207
|
-
return '';
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
async function checkFolder(dir, built) {
|
|
212
|
-
let info;
|
|
213
|
-
try {
|
|
214
|
-
info = await stat(dir);
|
|
215
|
-
} catch {
|
|
216
|
-
throw new Error(built ? `The build did not create "${dir}". Check the folder name (dist, out, build, ...).` : `Build folder "${dir}" not found.`);
|
|
217
|
-
}
|
|
218
|
-
if (!info.isDirectory()) throw new Error(`"${dir}" is not a folder.`);
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/** Runs the project's `npm run build` with the version's base path, so asset URLs point at /<slug>/v<n>/. */
|
|
222
|
-
export async function npmRunBuild({ basePath, cwd = process.cwd() }) {
|
|
223
|
-
const result = spawnSync('npm', ['run', 'build'], {
|
|
224
|
-
cwd,
|
|
225
|
-
stdio: 'inherit',
|
|
226
|
-
shell: process.platform === 'win32',
|
|
227
|
-
env: { ...process.env, HAWK_APP_BASE_PATH: basePath },
|
|
228
|
-
});
|
|
229
|
-
if (result.error) throw new Error(`Could not run \`npm run build\`: ${result.error.message}`);
|
|
230
|
-
if (result.status !== 0) {
|
|
231
|
-
throw new Error(`\`npm run build\` failed (exit code ${result.status}). Fix the build, or pass --no-build to upload the folder as it is.`);
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
/**
|
|
236
|
-
* Runs `npm audit --json --omit=dev` in the app folder: { status: 'checked', critical, high, moderate, low,
|
|
237
|
-
* dependencies, serious } or { status: 'not_checked', reason }. `serious` (the packages with high or critical findings)
|
|
238
|
-
* is for the developer only: deploy leaves it out of what it sends. Never throws: a deploy doesn't fail because of it. Reasons are
|
|
239
|
-
* fixed texts (no paths, no npm output) so nothing private reaches the platform.
|
|
240
|
-
*/
|
|
241
|
-
export async function auditDependencies({ cwd, runAudit = npmAudit }) {
|
|
242
|
-
const notChecked = (reason) => ({ status: 'not_checked', reason });
|
|
243
|
-
if (!hasLockfile(cwd)) return notChecked('no package-lock.json');
|
|
244
|
-
let stdout;
|
|
245
|
-
try {
|
|
246
|
-
// npm audit exits non-zero when it finds vulnerabilities: the JSON decides, not the exit code.
|
|
247
|
-
({ stdout } = await runAudit({ cwd }));
|
|
248
|
-
} catch (error) {
|
|
249
|
-
return notChecked(error?.code === 'ETIMEDOUT' ? `npm audit timed out after ${AUDIT_TIMEOUT_MS / 1000} s` : 'could not run npm audit');
|
|
250
|
-
}
|
|
251
|
-
let result;
|
|
252
|
-
try {
|
|
253
|
-
result = JSON.parse(stdout);
|
|
254
|
-
} catch {
|
|
255
|
-
return notChecked('npm audit output was not JSON');
|
|
256
|
-
}
|
|
257
|
-
if (result?.error) {
|
|
258
|
-
const code = String(result.error.code ?? '');
|
|
259
|
-
return notChecked(/^[A-Z0-9_]{1,40}$/.test(code) ? `npm audit failed (${code})` : 'npm audit failed');
|
|
260
|
-
}
|
|
261
|
-
const counts = result?.metadata?.vulnerabilities;
|
|
262
|
-
const levels = ['critical', 'high', 'moderate', 'low'];
|
|
263
|
-
if (!counts || !levels.every((level) => Number.isInteger(counts[level]) && counts[level] >= 0)) {
|
|
264
|
-
return notChecked('npm audit output had no vulnerability counts');
|
|
265
|
-
}
|
|
266
|
-
const deps = result.metadata.dependencies;
|
|
267
|
-
const dependencies = [deps?.prod, deps?.total].find((n) => Number.isInteger(n) && n >= 0) ?? 0;
|
|
268
|
-
const serious = Object.entries(result.vulnerabilities ?? {})
|
|
269
|
-
.filter(([, v]) => v?.severity === 'critical' || v?.severity === 'high')
|
|
270
|
-
.map(([name]) => name)
|
|
271
|
-
.sort();
|
|
272
|
-
return { status: 'checked', ...Object.fromEntries(levels.map((level) => [level, counts[level]])), dependencies, serious };
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
/** A package-lock.json in the folder or above it: an npm workspace keeps one lockfile at the repository root, and npm audit finds it from the workspace folder. */
|
|
276
|
-
function hasLockfile(dir) {
|
|
277
|
-
for (let current = dir; ; current = dirname(current)) {
|
|
278
|
-
if (existsSync(join(current, 'package-lock.json'))) return true;
|
|
279
|
-
if (dirname(current) === current) return false;
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
/**
|
|
284
|
-
* deploy's confirmAudit in the CLI: high or critical vulnerabilities fail the review's dependencies check (approvers can
|
|
285
|
-
* only force approve), so ask before deploying. --yes goes on with a warning; so does a pipeline (no terminal to ask).
|
|
286
|
-
*/
|
|
287
|
-
export async function confirmVulnerabilities(report, { yes = false, confirm = askYesNo, log = console.log } = {}) {
|
|
288
|
-
const packages = report.serious?.length ? ` (${report.serious.join(', ')})` : '';
|
|
289
|
-
const message = `npm audit found ${describeAudit(report).replace(' (npm audit)', '')} in the production dependencies${packages}. Approvers can only force approve this version.`;
|
|
290
|
-
if (yes) {
|
|
291
|
-
log(`! ${message}`);
|
|
292
|
-
return;
|
|
293
|
-
}
|
|
294
|
-
const answer = await confirm(`${message} Deploy anyway? (Fix with npm audit fix or by updating the packages; npm run app:audit shows the details.)`);
|
|
295
|
-
if (answer === null) {
|
|
296
|
-
log(`! ${message} Going on (no terminal to ask; add --yes to silence this).`);
|
|
297
|
-
return;
|
|
298
|
-
}
|
|
299
|
-
if (!answer) throw new Error('Cancelled. Nothing was deployed. `npm run app:audit` (or npm audit --omit=dev) shows the details.');
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
function describeAudit(report) {
|
|
303
|
-
if (report.status !== 'checked') return `not checked: ${report.reason}`;
|
|
304
|
-
const found = ['critical', 'high', 'moderate', 'low'].filter((level) => report[level]).map((level) => `${report[level]} ${level}`);
|
|
305
|
-
return found.length ? `${found.join(', ')} (npm audit)` : 'no known vulnerabilities';
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
/** Runs `npm audit --json --omit=dev` in `cwd`: { code, stdout }. Rejects when npm can't start or takes over `timeoutMs`. */
|
|
309
|
-
export function npmAudit({ cwd, timeoutMs = AUDIT_TIMEOUT_MS }) {
|
|
310
|
-
return new Promise((resolve, reject) => {
|
|
311
|
-
const child = spawn('npm', ['audit', '--json', '--omit=dev'], { cwd, stdio: ['ignore', 'pipe', 'ignore'], shell: process.platform === 'win32' });
|
|
312
|
-
const chunks = [];
|
|
313
|
-
const timer = setTimeout(() => {
|
|
314
|
-
// With a shell on Windows the child is cmd.exe: end the whole tree, not only the shell.
|
|
315
|
-
if (process.platform === 'win32') spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' });
|
|
316
|
-
else child.kill();
|
|
317
|
-
reject(Object.assign(new Error('npm audit timed out'), { code: 'ETIMEDOUT' }));
|
|
318
|
-
}, timeoutMs);
|
|
319
|
-
child.stdout.on('data', (chunk) => chunks.push(chunk));
|
|
320
|
-
child.on('error', (error) => {
|
|
321
|
-
clearTimeout(timer);
|
|
322
|
-
reject(error);
|
|
323
|
-
});
|
|
324
|
-
child.on('close', (code) => {
|
|
325
|
-
clearTimeout(timer);
|
|
326
|
-
resolve({ code, stdout: Buffer.concat(chunks).toString('utf8') });
|
|
327
|
-
});
|
|
328
|
-
});
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
async function uploadWithRetry(upload, body, fetchImpl) {
|
|
332
|
-
for (let attempt = 1; ; attempt++) {
|
|
333
|
-
try {
|
|
334
|
-
const response = await fetchImpl(upload.url, { method: 'PUT', headers: upload.headers, body });
|
|
335
|
-
if (response.ok) return;
|
|
336
|
-
const text = await response.text().catch(() => '');
|
|
337
|
-
if (response.status < 500 || attempt >= RETRIES) {
|
|
338
|
-
throw new Error(`Upload of ${upload.path} failed (${response.status}). ${response.status === 403 ? 'The upload link may have expired; run deploy again.' : text.slice(0, 200)}`);
|
|
339
|
-
}
|
|
340
|
-
} catch (error) {
|
|
341
|
-
if (attempt >= RETRIES || error.message?.startsWith('Upload of')) throw error;
|
|
342
|
-
}
|
|
343
|
-
await new Promise((resolve) => setTimeout(resolve, 500 * attempt));
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
function formatBytes(bytes) {
|
|
348
|
-
return bytes > 1024 * 1024 ? `${(bytes / 1024 / 1024).toFixed(1)} MB` : `${Math.ceil(bytes / 1024)} KB`;
|
|
349
|
-
}
|
|
1
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
4
|
+
import { dirname, join, relative, sep } from 'node:path';
|
|
5
|
+
import { buildFunction, describeConfig, describeFunctions, zipSources } from './functions.mjs';
|
|
6
|
+
import { checkAccessibility, describeAccessibility } from './accessibility.mjs';
|
|
7
|
+
import { checkBuildUrls, describeBuildUrls } from './project-checks.mjs';
|
|
8
|
+
import { askYesNo } from './slug-guard.mjs';
|
|
9
|
+
|
|
10
|
+
const CONCURRENCY = 8;
|
|
11
|
+
const RETRIES = 3;
|
|
12
|
+
const AUDIT_TIMEOUT_MS = 60_000;
|
|
13
|
+
|
|
14
|
+
/** Every file under `dir`, as forward-slash paths relative to it. Hidden files and folders are skipped. */
|
|
15
|
+
export async function listFiles(dir) {
|
|
16
|
+
const files = [];
|
|
17
|
+
async function walk(current) {
|
|
18
|
+
for (const entry of await readdir(current, { withFileTypes: true })) {
|
|
19
|
+
if (entry.name.startsWith('.')) continue;
|
|
20
|
+
const full = join(current, entry.name);
|
|
21
|
+
if (entry.isDirectory()) await walk(full);
|
|
22
|
+
else if (entry.isFile()) files.push({ path: relative(dir, full).split(sep).join('/'), size: (await stat(full)).size, full });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
await walk(dir);
|
|
26
|
+
return files.sort((a, b) => a.path.localeCompare(b.path));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Deploys a new version of an app. The platform reserves the next version number (v<n>), the app is built for
|
|
31
|
+
* `/<slug>/v<n>` (HAWK_APP_BASE_PATH), the platform hands out one upload URL per file (scoped to that version's
|
|
32
|
+
* folder on the CDN), the files go straight to storage, then the platform verifies them and decides who sees the
|
|
33
|
+
* version: an app not released yet shows it to Bridgeline staff at once; a released app keeps clients on their version until
|
|
34
|
+
* it's approved in HawkSearch (unless the app is set to auto-approve).
|
|
35
|
+
* `serverFiles` (the app's checked queries/ files, from the project root) go to the platform's private server bucket
|
|
36
|
+
* with their own upload URLs, never to the CDN.
|
|
37
|
+
* `functions` (checked `{ name, entry, config }` from loadFunctions) are bundled with esbuild, uploaded to the server
|
|
38
|
+
* bucket (bundle.zip, source.zip, deps.json), deployed one by one as Lambda versions, and listed on complete.
|
|
39
|
+
* `audit` runs `npm audit` of the production dependencies before a version is reserved and sends the result with
|
|
40
|
+
* complete, for reviewers. High or critical findings go to `confirmAudit` first (the CLI asks: confirmVulnerabilities).
|
|
41
|
+
*/
|
|
42
|
+
/**
|
|
43
|
+
* @param beforeReserve Runs after the local checks, just before a version number is taken (the slug check: slug-guard.mjs).
|
|
44
|
+
* @param confirmAudit Called with the audit report when it has high or critical vulnerabilities; throws to stop the deploy.
|
|
45
|
+
*/
|
|
46
|
+
export async function deploy({ client, slug, dir, alias, manifest, registerSettings, iconFile = null, storeFiles = [], build = true, serverFiles = [], functions = [], projectDir = process.cwd(), audit = true, runBuild = npmRunBuild, runAudit = npmAudit, runAccessibility = checkAccessibility, beforeReserve, confirmAudit, fetchImpl = fetch, log = console.log }) {
|
|
47
|
+
// Without a build step the folder must already be there: check before reserving a version.
|
|
48
|
+
if (!build) await checkFolder(dir, false);
|
|
49
|
+
|
|
50
|
+
// Bundle the functions first: a function that doesn't build shouldn't cost a version number.
|
|
51
|
+
const built = [];
|
|
52
|
+
if (functions.length) {
|
|
53
|
+
log(`Bundling ${describeFunctions(functions.length)}…`);
|
|
54
|
+
const sourceZip = await zipSources(projectDir);
|
|
55
|
+
for (const fn of functions) {
|
|
56
|
+
const result = await buildFunction({ projectDir, name: fn.name, entry: fn.entry, sourceZip });
|
|
57
|
+
built.push({ ...result, config: fn.config });
|
|
58
|
+
log(` ƒ ${fn.name} ${formatBytes(result.bundleBytes)} zipped, ${result.deps.length} package${result.deps.length === 1 ? '' : 's'}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const functionFiles = built.flatMap((fn) => fn.files);
|
|
62
|
+
|
|
63
|
+
// Dependencies before a version number: a developer can stop and fix them first.
|
|
64
|
+
const { serious, ...report } = audit ? await auditDependencies({ cwd: projectDir, runAudit }) : { status: 'not_checked', reason: 'skipped (--no-audit)' };
|
|
65
|
+
log(` dependencies ${describeAudit(report)}`);
|
|
66
|
+
if (confirmAudit && report.status === 'checked' && report.critical + report.high > 0) await confirmAudit({ ...report, serious });
|
|
67
|
+
|
|
68
|
+
if (beforeReserve) await beforeReserve();
|
|
69
|
+
// The main line: hawk-app.json goes first (scopes, origins, functions…), so the version carries the settings it's
|
|
70
|
+
// deployed with. For an alias, the branch's hawk-app.json goes with the version only.
|
|
71
|
+
if (!alias && registerSettings) await registerSettings();
|
|
72
|
+
const reserved = await client.reserveVersion(slug);
|
|
73
|
+
const version = reserved.version;
|
|
74
|
+
|
|
75
|
+
if (build) {
|
|
76
|
+
log(`Building v${version} of ${slug} (HAWK_APP_BASE_PATH=${reserved.basePath})…`);
|
|
77
|
+
await runBuild({ basePath: reserved.basePath });
|
|
78
|
+
await checkFolder(dir, true);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const files = await listFiles(dir);
|
|
82
|
+
if (!files.length) throw new Error(`"${dir}" is empty.`);
|
|
83
|
+
// The icon and the store screenshots go into the version too (_hawk/icon.svg, _hawk/store/1.png…): a folder of the
|
|
84
|
+
// build can't take their place.
|
|
85
|
+
if (files.some((f) => f.path.startsWith('_hawk/'))) throw new Error(`"${dir}" has a _hawk/ folder: the platform uses that path for the app's icon and store screenshots. Rename it.`);
|
|
86
|
+
if (iconFile) files.push(iconFile);
|
|
87
|
+
files.push(...storeFiles);
|
|
88
|
+
// The usual mistake of an app set up without a template: never stops the deploy (the version is already reserved).
|
|
89
|
+
const outside = reserved.basePath ? await checkBuildUrls(dir, reserved.basePath) : [];
|
|
90
|
+
if (outside.length) log(`! ${describeBuildUrls(outside, reserved.basePath)}`);
|
|
91
|
+
// axe over the built pages, for the approvers' accessibility check. Never stops the deploy.
|
|
92
|
+
const accessibility = await runAccessibility(dir);
|
|
93
|
+
log(` accessibility ${describeAccessibility(accessibility)}`);
|
|
94
|
+
const totalBytes = files.reduce((sum, f) => sum + f.size, 0);
|
|
95
|
+
const queryCount = new Set(serverFiles.map((f) => f.path.replace(/.(sql|json)$/, ''))).size;
|
|
96
|
+
const queriesNote = `${queryCount ? `, ${describeQueries(queryCount)}` : ''}${built.length ? `, ${describeFunctions(built.length)}` : ''}`;
|
|
97
|
+
log(`Uploading v${version} of ${slug}: ${files.length} files, ${formatBytes(totalBytes)} from ${dir}${queriesNote}`);
|
|
98
|
+
|
|
99
|
+
const started = await client.startUploads(
|
|
100
|
+
slug,
|
|
101
|
+
version,
|
|
102
|
+
files.map(({ path, size }) => ({ path, size })),
|
|
103
|
+
serverFiles.map(({ path, size }) => ({ path, size })),
|
|
104
|
+
functionFiles.map(({ path, size }) => ({ path, size })),
|
|
105
|
+
);
|
|
106
|
+
const byPath = new Map(files.map((f) => [f.path, f]));
|
|
107
|
+
const serverByPath = new Map(serverFiles.map((f) => [f.path, f]));
|
|
108
|
+
const functionByPath = new Map(functionFiles.map((f) => [f.path, f]));
|
|
109
|
+
if (serverFiles.length && (started.serverUploads?.length ?? 0) !== serverFiles.length) {
|
|
110
|
+
throw new Error('The platform did not accept the queries/ files. It may not support app queries yet; update it or remove queries/.');
|
|
111
|
+
}
|
|
112
|
+
if (functionFiles.length && (started.functionUploads?.length ?? 0) !== functionFiles.length) {
|
|
113
|
+
throw new Error('The platform did not accept the functions. It may not support app functions yet; update it or remove "functions".');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
let done = 0;
|
|
117
|
+
const queue = [
|
|
118
|
+
...started.uploads.map((upload) => ({ upload, file: byPath.get(upload.path) })),
|
|
119
|
+
...(started.serverUploads ?? []).map((upload) => ({ upload, file: serverByPath.get(upload.path) })),
|
|
120
|
+
...(started.functionUploads ?? []).map((upload) => ({ upload, file: functionByPath.get(upload.path) })),
|
|
121
|
+
];
|
|
122
|
+
const total = queue.length;
|
|
123
|
+
async function worker() {
|
|
124
|
+
for (let item = queue.shift(); item; item = queue.shift()) {
|
|
125
|
+
if (!item.file) throw new Error(`The platform returned an upload for an unknown file: ${item.upload.path}`);
|
|
126
|
+
await uploadWithRetry(item.upload, item.file.data ?? (await readFile(item.file.full)), fetchImpl);
|
|
127
|
+
done += 1;
|
|
128
|
+
if (done % 25 === 0 || done === total) log(` uploaded ${done}/${total}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, queue.length) }, worker));
|
|
132
|
+
|
|
133
|
+
// Each function becomes a Lambda version; the platform waits until it's ready.
|
|
134
|
+
for (const fn of built) {
|
|
135
|
+
const deployed = await client.deployFunction(slug, version, fn.name, fn.config);
|
|
136
|
+
log(` ✔ function ${fn.name} (${describeConfig(fn.config)})${deployed?.lambdaVersion ? ` Lambda version ${deployed.lambdaVersion}` : ''}`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const completed = await client.completeVersion(
|
|
140
|
+
slug,
|
|
141
|
+
version,
|
|
142
|
+
files.map((f) => f.path),
|
|
143
|
+
serverFiles.map((f) => f.path),
|
|
144
|
+
built.map((fn) => fn.name),
|
|
145
|
+
report,
|
|
146
|
+
accessibility,
|
|
147
|
+
alias ? { alias, manifest } : {},
|
|
148
|
+
);
|
|
149
|
+
const n = completed.version ?? version;
|
|
150
|
+
log(`✔ Deployed v${n} of ${slug} (${completed.files ?? files.length} files${queriesNote}).${describeState(completed)}`);
|
|
151
|
+
if (completed.entryUrl) log(` ${completed.entryUrl}`);
|
|
152
|
+
if (built.length && completed.state === 'pending') log(' (Versions with functions always need review, even on auto-approve apps.)');
|
|
153
|
+
if (completed.submissionCleared) log(` ! ${submissionClearedMessage('version')}`);
|
|
154
|
+
if (completed.removedVersions?.length) log(` Removed old versions ${completed.removedVersions.map((v) => `v${v}`).join(', ')}.`);
|
|
155
|
+
if (!alias) await warnAboutScopes({ client, slug, needs: neededScopes(serverFiles, built), log });
|
|
156
|
+
return completed;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Said when a deploy or register replaced what was waiting for approval: the platform cleared the request. */
|
|
160
|
+
export function submissionClearedMessage(what) {
|
|
161
|
+
return `Your new ${what} cleared the waiting approval request: submit it again in HawkSearch (npx hawk-app open).`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** The scopes a version's own files need to run: queries need data:read, functions need functions:invoke. */
|
|
165
|
+
function neededScopes(serverFiles, built) {
|
|
166
|
+
const needs = [];
|
|
167
|
+
if (serverFiles.some((f) => f.path.startsWith('queries/'))) needs.push('data:read');
|
|
168
|
+
if (built.length) needs.push('functions:invoke');
|
|
169
|
+
return needs;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* A version that uses queries or functions while hawk-app.json's scopes (granted or waiting for approval) lack the scope
|
|
174
|
+
* gets 403 scope_missing at run time. Say so now. Best effort: a failed status check never fails a deploy that succeeded.
|
|
175
|
+
*/
|
|
176
|
+
async function warnAboutScopes({ client, slug, needs, log }) {
|
|
177
|
+
if (!needs.length || !client.status) return;
|
|
178
|
+
let app;
|
|
179
|
+
try {
|
|
180
|
+
app = await client.status(slug);
|
|
181
|
+
} catch (error) {
|
|
182
|
+
if (error?.status === 404) log(` ! ${slug} isn't on the platform: add ${needs.join(', ')} to "scopes" in hawk-app.json and deploy again.`);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
const registered = new Set([...(app?.scopes ?? []), ...(app?.pendingScopes ?? [])]);
|
|
186
|
+
const missing = needs.filter((scope) => !registered.has(scope));
|
|
187
|
+
if (missing.length) {
|
|
188
|
+
log(` ! This version uses ${missing.join(' and ')}, which hawk-app.json's scopes on the platform don't include. Add ${missing.length === 1 ? 'it' : 'them'} to "scopes" in hawk-app.json and deploy again, or calls fail with 403 scope_missing.`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function describeQueries(count) {
|
|
193
|
+
return `${count} quer${count === 1 ? 'y' : 'ies'}`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function describeState({ state, liveVersion, alias }) {
|
|
197
|
+
switch (state) {
|
|
198
|
+
case 'alias':
|
|
199
|
+
return ` ${alias} → it: only Bridgeline staff in its clients see it until it's approved. The main line (default, and the version under review) is unchanged.`;
|
|
200
|
+
case 'draft':
|
|
201
|
+
return ' Not released yet: only Bridgeline staff see it. Review it and submit it for approval in HawkSearch: npx hawk-app open.';
|
|
202
|
+
case 'pending':
|
|
203
|
+
return ` A draft: clients stay on ${liveVersion ? `v${liveVersion}` : 'the default version'} until it's approved. Submit it for approval in HawkSearch: npx hawk-app open.`;
|
|
204
|
+
case 'live':
|
|
205
|
+
return ' Auto-approved: released to clients.';
|
|
206
|
+
default:
|
|
207
|
+
return '';
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function checkFolder(dir, built) {
|
|
212
|
+
let info;
|
|
213
|
+
try {
|
|
214
|
+
info = await stat(dir);
|
|
215
|
+
} catch {
|
|
216
|
+
throw new Error(built ? `The build did not create "${dir}". Check the folder name (dist, out, build, ...).` : `Build folder "${dir}" not found.`);
|
|
217
|
+
}
|
|
218
|
+
if (!info.isDirectory()) throw new Error(`"${dir}" is not a folder.`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Runs the project's `npm run build` with the version's base path, so asset URLs point at /<slug>/v<n>/. */
|
|
222
|
+
export async function npmRunBuild({ basePath, cwd = process.cwd() }) {
|
|
223
|
+
const result = spawnSync('npm', ['run', 'build'], {
|
|
224
|
+
cwd,
|
|
225
|
+
stdio: 'inherit',
|
|
226
|
+
shell: process.platform === 'win32',
|
|
227
|
+
env: { ...process.env, HAWK_APP_BASE_PATH: basePath },
|
|
228
|
+
});
|
|
229
|
+
if (result.error) throw new Error(`Could not run \`npm run build\`: ${result.error.message}`);
|
|
230
|
+
if (result.status !== 0) {
|
|
231
|
+
throw new Error(`\`npm run build\` failed (exit code ${result.status}). Fix the build, or pass --no-build to upload the folder as it is.`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Runs `npm audit --json --omit=dev` in the app folder: { status: 'checked', critical, high, moderate, low,
|
|
237
|
+
* dependencies, serious } or { status: 'not_checked', reason }. `serious` (the packages with high or critical findings)
|
|
238
|
+
* is for the developer only: deploy leaves it out of what it sends. Never throws: a deploy doesn't fail because of it. Reasons are
|
|
239
|
+
* fixed texts (no paths, no npm output) so nothing private reaches the platform.
|
|
240
|
+
*/
|
|
241
|
+
export async function auditDependencies({ cwd, runAudit = npmAudit }) {
|
|
242
|
+
const notChecked = (reason) => ({ status: 'not_checked', reason });
|
|
243
|
+
if (!hasLockfile(cwd)) return notChecked('no package-lock.json');
|
|
244
|
+
let stdout;
|
|
245
|
+
try {
|
|
246
|
+
// npm audit exits non-zero when it finds vulnerabilities: the JSON decides, not the exit code.
|
|
247
|
+
({ stdout } = await runAudit({ cwd }));
|
|
248
|
+
} catch (error) {
|
|
249
|
+
return notChecked(error?.code === 'ETIMEDOUT' ? `npm audit timed out after ${AUDIT_TIMEOUT_MS / 1000} s` : 'could not run npm audit');
|
|
250
|
+
}
|
|
251
|
+
let result;
|
|
252
|
+
try {
|
|
253
|
+
result = JSON.parse(stdout);
|
|
254
|
+
} catch {
|
|
255
|
+
return notChecked('npm audit output was not JSON');
|
|
256
|
+
}
|
|
257
|
+
if (result?.error) {
|
|
258
|
+
const code = String(result.error.code ?? '');
|
|
259
|
+
return notChecked(/^[A-Z0-9_]{1,40}$/.test(code) ? `npm audit failed (${code})` : 'npm audit failed');
|
|
260
|
+
}
|
|
261
|
+
const counts = result?.metadata?.vulnerabilities;
|
|
262
|
+
const levels = ['critical', 'high', 'moderate', 'low'];
|
|
263
|
+
if (!counts || !levels.every((level) => Number.isInteger(counts[level]) && counts[level] >= 0)) {
|
|
264
|
+
return notChecked('npm audit output had no vulnerability counts');
|
|
265
|
+
}
|
|
266
|
+
const deps = result.metadata.dependencies;
|
|
267
|
+
const dependencies = [deps?.prod, deps?.total].find((n) => Number.isInteger(n) && n >= 0) ?? 0;
|
|
268
|
+
const serious = Object.entries(result.vulnerabilities ?? {})
|
|
269
|
+
.filter(([, v]) => v?.severity === 'critical' || v?.severity === 'high')
|
|
270
|
+
.map(([name]) => name)
|
|
271
|
+
.sort();
|
|
272
|
+
return { status: 'checked', ...Object.fromEntries(levels.map((level) => [level, counts[level]])), dependencies, serious };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** A package-lock.json in the folder or above it: an npm workspace keeps one lockfile at the repository root, and npm audit finds it from the workspace folder. */
|
|
276
|
+
function hasLockfile(dir) {
|
|
277
|
+
for (let current = dir; ; current = dirname(current)) {
|
|
278
|
+
if (existsSync(join(current, 'package-lock.json'))) return true;
|
|
279
|
+
if (dirname(current) === current) return false;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* deploy's confirmAudit in the CLI: high or critical vulnerabilities fail the review's dependencies check (approvers can
|
|
285
|
+
* only force approve), so ask before deploying. --yes goes on with a warning; so does a pipeline (no terminal to ask).
|
|
286
|
+
*/
|
|
287
|
+
export async function confirmVulnerabilities(report, { yes = false, confirm = askYesNo, log = console.log } = {}) {
|
|
288
|
+
const packages = report.serious?.length ? ` (${report.serious.join(', ')})` : '';
|
|
289
|
+
const message = `npm audit found ${describeAudit(report).replace(' (npm audit)', '')} in the production dependencies${packages}. Approvers can only force approve this version.`;
|
|
290
|
+
if (yes) {
|
|
291
|
+
log(`! ${message}`);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
const answer = await confirm(`${message} Deploy anyway? (Fix with npm audit fix or by updating the packages; npm run app:audit shows the details.)`);
|
|
295
|
+
if (answer === null) {
|
|
296
|
+
log(`! ${message} Going on (no terminal to ask; add --yes to silence this).`);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (!answer) throw new Error('Cancelled. Nothing was deployed. `npm run app:audit` (or npm audit --omit=dev) shows the details.');
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function describeAudit(report) {
|
|
303
|
+
if (report.status !== 'checked') return `not checked: ${report.reason}`;
|
|
304
|
+
const found = ['critical', 'high', 'moderate', 'low'].filter((level) => report[level]).map((level) => `${report[level]} ${level}`);
|
|
305
|
+
return found.length ? `${found.join(', ')} (npm audit)` : 'no known vulnerabilities';
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Runs `npm audit --json --omit=dev` in `cwd`: { code, stdout }. Rejects when npm can't start or takes over `timeoutMs`. */
|
|
309
|
+
export function npmAudit({ cwd, timeoutMs = AUDIT_TIMEOUT_MS }) {
|
|
310
|
+
return new Promise((resolve, reject) => {
|
|
311
|
+
const child = spawn('npm', ['audit', '--json', '--omit=dev'], { cwd, stdio: ['ignore', 'pipe', 'ignore'], shell: process.platform === 'win32' });
|
|
312
|
+
const chunks = [];
|
|
313
|
+
const timer = setTimeout(() => {
|
|
314
|
+
// With a shell on Windows the child is cmd.exe: end the whole tree, not only the shell.
|
|
315
|
+
if (process.platform === 'win32') spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' });
|
|
316
|
+
else child.kill();
|
|
317
|
+
reject(Object.assign(new Error('npm audit timed out'), { code: 'ETIMEDOUT' }));
|
|
318
|
+
}, timeoutMs);
|
|
319
|
+
child.stdout.on('data', (chunk) => chunks.push(chunk));
|
|
320
|
+
child.on('error', (error) => {
|
|
321
|
+
clearTimeout(timer);
|
|
322
|
+
reject(error);
|
|
323
|
+
});
|
|
324
|
+
child.on('close', (code) => {
|
|
325
|
+
clearTimeout(timer);
|
|
326
|
+
resolve({ code, stdout: Buffer.concat(chunks).toString('utf8') });
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async function uploadWithRetry(upload, body, fetchImpl) {
|
|
332
|
+
for (let attempt = 1; ; attempt++) {
|
|
333
|
+
try {
|
|
334
|
+
const response = await fetchImpl(upload.url, { method: 'PUT', headers: upload.headers, body });
|
|
335
|
+
if (response.ok) return;
|
|
336
|
+
const text = await response.text().catch(() => '');
|
|
337
|
+
if (response.status < 500 || attempt >= RETRIES) {
|
|
338
|
+
throw new Error(`Upload of ${upload.path} failed (${response.status}). ${response.status === 403 ? 'The upload link may have expired; run deploy again.' : text.slice(0, 200)}`);
|
|
339
|
+
}
|
|
340
|
+
} catch (error) {
|
|
341
|
+
if (attempt >= RETRIES || error.message?.startsWith('Upload of')) throw error;
|
|
342
|
+
}
|
|
343
|
+
await new Promise((resolve) => setTimeout(resolve, 500 * attempt));
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function formatBytes(bytes) {
|
|
348
|
+
return bytes > 1024 * 1024 ? `${(bytes / 1024 / 1024).toFixed(1)} MB` : `${Math.ceil(bytes / 1024)} KB`;
|
|
349
|
+
}
|