@bridgeline-digital/hawk-app-cli 0.0.0-stage → 0.1.0-dev.20261020205204
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 -0
- package/README.md +769 -2
- package/agents/AGENTS.md +192 -0
- package/agents/project/custom.md +21 -0
- package/agents/project/next-mui.md +21 -0
- package/agents/project/next.md +24 -0
- package/agents/project/vanilla.md +22 -0
- package/agents/project/vite.md +19 -0
- package/bin/hawk-app.mjs +10 -0
- package/docs/bridge/README.md +1315 -0
- package/docs/showcase/README.md +47 -0
- package/docs/showcase/functions/echo.ts +12 -0
- package/docs/showcase/functions/long-task.ts +31 -0
- package/docs/showcase/functions/showcase.ts +186 -0
- package/docs/showcase/hawk-app.json +73 -0
- package/docs/showcase/queries/assistant-prompts-by-day.json +6 -0
- package/docs/showcase/queries/assistant-prompts-by-day.sql +8 -0
- package/docs/showcase/queries/searches-by-day.json +6 -0
- package/docs/showcase/queries/searches-by-day.sql +7 -0
- package/docs/showcase/src/App.tsx +1436 -0
- package/docs/showcase/src/main.tsx +10 -0
- package/docs/showcase/src/styles.css +71 -0
- package/docs/showcase/src/useHawkApp.ts +44 -0
- package/docs/showcase/src/vite-env.d.ts +1 -0
- package/docs/ui/README.md +166 -0
- package/package.json +50 -4
- package/schema/hawk-app.schema.json +611 -0
- package/src/accessibility.mjs +105 -0
- package/src/agents.mjs +145 -0
- package/src/aliases.mjs +30 -0
- package/src/app-icon.mjs +82 -0
- package/src/channel.mjs +116 -0
- package/src/cli.mjs +825 -0
- package/src/clients.mjs +221 -0
- package/src/config.mjs +148 -0
- package/src/create.mjs +186 -0
- package/src/credentials.mjs +56 -0
- package/src/data.mjs +169 -0
- package/src/deploy.mjs +349 -0
- package/src/dev.mjs +459 -0
- package/src/doctor.mjs +176 -0
- package/src/env.mjs +140 -0
- package/src/functions.mjs +313 -0
- package/src/login.mjs +166 -0
- package/src/logs.mjs +64 -0
- package/src/manifest.mjs +425 -0
- package/src/mcp-docs.mjs +168 -0
- package/src/mcp-hawksearch.mjs +342 -0
- package/src/mcp-reports.mjs +130 -0
- package/src/mcp-rules.mjs +109 -0
- package/src/mcp-ui.mjs +73 -0
- package/src/mcp.mjs +539 -0
- package/src/platform.mjs +116 -0
- package/src/project-checks.mjs +153 -0
- package/src/queries.mjs +289 -0
- package/src/scope-check.mjs +91 -0
- package/src/secrets.mjs +86 -0
- package/src/slug-guard.mjs +80 -0
- package/src/sso.mjs +182 -0
- package/src/store-listing.mjs +106 -0
- package/src/telemetry.mjs +50 -0
- package/src/template-history.json +34 -0
- package/src/upgrade.mjs +406 -0
- package/src/versions.mjs +102 -0
- package/templates/next/next.config.ts +13 -0
- package/templates/next/package.json +26 -0
- package/templates/next/src/app/globals.css +5 -0
- package/templates/next/src/app/layout.tsx +14 -0
- package/templates/next/src/app/page.tsx +86 -0
- package/templates/next/src/hawk-elements.d.ts +10 -0
- package/templates/next/src/lib/useHawkApp.ts +46 -0
- package/templates/next/tsconfig.json +21 -0
- package/templates/next-mui/next.config.ts +13 -0
- package/templates/next-mui/package.json +31 -0
- package/templates/next-mui/src/app/layout.tsx +20 -0
- package/templates/next-mui/src/app/page.tsx +79 -0
- package/templates/next-mui/src/app/providers.tsx +11 -0
- package/templates/next-mui/tsconfig.json +21 -0
- package/templates/preact/index.html +12 -0
- package/templates/preact/package.json +26 -0
- package/templates/preact/src/App.tsx +83 -0
- package/templates/preact/src/hawk-elements.d.ts +10 -0
- package/templates/preact/src/main.tsx +8 -0
- package/templates/preact/src/styles.css +5 -0
- package/templates/preact/src/useHawkApp.ts +44 -0
- package/templates/preact/src/vite-env.d.ts +1 -0
- package/templates/preact/tsconfig.json +17 -0
- package/templates/preact/vite.config.ts +11 -0
- package/templates/react/index.html +12 -0
- package/templates/react/package.json +29 -0
- package/templates/react/src/App.tsx +83 -0
- package/templates/react/src/hawk-elements.d.ts +10 -0
- package/templates/react/src/main.tsx +13 -0
- package/templates/react/src/styles.css +5 -0
- package/templates/react/src/useHawkApp.ts +44 -0
- package/templates/react/src/vite-env.d.ts +1 -0
- package/templates/react/tsconfig.json +16 -0
- package/templates/react/vite.config.ts +11 -0
- package/templates/vanilla/package.json +15 -0
- package/templates/vanilla/public/app.js +59 -0
- package/templates/vanilla/public/index.html +37 -0
- package/templates/vanilla/public/styles.css +5 -0
package/src/env.mjs
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { credentialsPath, normalizeUrl, readCredentials } from './credentials.mjs';
|
|
5
|
+
|
|
6
|
+
export const ENV_FILES = ['.env.local', '.env'];
|
|
7
|
+
|
|
8
|
+
/** The platform of each environment (`--env` / HAWK_APPS_ENV). */
|
|
9
|
+
export const PLATFORMS = {
|
|
10
|
+
dev: 'https://api-dev.hawksearchapps.com',
|
|
11
|
+
prod: 'https://api.hawksearchapps.com',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** Dev, unless a command, the environment or the project says otherwise: prod is never a default. */
|
|
15
|
+
export const DEFAULT_PLATFORM_URL = PLATFORMS.dev;
|
|
16
|
+
|
|
17
|
+
/** The URL of an environment name; throws for an unknown one (a typo must not fall back to dev or prod). */
|
|
18
|
+
export function platformForEnv(name, source) {
|
|
19
|
+
if (!name) return undefined;
|
|
20
|
+
const url = PLATFORMS[String(name).trim().toLowerCase()];
|
|
21
|
+
if (!url) throw new Error(`Unknown environment "${name}" (${source}): use ${Object.keys(PLATFORMS).join(' or ')}.`);
|
|
22
|
+
return url;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** "prod (https://…)" for a known platform, else the URL. */
|
|
26
|
+
export function describePlatform(url) {
|
|
27
|
+
const name = Object.entries(PLATFORMS).find(([, u]) => normalizeUrl(u) === normalizeUrl(url))?.[0];
|
|
28
|
+
return name ? `${name} (${url})` : String(url);
|
|
29
|
+
}
|
|
30
|
+
const PREFIX = 'HAWK_APPS_';
|
|
31
|
+
|
|
32
|
+
/** Parses KEY=VALUE lines; supports comments, blank lines, `export KEY=...` and single/double quotes. */
|
|
33
|
+
export function parseEnv(text) {
|
|
34
|
+
const values = {};
|
|
35
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
36
|
+
const line = raw.trim();
|
|
37
|
+
if (!line || line.startsWith('#')) continue;
|
|
38
|
+
const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
|
|
39
|
+
if (!match) continue;
|
|
40
|
+
let value = match[2].trim();
|
|
41
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
42
|
+
value = value.slice(1, -1);
|
|
43
|
+
} else {
|
|
44
|
+
value = value.replace(/\s+#.*$/, '');
|
|
45
|
+
}
|
|
46
|
+
values[match[1]] = value;
|
|
47
|
+
}
|
|
48
|
+
return values;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Loads HAWK_APPS_* settings from .env.local / .env in `dir`. Variables already set in the environment win,
|
|
53
|
+
* so CI secrets always take precedence. Only HAWK_APPS_* keys are applied.
|
|
54
|
+
* @returns {{ loaded: string[], warnings: string[] }}
|
|
55
|
+
*/
|
|
56
|
+
export function loadEnv(dir = process.cwd(), env = process.env, isIgnored = gitIgnored) {
|
|
57
|
+
const loaded = [];
|
|
58
|
+
const warnings = [];
|
|
59
|
+
for (const file of ENV_FILES) {
|
|
60
|
+
const path = join(dir, file);
|
|
61
|
+
if (!existsSync(path)) continue;
|
|
62
|
+
const values = parseEnv(readFileSync(path, 'utf8'));
|
|
63
|
+
for (const [key, value] of Object.entries(values)) {
|
|
64
|
+
if (!key.startsWith(PREFIX) || !value || env[key] !== undefined) continue;
|
|
65
|
+
env[key] = value;
|
|
66
|
+
loaded.push(`${key} (${file})`);
|
|
67
|
+
}
|
|
68
|
+
if (values.HAWK_APPS_PUBLISHER_KEY && isIgnored(dir, file) === false) {
|
|
69
|
+
warnings.push(`${file} contains your publisher key but is not git-ignored. Add it to .gitignore so the key is never committed.`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return { loaded, warnings };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Where the platform URL and publisher key come from, highest priority first:
|
|
77
|
+
* URL: --platform / --env, HAWK_APPS_PLATFORM_URL / HAWK_APPS_ENV, project .env.local / .env (the same two), dev.
|
|
78
|
+
* `hawk-app login` never changes it: logging in to prod doesn't make later commands use prod.
|
|
79
|
+
* Key: HAWK_APPS_PUBLISHER_KEY, project .env.local / .env, the key saved by `hawk-app login` for that URL.
|
|
80
|
+
*/
|
|
81
|
+
// credentialsFile defaults to this machine's `hawk-app login` file, so every command sees the saved login (not just whoami).
|
|
82
|
+
export function resolveSettings({ flagPlatform, flagEnv, env = process.env, dir = process.cwd(), credentialsFile = credentialsPath(env) } = {}) {
|
|
83
|
+
if (flagPlatform && flagEnv) throw new Error('Use --env or --platform, not both.');
|
|
84
|
+
const fileValues = {};
|
|
85
|
+
const fileSource = {};
|
|
86
|
+
for (const file of [...ENV_FILES].reverse()) {
|
|
87
|
+
const path = join(dir, file);
|
|
88
|
+
if (!existsSync(path)) continue;
|
|
89
|
+
for (const [key, value] of Object.entries(parseEnv(readFileSync(path, 'utf8')))) {
|
|
90
|
+
if (key.startsWith(PREFIX) && value) {
|
|
91
|
+
fileValues[key] = value;
|
|
92
|
+
fileSource[key] = file;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const credentials = readCredentials(credentialsFile);
|
|
97
|
+
|
|
98
|
+
const [url, urlSource] = first([
|
|
99
|
+
[flagPlatform, '--platform'],
|
|
100
|
+
[platformForEnv(flagEnv, '--env'), '--env'],
|
|
101
|
+
[env.HAWK_APPS_PLATFORM_URL, 'environment'],
|
|
102
|
+
[platformForEnv(env.HAWK_APPS_ENV, 'HAWK_APPS_ENV'), 'HAWK_APPS_ENV'],
|
|
103
|
+
[fileValues.HAWK_APPS_PLATFORM_URL, fileSource.HAWK_APPS_PLATFORM_URL],
|
|
104
|
+
[platformForEnv(fileValues.HAWK_APPS_ENV, fileSource.HAWK_APPS_ENV), fileSource.HAWK_APPS_ENV],
|
|
105
|
+
[DEFAULT_PLATFORM_URL, 'default (dev)'],
|
|
106
|
+
]);
|
|
107
|
+
const platform = normalizeUrl(url);
|
|
108
|
+
const [key, keySource] = first([
|
|
109
|
+
[env.HAWK_APPS_PUBLISHER_KEY, 'environment'],
|
|
110
|
+
[fileValues.HAWK_APPS_PUBLISHER_KEY, fileSource.HAWK_APPS_PUBLISHER_KEY],
|
|
111
|
+
[credentials.platforms[platform]?.publisherKey, credentialsFile],
|
|
112
|
+
]);
|
|
113
|
+
return { url: platform, urlSource, key, keySource, credentialsFile };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function first(candidates) {
|
|
117
|
+
return candidates.find(([value]) => value) ?? [undefined, undefined];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** true / false inside a git repo; undefined when git isn't available or this isn't a repo. */
|
|
121
|
+
function gitIgnored(dir, file) {
|
|
122
|
+
const result = spawnSync('git', ['check-ignore', '-q', file], { cwd: dir, stdio: 'ignore' });
|
|
123
|
+
if (result.error || result.status === 128) return undefined;
|
|
124
|
+
return result.status === 0;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export const ENV_EXAMPLE = `# Optional per-project overrides. Normally you don't need this file: run \`npx hawk-app sso\` once per machine.
|
|
128
|
+
# To override for this project only, copy to .env.local (git-ignored) and uncomment.
|
|
129
|
+
# Environment variables (e.g. CI secrets) take precedence over both.
|
|
130
|
+
|
|
131
|
+
# The environment this project deploys to (dev unless set; or pass --env prod to a command).
|
|
132
|
+
# HAWK_APPS_ENV=prod
|
|
133
|
+
|
|
134
|
+
# Publisher key for this project only. Prefer \`npx hawk-app login --local\` so it isn't typed into a file. Never commit it.
|
|
135
|
+
# HAWK_APPS_PUBLISHER_KEY=
|
|
136
|
+
|
|
137
|
+
# The client (and site) \`hawk-app dev\` runs the app with; it asks the first time and saves the client here.
|
|
138
|
+
# HAWK_APPS_DEV_CLIENT=
|
|
139
|
+
# HAWK_APPS_DEV_ENGINE=
|
|
140
|
+
`;
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
5
|
+
import { zipSync } from 'fflate';
|
|
6
|
+
|
|
7
|
+
// App functions (`functions:invoke`): functions/<name>.ts (or .js/.mts/.mjs) next to hawk-app.json, declared under
|
|
8
|
+
// "functions" in the manifest, bundled with esbuild into one index.mjs per function and deployed with each version
|
|
9
|
+
// as an AWS Lambda version. Same limits as the platform, which validates again on deploy.
|
|
10
|
+
export const FUNCTIONS_DIR = 'functions';
|
|
11
|
+
export const FUNCTIONS_SCOPE = 'functions:invoke';
|
|
12
|
+
export const BRIDGE_PACKAGE = '@bridgeline-digital/hawk-app-bridge';
|
|
13
|
+
export const MAX_FUNCTIONS = 10;
|
|
14
|
+
export const MAX_NAME_LENGTH = 40;
|
|
15
|
+
export const MAX_SECRETS = 20;
|
|
16
|
+
export const TIMEOUT = { min: 1, max: 25, default: 10 };
|
|
17
|
+
/** Background jobs ("job": true): started with app.functions.start, not called, so they may run longer. */
|
|
18
|
+
export const JOB_TIMEOUT = { min: 1, max: 900, default: 300 };
|
|
19
|
+
export const MEMORY = { min: 128, max: 1024, default: 256 };
|
|
20
|
+
export const MAX_BUNDLE_BYTES = 10 * 1024 * 1024;
|
|
21
|
+
export const MAX_SOURCE_BYTES = 5 * 1024 * 1024;
|
|
22
|
+
export const ENTRY_EXTENSIONS = ['.ts', '.mts', '.js', '.mjs'];
|
|
23
|
+
|
|
24
|
+
export const FUNCTION_NAME = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
25
|
+
export const SECRET_NAME = /^[A-Z][A-Z0-9_]{0,63}$/;
|
|
26
|
+
const CONFIG_KEYS = ['job', 'timeoutSeconds', 'memoryMb', 'secrets', 'clientSecrets'];
|
|
27
|
+
const MAX_CLIENT_SECRETS = 10;
|
|
28
|
+
/** Fixed zip entry time, so a zip's bytes (and the Lambda codeSha256) only change when the code does. */
|
|
29
|
+
const ZIP_TIME = new Date(1980, 0, 1, 0, 0, 0);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Checks the manifest's "functions" block (names, settings, the functions:invoke scope).
|
|
33
|
+
* @returns {string[]} errors
|
|
34
|
+
*/
|
|
35
|
+
export function validateFunctionsManifest(manifest) {
|
|
36
|
+
const errors = [];
|
|
37
|
+
const functions = manifest?.functions;
|
|
38
|
+
if (functions === undefined) return errors;
|
|
39
|
+
if (!functions || typeof functions !== 'object' || Array.isArray(functions)) {
|
|
40
|
+
errors.push('functions must be an object: { "<name>": { "timeoutSeconds": 10, "memoryMb": 256, "secrets": [] } }.');
|
|
41
|
+
return errors;
|
|
42
|
+
}
|
|
43
|
+
const names = Object.keys(functions);
|
|
44
|
+
if (names.length > MAX_FUNCTIONS) errors.push(`functions: at most ${MAX_FUNCTIONS} functions per app (found ${names.length}).`);
|
|
45
|
+
for (const name of names) {
|
|
46
|
+
const where = `functions.${name}`;
|
|
47
|
+
if (!FUNCTION_NAME.test(name) || name.length > MAX_NAME_LENGTH) {
|
|
48
|
+
errors.push(`${where}: function names must be 1-${MAX_NAME_LENGTH} lowercase letters, digits and single dashes (e.g. "sync-feed").`);
|
|
49
|
+
}
|
|
50
|
+
const config = functions[name];
|
|
51
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) {
|
|
52
|
+
errors.push(`${where} must be an object, e.g. { "timeoutSeconds": 10, "memoryMb": 256, "secrets": [] } (or {} for the defaults).`);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const unknown = Object.keys(config).filter((key) => !CONFIG_KEYS.includes(key));
|
|
56
|
+
if (unknown.length) errors.push(`${where}: unknown setting${unknown.length === 1 ? '' : 's'} ${unknown.map((k) => `"${k}"`).join(', ')} (allowed: ${CONFIG_KEYS.join(', ')}).`);
|
|
57
|
+
if (config.job !== undefined && typeof config.job !== 'boolean') errors.push(`${where}.job must be true or false.`);
|
|
58
|
+
const timeout = config.job === true ? JOB_TIMEOUT : TIMEOUT;
|
|
59
|
+
if (config.timeoutSeconds !== undefined && !isIntIn(config.timeoutSeconds, timeout)) {
|
|
60
|
+
const hint = config.job === true ? '' : `; up to ${JOB_TIMEOUT.max} with "job": true`;
|
|
61
|
+
errors.push(`${where}.timeoutSeconds must be a whole number from ${timeout.min} to ${timeout.max} (default ${timeout.default}${hint}).`);
|
|
62
|
+
}
|
|
63
|
+
if (config.memoryMb !== undefined && !isIntIn(config.memoryMb, MEMORY)) {
|
|
64
|
+
errors.push(`${where}.memoryMb must be a whole number from ${MEMORY.min} to ${MEMORY.max} (default ${MEMORY.default}).`);
|
|
65
|
+
}
|
|
66
|
+
if (config.secrets !== undefined) {
|
|
67
|
+
const secrets = config.secrets;
|
|
68
|
+
if (!Array.isArray(secrets)) {
|
|
69
|
+
errors.push(`${where}.secrets must be a list of names, e.g. ["FEED_API_KEY"].`);
|
|
70
|
+
} else {
|
|
71
|
+
const bad = secrets.filter((s) => typeof s !== 'string' || !SECRET_NAME.test(s));
|
|
72
|
+
if (bad.length) errors.push(`${where}.secrets: ${bad.map((s) => JSON.stringify(s)).join(', ')} must be UPPER_SNAKE_CASE names (a letter, then letters, digits or _; up to 64).`);
|
|
73
|
+
const duplicates = [...new Set(secrets.filter((s, i) => secrets.indexOf(s) !== i))];
|
|
74
|
+
if (duplicates.length) errors.push(`${where}.secrets lists ${duplicates.join(', ')} more than once.`);
|
|
75
|
+
if (secrets.length > MAX_SECRETS) errors.push(`${where}.secrets: at most ${MAX_SECRETS} secrets per function.`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (config.clientSecrets !== undefined) {
|
|
79
|
+
const names = config.clientSecrets;
|
|
80
|
+
if (!Array.isArray(names)) {
|
|
81
|
+
errors.push(`${where}.clientSecrets must be a list of names, e.g. ["MAILCHIMP_API_KEY"].`);
|
|
82
|
+
} else {
|
|
83
|
+
const bad = names.filter((s) => typeof s !== 'string' || !SECRET_NAME.test(s));
|
|
84
|
+
if (bad.length) errors.push(`${where}.clientSecrets: ${bad.map((s) => JSON.stringify(s)).join(', ')} must be UPPER_SNAKE_CASE names (a letter, then letters, digits or _; up to 64).`);
|
|
85
|
+
const duplicates = [...new Set(names.filter((s, i) => names.indexOf(s) !== i))];
|
|
86
|
+
if (duplicates.length) errors.push(`${where}.clientSecrets lists ${duplicates.join(', ')} more than once.`);
|
|
87
|
+
if (names.length > MAX_CLIENT_SECRETS) errors.push(`${where}.clientSecrets: at most ${MAX_CLIENT_SECRETS} client secrets per function.`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (names.length && !(manifest.scopes ?? []).includes(FUNCTIONS_SCOPE)) {
|
|
92
|
+
errors.push(`functions need the "${FUNCTIONS_SCOPE}" scope: add it to "scopes" in hawk-app.json.`);
|
|
93
|
+
}
|
|
94
|
+
return errors;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const isIntIn = (value, { min, max }) => Number.isInteger(value) && value >= min && value <= max;
|
|
98
|
+
|
|
99
|
+
/** A function's settings with the defaults filled in, as sent to the platform. */
|
|
100
|
+
export function functionConfig(config = {}) {
|
|
101
|
+
const job = config.job === true;
|
|
102
|
+
return {
|
|
103
|
+
job,
|
|
104
|
+
timeoutSeconds: config.timeoutSeconds ?? (job ? JOB_TIMEOUT : TIMEOUT).default,
|
|
105
|
+
memoryMb: config.memoryMb ?? MEMORY.default,
|
|
106
|
+
secrets: config.secrets ?? [],
|
|
107
|
+
clientSecrets: config.clientSecrets ?? [],
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Matches the manifest's functions with the entry files in `<projectDir>/functions`. Null when the app has neither.
|
|
113
|
+
* Every declared function needs functions/<name>.(ts|mts|js|mjs), and every entry file there needs a manifest entry
|
|
114
|
+
* (shared code goes in subfolders such as functions/lib/).
|
|
115
|
+
* @returns {Promise<{ functions: { name: string, entry: string, config: object }[], errors: string[] } | null>}
|
|
116
|
+
*/
|
|
117
|
+
export async function loadFunctions(projectDir, manifest) {
|
|
118
|
+
const declared = manifest?.functions && typeof manifest.functions === 'object' && !Array.isArray(manifest.functions) ? manifest.functions : {};
|
|
119
|
+
const dir = join(projectDir, FUNCTIONS_DIR);
|
|
120
|
+
const hasDir = existsSync(dir);
|
|
121
|
+
if (!hasDir && !Object.keys(declared).length) return null;
|
|
122
|
+
|
|
123
|
+
const errors = [];
|
|
124
|
+
const entries = new Map();
|
|
125
|
+
if (hasDir) {
|
|
126
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
127
|
+
if (!entry.isFile() || entry.name.startsWith('.') || entry.name.endsWith('.d.ts')) continue;
|
|
128
|
+
const ext = ENTRY_EXTENSIONS.find((e) => entry.name.endsWith(e));
|
|
129
|
+
if (!ext) continue;
|
|
130
|
+
const name = entry.name.slice(0, -ext.length);
|
|
131
|
+
if (entries.has(name)) {
|
|
132
|
+
errors.push(`${FUNCTIONS_DIR}/: ${name} has more than one entry file (${entries.get(name).file} and ${entry.name}); keep one.`);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
entries.set(name, { file: entry.name, full: join(dir, entry.name) });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const functions = [];
|
|
140
|
+
for (const name of Object.keys(declared).sort()) {
|
|
141
|
+
const entry = entries.get(name);
|
|
142
|
+
if (!entry) {
|
|
143
|
+
errors.push(`functions.${name} has no entry file: add ${FUNCTIONS_DIR}/${name}.ts (or .js/.mts/.mjs) exporting \`handler\`.`);
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
functions.push({ name, entry: entry.full, config: functionConfig(declared[name]) });
|
|
147
|
+
}
|
|
148
|
+
for (const [name, entry] of entries) {
|
|
149
|
+
if (!(name in declared)) {
|
|
150
|
+
errors.push(`${FUNCTIONS_DIR}/${entry.file} is not declared in hawk-app.json: add "${name}": {} under "functions", or move shared code into a subfolder (e.g. ${FUNCTIONS_DIR}/lib/).`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (hasDir && !entries.size && !Object.keys(declared).length) {
|
|
154
|
+
errors.push(`${FUNCTIONS_DIR}/ has no functions: add ${FUNCTIONS_DIR}/<name>.ts and declare it under "functions" in hawk-app.json, or remove the folder.`);
|
|
155
|
+
}
|
|
156
|
+
return { functions, errors };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Checks that the app has a bridge with the `/server` entry (the wrapper imports `toLambdaHandler` from it).
|
|
161
|
+
* @returns {string | null} an error message, or null when it's fine
|
|
162
|
+
*/
|
|
163
|
+
export function checkBridge(projectDir) {
|
|
164
|
+
let packageFile;
|
|
165
|
+
try {
|
|
166
|
+
packageFile = createRequire(join(projectDir, 'package.json')).resolve(`${BRIDGE_PACKAGE}/package.json`);
|
|
167
|
+
} catch {
|
|
168
|
+
return `Functions need ${BRIDGE_PACKAGE} in the app: run \`npm install ${BRIDGE_PACKAGE}\`.`;
|
|
169
|
+
}
|
|
170
|
+
const pkg = JSON.parse(readFileSync(packageFile, 'utf8'));
|
|
171
|
+
if (!pkg.exports?.['./server']) {
|
|
172
|
+
return `${BRIDGE_PACKAGE} ${pkg.version} has no "/server" entry: update it (\`npm install ${BRIDGE_PACKAGE}@latest\`).`;
|
|
173
|
+
}
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The generated entry: the user's module wrapped as the Lambda handler. */
|
|
178
|
+
export function wrapperSource(entry) {
|
|
179
|
+
const userModule = entry.split(sep).join('/');
|
|
180
|
+
return [
|
|
181
|
+
`import { toLambdaHandler } from '${BRIDGE_PACKAGE}/server';`,
|
|
182
|
+
`import { handler as userHandler } from ${JSON.stringify(userModule)};`,
|
|
183
|
+
'export const handler = toLambdaHandler(userHandler);',
|
|
184
|
+
'',
|
|
185
|
+
].join('\n');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Bundles one function (esbuild: node22, ESM, every dependency included) and packs what the platform needs:
|
|
190
|
+
* `bundle.zip` (index.mjs exporting `handler`), `source.zip` (every file under functions/, for review) and
|
|
191
|
+
* `deps.json` ([{ name, version }] of the packages in the bundle).
|
|
192
|
+
* @returns {Promise<{ name: string, files: { path: string, size: number, data: Uint8Array }[], bundleBytes: number, deps: { name: string, version: string }[] }>}
|
|
193
|
+
*/
|
|
194
|
+
export async function buildFunction({ projectDir, name, entry, sourceZip }) {
|
|
195
|
+
const problem = checkBridge(projectDir);
|
|
196
|
+
if (problem) throw new Error(problem);
|
|
197
|
+
const esbuild = await import('esbuild');
|
|
198
|
+
let result;
|
|
199
|
+
try {
|
|
200
|
+
result = await esbuild.build({
|
|
201
|
+
stdin: { contents: wrapperSource(entry), resolveDir: projectDir, sourcefile: `hawk-function-${name}.mjs`, loader: 'js' },
|
|
202
|
+
absWorkingDir: projectDir,
|
|
203
|
+
bundle: true,
|
|
204
|
+
platform: 'node',
|
|
205
|
+
target: 'node22',
|
|
206
|
+
format: 'esm',
|
|
207
|
+
outfile: 'index.mjs',
|
|
208
|
+
write: false,
|
|
209
|
+
metafile: true,
|
|
210
|
+
logLevel: 'silent',
|
|
211
|
+
legalComments: 'none',
|
|
212
|
+
// CommonJS dependencies that call require() need it in an ES module.
|
|
213
|
+
banner: { js: "import { createRequire as __hawkCreateRequire } from 'node:module';\nconst require = __hawkCreateRequire(import.meta.url);" },
|
|
214
|
+
});
|
|
215
|
+
} catch (error) {
|
|
216
|
+
const messages = (error.errors ?? []).map((e) => ` ${e.location ? `${e.location.file}:${e.location.line}: ` : ''}${e.text}`);
|
|
217
|
+
const missingHandler = (error.errors ?? []).some((e) => /No matching export .* for import "handler"/.test(e.text));
|
|
218
|
+
throw new Error(
|
|
219
|
+
`Could not bundle function "${name}"${missingHandler ? ` (${FUNCTIONS_DIR}/${name} must export \`handler\`)` : ''}:\n${messages.join('\n') || ` ${error.message}`}`,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
const code = result.outputFiles.find((f) => f.path.endsWith('index.mjs')).contents;
|
|
223
|
+
const bundle = zip({ 'index.mjs': code });
|
|
224
|
+
if (bundle.length > MAX_BUNDLE_BYTES) {
|
|
225
|
+
throw new Error(`Function "${name}" is ${formatMb(bundle.length)} zipped; the limit is ${formatMb(MAX_BUNDLE_BYTES)}. Drop large dependencies.`);
|
|
226
|
+
}
|
|
227
|
+
const deps = dependencies(result.metafile, projectDir);
|
|
228
|
+
const depsJson = new TextEncoder().encode(JSON.stringify(deps, null, 2) + '\n');
|
|
229
|
+
const source = sourceZip ?? (await zipSources(projectDir));
|
|
230
|
+
const prefix = `${FUNCTIONS_DIR}/${name}`;
|
|
231
|
+
return {
|
|
232
|
+
name,
|
|
233
|
+
bundleBytes: bundle.length,
|
|
234
|
+
deps,
|
|
235
|
+
files: [
|
|
236
|
+
{ path: `${prefix}/bundle.zip`, size: bundle.length, data: bundle },
|
|
237
|
+
{ path: `${prefix}/source.zip`, size: source.length, data: source },
|
|
238
|
+
{ path: `${prefix}/deps.json`, size: depsJson.length, data: depsJson },
|
|
239
|
+
],
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Zips every file under functions/ (no node_modules or hidden files), with paths like functions/lib/feed.ts. */
|
|
244
|
+
export async function zipSources(projectDir) {
|
|
245
|
+
const root = join(projectDir, FUNCTIONS_DIR);
|
|
246
|
+
const files = {};
|
|
247
|
+
async function walk(dir) {
|
|
248
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
249
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
|
250
|
+
const full = join(dir, entry.name);
|
|
251
|
+
if (entry.isDirectory()) await walk(full);
|
|
252
|
+
else if (entry.isFile()) files[relative(projectDir, full).split(sep).join('/')] = new Uint8Array(await readFile(full));
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
if (existsSync(root)) await walk(root);
|
|
256
|
+
const zipped = zip(files);
|
|
257
|
+
if (zipped.length > MAX_SOURCE_BYTES) {
|
|
258
|
+
throw new Error(`${FUNCTIONS_DIR}/ is ${formatMb(zipped.length)} zipped; the limit is ${formatMb(MAX_SOURCE_BYTES)}. Keep data files and builds out of it.`);
|
|
259
|
+
}
|
|
260
|
+
return zipped;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** A zip with sorted entries and fixed timestamps: the same files always give the same bytes. */
|
|
264
|
+
export function zip(files) {
|
|
265
|
+
const sorted = {};
|
|
266
|
+
for (const path of Object.keys(files).sort()) sorted[path] = [files[path], { mtime: ZIP_TIME }];
|
|
267
|
+
return zipSync(sorted, { level: 9, mtime: ZIP_TIME });
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** The packages in the bundle, from the esbuild metafile: every input outside the app's own code, by its package.json. */
|
|
271
|
+
export function dependencies(metafile, projectDir) {
|
|
272
|
+
const found = new Map();
|
|
273
|
+
const appModules = join(projectDir, 'node_modules');
|
|
274
|
+
for (const input of Object.keys(metafile.inputs)) {
|
|
275
|
+
// <stdin> is the generated wrapper; "ns:…" inputs come from other esbuild namespaces, not files.
|
|
276
|
+
if (input.startsWith('<') || /^[a-z-]{2,}:/i.test(input)) continue;
|
|
277
|
+
const full = resolve(projectDir, input);
|
|
278
|
+
const inApp = !relative(projectDir, full).startsWith('..') && !full.startsWith(appModules + sep);
|
|
279
|
+
if (inApp) continue;
|
|
280
|
+
const pkg = nearestPackage(dirname(full));
|
|
281
|
+
if (pkg?.name && !found.has(pkg.name)) found.set(pkg.name, pkg.version ?? 'unknown');
|
|
282
|
+
}
|
|
283
|
+
return [...found].sort(([a], [b]) => a.localeCompare(b)).map(([name, version]) => ({ name, version }));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function nearestPackage(dir) {
|
|
287
|
+
for (let current = dir; ; current = dirname(current)) {
|
|
288
|
+
const file = join(current, 'package.json');
|
|
289
|
+
if (existsSync(file)) {
|
|
290
|
+
try {
|
|
291
|
+
const pkg = JSON.parse(readFileSync(file, 'utf8'));
|
|
292
|
+
// Nested package.json files that only set "type" (e.g. dist/esm/package.json) aren't the package.
|
|
293
|
+
if (pkg.name) return pkg;
|
|
294
|
+
} catch {
|
|
295
|
+
// Unreadable: keep looking up.
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
if (dirname(current) === current) return null;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function describeFunctions(count) {
|
|
303
|
+
return `${count} function${count === 1 ? '' : 's'}`;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** "20 s, 512 MB, secrets FEED_API_KEY" ("job, 300 s, …" for a background job) */
|
|
307
|
+
export function describeConfig(config) {
|
|
308
|
+
return `${config.job ? 'job, ' : ''}${config.timeoutSeconds} s, ${config.memoryMb} MB${config.secrets.length ? `, secrets ${config.secrets.join(' ')}` : ''}${config.clientSecrets?.length ? `, client secrets ${config.clientSecrets.join(' ')}` : ''}`;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function formatMb(bytes) {
|
|
312
|
+
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
313
|
+
}
|
package/src/login.mjs
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { appendFile, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { credentialsPath, removeCredential, saveCredential } from './credentials.mjs';
|
|
6
|
+
import { describePlatform, resolveSettings } from './env.mjs';
|
|
7
|
+
|
|
8
|
+
/** Reads a line from the terminal without echoing it. */
|
|
9
|
+
export function promptHidden(
|
|
10
|
+
question,
|
|
11
|
+
{
|
|
12
|
+
input = process.stdin,
|
|
13
|
+
output = process.stdout,
|
|
14
|
+
notInteractive = '`hawk-app login` needs an interactive terminal. In CI, set HAWK_APPS_PUBLISHER_KEY as a secured variable instead.',
|
|
15
|
+
} = {},
|
|
16
|
+
) {
|
|
17
|
+
if (!input.isTTY) {
|
|
18
|
+
return Promise.reject(new Error(notInteractive));
|
|
19
|
+
}
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
let value = '';
|
|
22
|
+
output.write(question);
|
|
23
|
+
input.setRawMode(true);
|
|
24
|
+
input.resume();
|
|
25
|
+
input.setEncoding('utf8');
|
|
26
|
+
const onData = (chunk) => {
|
|
27
|
+
for (const ch of chunk) {
|
|
28
|
+
if (ch === '\r' || ch === '\n') {
|
|
29
|
+
finish();
|
|
30
|
+
return resolve(value.trim());
|
|
31
|
+
}
|
|
32
|
+
if (ch === '\u0003') {
|
|
33
|
+
finish();
|
|
34
|
+
return reject(new Error('Cancelled.'));
|
|
35
|
+
}
|
|
36
|
+
if (ch === '\u007f' || ch === '\b') value = value.slice(0, -1);
|
|
37
|
+
else if (ch >= ' ') value += ch;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
function finish() {
|
|
41
|
+
input.off('data', onData);
|
|
42
|
+
input.setRawMode(false);
|
|
43
|
+
input.pause();
|
|
44
|
+
output.write('\n');
|
|
45
|
+
}
|
|
46
|
+
input.on('data', onData);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Sets or replaces KEY=value lines in a .env file, keeping everything else (comments, other keys) as is. */
|
|
51
|
+
export function upsertEnv(text, values) {
|
|
52
|
+
const lines = text ? text.split(/\r?\n/) : [];
|
|
53
|
+
const pending = { ...values };
|
|
54
|
+
const updated = lines.map((line) => {
|
|
55
|
+
const match = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line);
|
|
56
|
+
if (match && match[1] in pending) {
|
|
57
|
+
const next = `${match[1]}=${pending[match[1]]}`;
|
|
58
|
+
delete pending[match[1]];
|
|
59
|
+
return next;
|
|
60
|
+
}
|
|
61
|
+
return line;
|
|
62
|
+
});
|
|
63
|
+
while (updated.length && updated[updated.length - 1] === '') updated.pop();
|
|
64
|
+
for (const [key, value] of Object.entries(pending)) updated.push(`${key}=${value}`);
|
|
65
|
+
return updated.join('\n') + '\n';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Checks a key with the platform: 404 = accepted (no app by that name), 401 = rejected. */
|
|
69
|
+
export async function checkKey(url, key, fetchImpl = fetch) {
|
|
70
|
+
let response;
|
|
71
|
+
try {
|
|
72
|
+
response = await fetchImpl(`${url}/publish/apps/login-check`, { headers: { Authorization: `PublisherKey ${key}` } });
|
|
73
|
+
} catch (error) {
|
|
74
|
+
return { ok: false, reason: `Could not reach ${url}: ${error.message}.` };
|
|
75
|
+
}
|
|
76
|
+
if (response.status === 401) return { ok: false, reason: 'The platform rejected the key.' };
|
|
77
|
+
if (response.status !== 404 && !response.ok) return { ok: false, reason: `Unexpected response from the platform (${response.status}).` };
|
|
78
|
+
return { ok: true };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const publisherOf = (key) => key.split('.')[0];
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Asks for the publisher key, checks it with the platform and saves it once for this machine, so every app
|
|
85
|
+
* uses it. With `local: true` it goes into this project's .env.local instead. The key never appears on
|
|
86
|
+
* screen, in shell history or as a command-line argument.
|
|
87
|
+
*/
|
|
88
|
+
export async function login({
|
|
89
|
+
dir = process.cwd(),
|
|
90
|
+
platform,
|
|
91
|
+
env,
|
|
92
|
+
local = false,
|
|
93
|
+
prompt = promptHidden,
|
|
94
|
+
fetchImpl = fetch,
|
|
95
|
+
log = console.log,
|
|
96
|
+
isIgnored = gitIgnored,
|
|
97
|
+
credentialsFile = credentialsPath(),
|
|
98
|
+
}) {
|
|
99
|
+
const { url } = resolveSettings({ flagPlatform: platform, flagEnv: env, dir, credentialsFile });
|
|
100
|
+
|
|
101
|
+
log(`Platform: ${describePlatform(url)}`);
|
|
102
|
+
const key = await prompt('Publisher key (input hidden): ');
|
|
103
|
+
if (!/^[a-z0-9-]+\.[A-Za-z0-9_-]{16,}$/.test(key)) throw new Error('That does not look like a publisher key ("<publisherId>.<secret>"). Nothing was saved.');
|
|
104
|
+
const check = await checkKey(url, key, fetchImpl);
|
|
105
|
+
if (!check.ok) throw new Error(`${check.reason} Nothing was saved.`);
|
|
106
|
+
|
|
107
|
+
if (!local) {
|
|
108
|
+
await saveCredential(credentialsFile, url, key);
|
|
109
|
+
log(`✔ Logged in to ${describePlatform(url)} as publisher "${publisherOf(key)}".`);
|
|
110
|
+
log(` Saved for every app on this machine: ${credentialsFile}`);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const envPath = join(dir, '.env.local');
|
|
115
|
+
const existing = existsSync(envPath) ? readFileSync(envPath, 'utf8') : '';
|
|
116
|
+
await writeFile(envPath, upsertEnv(existing, { HAWK_APPS_PLATFORM_URL: url, HAWK_APPS_PUBLISHER_KEY: key }));
|
|
117
|
+
log(`✔ Logged in to ${url} as publisher "${publisherOf(key)}" for this project only (.env.local).`);
|
|
118
|
+
if (isIgnored(dir, '.env.local') === false || (isIgnored(dir, '.env.local') === undefined && !ignoredByGitignoreFile(dir))) {
|
|
119
|
+
const gitignore = join(dir, '.gitignore');
|
|
120
|
+
const needsNewline = existsSync(gitignore) && !readFileSync(gitignore, 'utf8').endsWith('\n');
|
|
121
|
+
await appendFile(gitignore, `${needsNewline ? '\n' : ''}.env.local\n`);
|
|
122
|
+
log(' Added .env.local to .gitignore so the key is never committed.');
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Shows which platform and key would be used here, and whether the platform accepts the key. */
|
|
127
|
+
export async function whoami({ dir = process.cwd(), platform, env, fetchImpl = fetch, log = console.log, credentialsFile = credentialsPath() }) {
|
|
128
|
+
const settings = resolveSettings({ flagPlatform: platform, flagEnv: env, dir, credentialsFile });
|
|
129
|
+
log(`Platform: ${describePlatform(settings.url)} (from ${settings.urlSource})`);
|
|
130
|
+
if (!settings.key) {
|
|
131
|
+
log('✖ Not logged in. Run `npx hawk-app sso` (Bridgeline account) or `npx hawk-app login` (publisher key) once on this machine.');
|
|
132
|
+
return 1;
|
|
133
|
+
}
|
|
134
|
+
const check = await checkKey(settings.url, settings.key, fetchImpl);
|
|
135
|
+
if (!check.ok) {
|
|
136
|
+
log(`✖ ${check.reason} Key from ${settings.keySource}. Run \`npx hawk-app sso\` or \`npx hawk-app login\` again.`);
|
|
137
|
+
return 1;
|
|
138
|
+
}
|
|
139
|
+
log(`✔ Logged in as publisher "${publisherOf(settings.key)}" (key from ${settings.keySource})`);
|
|
140
|
+
return 0;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Removes this machine's saved key for the platform. Keys in .env.local or the environment are not touched. */
|
|
144
|
+
export async function logout({ dir = process.cwd(), platform, env, log = console.log, credentialsFile = credentialsPath() }) {
|
|
145
|
+
const { url } = resolveSettings({ flagPlatform: platform, flagEnv: env, dir, credentialsFile });
|
|
146
|
+
if (await removeCredential(credentialsFile, url)) log(`✔ Logged out of ${url} on this machine.`);
|
|
147
|
+
else log(`Not logged in to ${url} on this machine.`);
|
|
148
|
+
const remaining = resolveSettings({ flagPlatform: platform, flagEnv: env, dir, credentialsFile });
|
|
149
|
+
if (remaining.key) log(` Note: a key is still set in ${remaining.keySource}; that one still applies here.`);
|
|
150
|
+
return 0;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function ignoredByGitignoreFile(dir) {
|
|
154
|
+
const path = join(dir, '.gitignore');
|
|
155
|
+
if (!existsSync(path)) return false;
|
|
156
|
+
return readFileSync(path, 'utf8')
|
|
157
|
+
.split(/\r?\n/)
|
|
158
|
+
.map((l) => l.trim())
|
|
159
|
+
.some((l) => l === '.env.local' || l === '.env*' || l === '.env.*' || l === '*.local');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function gitIgnored(dir, file) {
|
|
163
|
+
const result = spawnSync('git', ['check-ignore', '-q', file], { cwd: dir, stdio: 'ignore' });
|
|
164
|
+
if (result.error || result.status === 128) return undefined;
|
|
165
|
+
return result.status === 0;
|
|
166
|
+
}
|