@enformik/cli 0.4.0 → 0.6.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/README.md +72 -31
- package/dist/auth.js +13 -9
- package/dist/cli.js +56 -38
- package/dist/codegen.js +6 -6
- package/dist/commands/ai.js +55 -30
- package/dist/commands/app.js +90 -73
- package/dist/commands/codegen.js +9 -6
- package/dist/commands/link.js +12 -12
- package/dist/commands/schema.js +60 -40
- package/dist/commands/site.js +51 -29
- package/dist/commands/whoami.js +20 -15
- package/dist/config.js +4 -4
- package/dist/context.js +32 -17
- package/dist/errors.js +5 -1
- package/dist/http.js +41 -5
- package/dist/output.js +1 -1
- package/dist/precheck.js +90 -21
- package/dist/schema.js +50 -34
- package/dist/site.js +75 -20
- package/dist/zip.js +11 -5
- package/package.json +2 -2
package/dist/commands/site.js
CHANGED
|
@@ -1,30 +1,43 @@
|
|
|
1
|
-
import { mkdtempSync, rmSync, statSync } from 'node:fs';
|
|
1
|
+
import { mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs';
|
|
2
2
|
import { tmpdir } from 'node:os';
|
|
3
3
|
import { join, resolve } from 'node:path';
|
|
4
4
|
import { CliError } from '../errors.js';
|
|
5
|
-
import { assertSecureUrl } from '../http.js';
|
|
6
5
|
import { formatBytes } from '../output.js';
|
|
7
|
-
import { precheckSite, DEFAULT_LIMITS } from '../precheck.js';
|
|
8
|
-
import { collectMeta, DeployClient, idempotencyKey, isTerminal, MAX_ARCHIVE_BYTES, sha256File, waitForDeployment } from '../site.js';
|
|
6
|
+
import { precheckSite, parseScanIgnore, DEFAULT_LIMITS } from '../precheck.js';
|
|
7
|
+
import { collectMeta, DeployClient, finalizeDeployment, idempotencyKey, isTerminal, MAX_ARCHIVE_BYTES, sha256File, waitForDeployment } from '../site.js';
|
|
9
8
|
import { writeZipFile } from '../zip.js';
|
|
10
9
|
function client(ctx) {
|
|
11
10
|
const { settings } = ctx;
|
|
12
11
|
if (!settings.consoleUrl)
|
|
13
|
-
throw new CliError('
|
|
12
|
+
throw new CliError('No console URL. Run `enformik link --console-url https://…` or set ENFORMIK_CONSOLE_URL.');
|
|
14
13
|
if (!settings.deployKey)
|
|
15
|
-
throw new CliError('ENFORMIK_DEPLOY_KEY
|
|
16
|
-
assertSecureUrl(settings.consoleUrl, 'Konsol adresi');
|
|
14
|
+
throw new CliError('ENFORMIK_DEPLOY_KEY is not set (in the console: Add-on environments → Developer site → Deploy keys).');
|
|
17
15
|
return new DeployClient({ consoleUrl: settings.consoleUrl, deployKey: settings.deployKey, version: ctx.version, fetchImpl: ctx.fetchImpl });
|
|
18
16
|
}
|
|
17
|
+
/** --expect-host verilmişse hedef sitenin ana makine adı eşleşmeli; eşleşmezse hiçbir şey yüklenmeden durur. */
|
|
18
|
+
function assertExpectedHost(ctx, info) {
|
|
19
|
+
const expect = ctx.flags['expect-host'];
|
|
20
|
+
if (typeof expect !== 'string')
|
|
21
|
+
return;
|
|
22
|
+
let host = '';
|
|
23
|
+
try {
|
|
24
|
+
host = new URL(info.url).hostname;
|
|
25
|
+
}
|
|
26
|
+
catch { /* adres bozuksa eşleşmez */ }
|
|
27
|
+
if (expect.toLowerCase() !== host.toLowerCase()) {
|
|
28
|
+
throw new CliError(`Expected site "${expect}" but the target is "${host || info.url}". Stopped so that nothing is deployed to the wrong site.`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
19
31
|
/** enformik site status */
|
|
20
32
|
export async function siteStatus(ctx) {
|
|
21
33
|
const info = await client(ctx).getSite();
|
|
22
|
-
ctx
|
|
23
|
-
ctx.out.info(`
|
|
34
|
+
assertExpectedHost(ctx, info);
|
|
35
|
+
ctx.out.info(`Website environment : ${info.environment.name}`);
|
|
36
|
+
ctx.out.info(`URL : ${info.url}`);
|
|
24
37
|
if (info.app)
|
|
25
|
-
ctx.out.info(`
|
|
26
|
-
ctx.out.info(`
|
|
27
|
-
ctx.out.info(`
|
|
38
|
+
ctx.out.info(`App : ${info.app.name}${info.app.workspaceId ? ` (workspace ${info.app.workspaceId})` : ''}`);
|
|
39
|
+
ctx.out.info(`Live release : ${info.live ? `#${info.live.seq} (${info.live.at})` : 'no deployment yet'}`);
|
|
40
|
+
ctx.out.info(`Key expires : ${info.key.expiresAt.slice(0, 10)}`);
|
|
28
41
|
ctx.out.result(info);
|
|
29
42
|
return 0;
|
|
30
43
|
}
|
|
@@ -38,14 +51,23 @@ export async function siteDeploy(ctx) {
|
|
|
38
51
|
throw new Error();
|
|
39
52
|
}
|
|
40
53
|
catch {
|
|
41
|
-
throw new CliError(`
|
|
54
|
+
throw new CliError(`Build folder not found: ${dir} (build the site first or pass --dir).`);
|
|
42
55
|
}
|
|
43
56
|
const timeoutSec = parseTimeout(ctx.flags.timeout);
|
|
44
57
|
// Sunucunun kotası biliniyorsa ön denetimde kullan (aşımı yüklemeden görmek için).
|
|
45
58
|
const info = await api.getSite();
|
|
46
|
-
|
|
59
|
+
assertExpectedHost(ctx, info);
|
|
60
|
+
// Sır taraması yanlış alarmı için proje kökündeki .enformikignore (yol listesi); yoksa boş.
|
|
61
|
+
let scanIgnore = [];
|
|
62
|
+
try {
|
|
63
|
+
scanIgnore = parseScanIgnore(readFileSync(join(ctx.cwd, '.enformikignore'), 'utf8'));
|
|
64
|
+
}
|
|
65
|
+
catch { /* dosya yok */ }
|
|
66
|
+
const pre = precheckSite(dir, { ...DEFAULT_LIMITS, maxFiles: info.limits.maxFiles, maxFileBytes: info.limits.maxFileBytes, maxUnpackedBytes: info.limits.maxUnpackedBytes }, { scanIgnore });
|
|
67
|
+
for (const w of pre.warnings)
|
|
68
|
+
out.warn(w);
|
|
47
69
|
if (pre.issues.length > 0) {
|
|
48
|
-
out.error('
|
|
70
|
+
out.error('The site archive does not meet the rules; nothing was uploaded:');
|
|
49
71
|
for (const i of pre.issues)
|
|
50
72
|
out.error(` - ${i.path ? i.path + ': ' : ''}${i.message}`);
|
|
51
73
|
out.result({ deployed: false, issues: pre.issues });
|
|
@@ -56,45 +78,45 @@ export async function siteDeploy(ctx) {
|
|
|
56
78
|
const zipPath = join(tmp, 'site.zip');
|
|
57
79
|
const z = writeZipFile(pre.files.map((f) => ({ name: f.name, path: f.path, mtime: f.mtime })), zipPath);
|
|
58
80
|
if (z.bytes > MAX_ARCHIVE_BYTES)
|
|
59
|
-
throw new CliError(`
|
|
60
|
-
out.info(`${pre.files.length}
|
|
81
|
+
throw new CliError(`The compressed archive is ${formatBytes(z.bytes)}; the maximum is ${formatBytes(MAX_ARCHIVE_BYTES)}.`);
|
|
82
|
+
out.info(`${pre.files.length} files, ${formatBytes(pre.unpackedBytes)} → ${formatBytes(z.bytes)} (zip)`);
|
|
61
83
|
const sha = sha256File(zipPath);
|
|
62
|
-
const init = await api.init({ sizeBytes: z.bytes, sha256: sha, meta: collectMeta(ctx.env, ctx.cwd) }, idempotencyKey(ctx.env));
|
|
84
|
+
const init = await api.init({ sizeBytes: z.bytes, sha256: sha, meta: collectMeta(ctx.env, ctx.cwd) }, idempotencyKey(ctx.env, undefined, sha));
|
|
63
85
|
let dep = init.deployment;
|
|
64
86
|
if (init.replayed)
|
|
65
|
-
out.info(
|
|
87
|
+
out.info(`Found a previous attempt (#${dep.seq}, status: ${dep.status}); resuming where it left off.`);
|
|
66
88
|
// Durumuna göre devam: yükle → sonlandır → bekle (yeniden oynatılan kayıt için de doğru).
|
|
67
89
|
if (dep.status === 'awaiting_upload') {
|
|
68
|
-
out.info(`
|
|
90
|
+
out.info(`Uploading (#${dep.seq})…`);
|
|
69
91
|
await api.upload(dep.deploymentId, zipPath);
|
|
70
92
|
dep = { ...dep, status: 'uploaded' };
|
|
71
93
|
}
|
|
72
94
|
if (dep.status === 'uploaded') {
|
|
73
|
-
await api
|
|
74
|
-
dep = { ...dep, status: 'validating' };
|
|
95
|
+
dep = await finalizeDeployment(api, dep);
|
|
75
96
|
}
|
|
76
|
-
|
|
77
|
-
|
|
97
|
+
// Terminal durumdaki (failed/expired/live) yeniden oynatılan kayıt --no-wait ile de gerçek sonucu vermeli.
|
|
98
|
+
if (ctx.flags['no-wait'] === true && !isTerminal(dep.status)) {
|
|
99
|
+
out.info(`Deployment started (#${dep.seq}). To follow its status: console → Developer site → Releases.`);
|
|
78
100
|
out.result({ deployed: false, deploymentId: dep.deploymentId, seq: dep.seq, status: dep.status });
|
|
79
101
|
return 0;
|
|
80
102
|
}
|
|
81
103
|
const final = isTerminal(dep.status) ? dep : await waitForDeployment(api, dep.deploymentId, {
|
|
82
104
|
timeoutMs: timeoutSec * 1000,
|
|
83
|
-
onStatus: (s) => out.info(`
|
|
105
|
+
onStatus: (s) => out.info(` status: ${s}`),
|
|
84
106
|
sleep: ctx.sleep,
|
|
85
107
|
});
|
|
86
108
|
if (final.status === 'live') {
|
|
87
|
-
out.info(out.paint('green', `✓
|
|
109
|
+
out.info(out.paint('green', `✓ Live: ${final.url ?? info.url} (release #${final.seq})`));
|
|
88
110
|
out.result({ deployed: true, deploymentId: final.deploymentId, seq: final.seq, url: final.url ?? info.url });
|
|
89
111
|
return 0;
|
|
90
112
|
}
|
|
91
113
|
if (final.status === 'superseded') {
|
|
92
114
|
// Daha yeni bir sürüm canlıya çıktı: istenen son durum sağlandı; CI'ı kırmayız ama açıkça uyarırız.
|
|
93
|
-
out.warn(`
|
|
115
|
+
out.warn(`Warning: this deployment (#${final.seq}) was superseded by a newer release; the newer release is the one that is live.`);
|
|
94
116
|
out.result({ deployed: false, superseded: true, status: 'superseded', deploymentId: final.deploymentId, seq: final.seq, url: info.url });
|
|
95
117
|
return 0;
|
|
96
118
|
}
|
|
97
|
-
out.error(`✗
|
|
119
|
+
out.error(`✗ Deployment failed (#${final.seq}, ${final.status}): ${final.error ?? 'unknown reason'}`);
|
|
98
120
|
for (const i of final.issues ?? [])
|
|
99
121
|
out.error(` - ${i.path ? i.path + ': ' : ''}${i.message}`);
|
|
100
122
|
out.result({ deployed: false, deploymentId: final.deploymentId, seq: final.seq, status: final.status, error: final.error, issues: final.issues });
|
|
@@ -110,6 +132,6 @@ function parseTimeout(raw) {
|
|
|
110
132
|
return 600;
|
|
111
133
|
const n = typeof raw === 'string' ? Number(raw) : NaN;
|
|
112
134
|
if (!Number.isFinite(n) || n <= 0)
|
|
113
|
-
throw new CliError(`--timeout
|
|
135
|
+
throw new CliError(`--timeout must be a positive number of seconds (got: ${String(raw)}).`);
|
|
114
136
|
return n;
|
|
115
137
|
}
|
package/dist/commands/whoami.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { discoverApp, fetchAccessToken, rolesFromClaims } from '../auth.js';
|
|
2
2
|
import { DeployClient } from '../site.js';
|
|
3
|
-
/** enformik whoami:
|
|
3
|
+
/** enformik whoami: shows which identity connects to which target (prints no secrets). */
|
|
4
4
|
export async function whoami(ctx) {
|
|
5
5
|
const { out, settings } = ctx;
|
|
6
6
|
const result = {};
|
|
@@ -10,27 +10,32 @@ export async function whoami(ctx) {
|
|
|
10
10
|
const app = await discoverApp(settings.appUrl, ctx.version, ctx.fetchImpl);
|
|
11
11
|
const tok = await fetchAccessToken(app, settings.clientId, settings.clientSecret, ctx.version, ctx.fetchImpl);
|
|
12
12
|
const roles = rolesFromClaims(tok.claims);
|
|
13
|
-
out.info('
|
|
14
|
-
out.info(`
|
|
15
|
-
out.info(`
|
|
16
|
-
out.info(`
|
|
17
|
-
out.info(`
|
|
18
|
-
out.info(`
|
|
19
|
-
|
|
13
|
+
out.info('Schema upgrades (API client)');
|
|
14
|
+
out.info(` App : ${settings.appUrl}`);
|
|
15
|
+
out.info(` Client : ${settings.clientId}`);
|
|
16
|
+
out.info(` Identity provider : ${new URL(app.oidcAuthority).host}`);
|
|
17
|
+
out.info(` Roles : ${roles.join(', ') || '(no roles in the token)'}`);
|
|
18
|
+
out.info(` Workspace : ${settings.workspaceId || app.tenantId || '(not specified)'}`);
|
|
19
|
+
out.info(` Token : valid${tok.expiresInSec > 0 ? ` (${Math.round(tok.expiresInSec / 60)} min)` : ' (no expiry info)'}`);
|
|
20
|
+
result.schema = { appUrl: settings.appUrl, authorityHost: new URL(app.oidcAuthority).host, clientId: settings.clientId, roles, workspaceId: settings.workspaceId || app.tenantId || null };
|
|
20
21
|
}
|
|
21
22
|
if (settings.consoleUrl && settings.deployKey) {
|
|
22
23
|
any = true;
|
|
23
24
|
const info = await new DeployClient({ consoleUrl: settings.consoleUrl, deployKey: settings.deployKey, version: ctx.version, fetchImpl: ctx.fetchImpl }).getSite();
|
|
24
|
-
out.info('Site
|
|
25
|
-
out.info(`
|
|
26
|
-
out.info(`
|
|
27
|
-
out.info(`
|
|
25
|
+
out.info('Site deployment (deploy key)');
|
|
26
|
+
out.info(` Console : ${settings.consoleUrl}`);
|
|
27
|
+
out.info(` Key : ${settings.deployKey.slice(0, 12)}… (expires ${info.key.expiresAt.slice(0, 10)})`);
|
|
28
|
+
out.info(` Environment / URL : ${info.environment.name} → ${info.url}`);
|
|
28
29
|
result.site = { consoleUrl: settings.consoleUrl, keyPrefix: settings.deployKey.slice(0, 12), expiresAt: info.key.expiresAt, environment: info.environment.name, url: info.url };
|
|
29
30
|
}
|
|
30
31
|
if (!any) {
|
|
31
|
-
out.info('
|
|
32
|
-
out.info('
|
|
33
|
-
out.info(' Site
|
|
32
|
+
out.info('No credentials configured. Required:');
|
|
33
|
+
out.info(' Schema upgrade : ENFORMIK_APP_URL (or link) + ENFORMIK_CLIENT_ID + ENFORMIK_CLIENT_SECRET');
|
|
34
|
+
out.info(' Site deployment: ENFORMIK_CONSOLE_URL (or link) + ENFORMIK_DEPLOY_KEY');
|
|
35
|
+
out.result({
|
|
36
|
+
error: 'no_credentials', message: 'No credentials configured.',
|
|
37
|
+
required: ['ENFORMIK_APP_URL + ENFORMIK_CLIENT_ID + ENFORMIK_CLIENT_SECRET', 'ENFORMIK_CONSOLE_URL + ENFORMIK_DEPLOY_KEY'],
|
|
38
|
+
});
|
|
34
39
|
return 1;
|
|
35
40
|
}
|
|
36
41
|
out.result(result);
|
package/dist/config.js
CHANGED
|
@@ -25,21 +25,21 @@ export function loadConfig(cwd, explicitPath) {
|
|
|
25
25
|
raw = readFileSync(path, 'utf8');
|
|
26
26
|
}
|
|
27
27
|
catch {
|
|
28
|
-
throw new CliError(`
|
|
28
|
+
throw new CliError(`Could not read the config: ${path}`);
|
|
29
29
|
}
|
|
30
30
|
let parsed;
|
|
31
31
|
try {
|
|
32
32
|
parsed = JSON.parse(raw);
|
|
33
33
|
}
|
|
34
34
|
catch {
|
|
35
|
-
throw new CliError(`
|
|
35
|
+
throw new CliError(`The config is not valid JSON: ${path}`);
|
|
36
36
|
}
|
|
37
37
|
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
|
|
38
|
-
throw new CliError(`
|
|
38
|
+
throw new CliError(`The config must be an object: ${path}`);
|
|
39
39
|
// Depoya yanlışlıkla sır yazılmasını engelle.
|
|
40
40
|
const s = raw.toLowerCase();
|
|
41
41
|
if (s.includes('enf_dk_') || /"(client_?secret|secret|deploy_?key|password|token)"/.test(s)) {
|
|
42
|
-
throw new CliError(`${path}
|
|
42
|
+
throw new CliError(`${path} appears to contain a secret. Secrets belong in environment variables (ENFORMIK_CLIENT_SECRET, ENFORMIK_DEPLOY_KEY), not in the config file; remove it from the file.`);
|
|
43
43
|
}
|
|
44
44
|
// .enformik/config.json → proje kökü = .enformik'in üst dizini
|
|
45
45
|
return { config: parsed, path, root: dirname(dirname(path)) };
|
package/dist/context.js
CHANGED
|
@@ -1,25 +1,40 @@
|
|
|
1
1
|
import { createInterface } from 'node:readline/promises';
|
|
2
|
-
export function realPrompts() {
|
|
2
|
+
export function realPrompts(input = process.stdin, output = process.stderr) {
|
|
3
|
+
/**
|
|
4
|
+
* One line, or null when the input ends before an answer. A closed stdin must read as "no": left pending, the question never settles
|
|
5
|
+
* and the process would end silently with exit code 0 although nothing was applied.
|
|
6
|
+
*/
|
|
7
|
+
const readLine = async (question) => {
|
|
8
|
+
const ended = input;
|
|
9
|
+
if (ended.readableEnded || ended.destroyed) {
|
|
10
|
+
output.write(`${question}\n`);
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
const rl = createInterface({ input, output });
|
|
14
|
+
try {
|
|
15
|
+
const closed = new Promise((resolve) => rl.once('close', () => resolve(null)));
|
|
16
|
+
const line = await Promise.race([rl.question(question), closed]);
|
|
17
|
+
if (line === null)
|
|
18
|
+
output.write('\n');
|
|
19
|
+
return line;
|
|
20
|
+
}
|
|
21
|
+
finally {
|
|
22
|
+
rl.close();
|
|
23
|
+
}
|
|
24
|
+
};
|
|
3
25
|
return {
|
|
4
26
|
async confirm(question) {
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
finally {
|
|
11
|
-
rl.close();
|
|
12
|
-
}
|
|
27
|
+
const a = await readLine(`${question} [y/N]: `);
|
|
28
|
+
if (a === null)
|
|
29
|
+
return false;
|
|
30
|
+
const t = a.trim().toLowerCase();
|
|
31
|
+
return t === 'y' || t === 'yes';
|
|
13
32
|
},
|
|
14
33
|
async ask(question, def) {
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
}
|
|
20
|
-
finally {
|
|
21
|
-
rl.close();
|
|
22
|
-
}
|
|
34
|
+
const a = await readLine(def ? `${question} [${def}]: ` : `${question}: `);
|
|
35
|
+
if (a === null)
|
|
36
|
+
return def ?? '';
|
|
37
|
+
return a.trim() || def || '';
|
|
23
38
|
},
|
|
24
39
|
};
|
|
25
40
|
}
|
package/dist/errors.js
CHANGED
|
@@ -16,11 +16,15 @@ export class ApiError extends Error {
|
|
|
16
16
|
status;
|
|
17
17
|
code;
|
|
18
18
|
body;
|
|
19
|
-
|
|
19
|
+
retryAfterSeconds;
|
|
20
|
+
constructor(status, message, code, body,
|
|
21
|
+
/** Only for 429: seconds from the Retry-After header (clamped 0..3600), when the server sent one. */
|
|
22
|
+
retryAfterSeconds) {
|
|
20
23
|
super(message);
|
|
21
24
|
this.status = status;
|
|
22
25
|
this.code = code;
|
|
23
26
|
this.body = body;
|
|
27
|
+
this.retryAfterSeconds = retryAfterSeconds;
|
|
24
28
|
this.name = 'ApiError';
|
|
25
29
|
}
|
|
26
30
|
}
|
package/dist/http.js
CHANGED
|
@@ -7,10 +7,10 @@ export function assertSecureUrl(raw, what) {
|
|
|
7
7
|
u = new URL(raw);
|
|
8
8
|
}
|
|
9
9
|
catch {
|
|
10
|
-
throw new CliError(`${what}
|
|
10
|
+
throw new CliError(`${what} is not a valid URL: ${raw}`);
|
|
11
11
|
}
|
|
12
12
|
if (u.protocol !== 'https:' && !(u.protocol === 'http:' && LOCAL_HOSTS.has(u.hostname))) {
|
|
13
|
-
throw new CliError(`${what} HTTPS
|
|
13
|
+
throw new CliError(`${what} must use HTTPS (credentials are never sent in plain text): ${raw}`);
|
|
14
14
|
}
|
|
15
15
|
return u;
|
|
16
16
|
}
|
|
@@ -18,6 +18,8 @@ export function joinUrl(base, path) {
|
|
|
18
18
|
return base.replace(/\/+$/, '') + (path.startsWith('/') ? path : '/' + path);
|
|
19
19
|
}
|
|
20
20
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
21
|
+
/** fetch(redirect:'error') yönlendirmede "fetch failed" + cause "unexpected redirect" ile reddeder. */
|
|
22
|
+
const isRedirectError = (e) => /redirect/i.test(String(e?.cause?.message ?? e?.message ?? ''));
|
|
21
23
|
export async function request(url, opts = {}) {
|
|
22
24
|
const doFetch = opts.fetchImpl ?? fetch;
|
|
23
25
|
const attempts = (opts.retries ?? 0) + 1;
|
|
@@ -47,6 +49,8 @@ export async function request(url, opts = {}) {
|
|
|
47
49
|
}
|
|
48
50
|
catch (err) {
|
|
49
51
|
lastErr = err;
|
|
52
|
+
if (isRedirectError(err))
|
|
53
|
+
break; // yönlendirme tekrar denemeyle değişmez
|
|
50
54
|
if (i < attempts - 1) {
|
|
51
55
|
await sleep(400 * 2 ** i);
|
|
52
56
|
continue;
|
|
@@ -54,15 +58,47 @@ export async function request(url, opts = {}) {
|
|
|
54
58
|
}
|
|
55
59
|
}
|
|
56
60
|
const e = lastErr;
|
|
57
|
-
|
|
58
|
-
|
|
61
|
+
if (isRedirectError(e)) {
|
|
62
|
+
throw new CliError(`The server redirected to another address (${new URL(url).host}); for security, redirects are not followed when credentials are sent. Update the URL to the redirect target (e.g. https:// or the correct domain).`);
|
|
63
|
+
}
|
|
64
|
+
const reason = e?.name === 'TimeoutError' ? 'timed out' : (e?.cause?.code ?? e?.message ?? 'unknown error');
|
|
65
|
+
throw new CliError(`Could not reach the server (${new URL(url).host}): ${reason}`);
|
|
66
|
+
}
|
|
67
|
+
/** Retry-After (delta-seconds or HTTP-date) → seconds, clamped to 0..3600; undefined when absent or unparsable. */
|
|
68
|
+
export function parseRetryAfter(raw, nowMs = Date.now()) {
|
|
69
|
+
const v = raw?.trim();
|
|
70
|
+
if (!v)
|
|
71
|
+
return undefined;
|
|
72
|
+
let seconds;
|
|
73
|
+
if (/^\d+$/.test(v))
|
|
74
|
+
seconds = Number(v);
|
|
75
|
+
else {
|
|
76
|
+
if (!/[A-Za-z]/.test(v))
|
|
77
|
+
return undefined; // an HTTP-date has letters; Date.parse would turn "1.5" or "-5" into a (past) date
|
|
78
|
+
const t = Date.parse(v);
|
|
79
|
+
if (Number.isNaN(t))
|
|
80
|
+
return undefined;
|
|
81
|
+
seconds = Math.ceil((t - nowMs) / 1000);
|
|
82
|
+
}
|
|
83
|
+
return Math.min(3600, Math.max(0, seconds));
|
|
84
|
+
}
|
|
85
|
+
export const rateLimitMessage = (seconds) => seconds === undefined ? 'Rate limited by the server; try again shortly.' : `Rate limited by the server; retry in about ${seconds} s.`;
|
|
86
|
+
/** HTTP 429 as an ApiError. `code` stays the server's own code (if it sent one); there is deliberately no automatic retry. */
|
|
87
|
+
export function rateLimitedError(res) {
|
|
88
|
+
const body = res.json;
|
|
89
|
+
const seconds = parseRetryAfter(res.headers.get('retry-after'));
|
|
90
|
+
return new ApiError(429, rateLimitMessage(seconds), typeof body?.error === 'string' ? body.error : undefined, res.json ?? res.text, seconds);
|
|
59
91
|
}
|
|
60
92
|
/** Yanıt başarısızsa ApiError fırlatır; gövdeden {error, message} ya da düz metin okur. */
|
|
61
93
|
export function ensureOk(res) {
|
|
62
94
|
if (res.ok)
|
|
63
95
|
return res;
|
|
96
|
+
if (res.status === 429)
|
|
97
|
+
throw rateLimitedError(res);
|
|
64
98
|
const body = res.json;
|
|
65
99
|
const code = typeof body?.error === 'string' ? body.error : undefined;
|
|
66
|
-
|
|
100
|
+
// Ara katmanların (CDN/WAF) HTML hata sayfaları mesaja girmez; yalnızca "HTTP <durum>" kalır.
|
|
101
|
+
const plain = /^\s*</.test(res.text) ? '' : res.text.trim().slice(0, 300);
|
|
102
|
+
const message = body?.message ?? (typeof body?.error === 'string' ? body.error : plain) ?? '';
|
|
67
103
|
throw new ApiError(res.status, message || `HTTP ${res.status}`, code, res.json ?? res.text);
|
|
68
104
|
}
|
package/dist/output.js
CHANGED
|
@@ -14,7 +14,7 @@ export function createOutput(opts = {}) {
|
|
|
14
14
|
const stderr = opts.stderr ?? process.stderr;
|
|
15
15
|
const env = opts.env ?? process.env;
|
|
16
16
|
const json = !!opts.json;
|
|
17
|
-
const color = !env.NO_COLOR && env.TERM !== 'dumb' && !!stderr.isTTY &&
|
|
17
|
+
const color = !env.NO_COLOR && env.TERM !== 'dumb' && !!stderr.isTTY && (json || !!stdout.isTTY); // stdout dosyaya/boruya yönlendirilmişse ANSI kaçışı yazılmaz
|
|
18
18
|
const human = (s) => (json ? stderr : stdout).write(s + '\n');
|
|
19
19
|
return {
|
|
20
20
|
json,
|
package/dist/precheck.js
CHANGED
|
@@ -1,25 +1,75 @@
|
|
|
1
1
|
import { lstatSync, readdirSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
+
/** İzin listesi: yalnızca sitenin çalışması için gereken türler (SPEC CAP-2). Worker (site_artifact.go) ile aynı kümedir;
|
|
4
|
+
* sapmayı temporal-worker testdata/site_file_types.json sözleşme dosyası yakalar. */
|
|
5
|
+
export const ALLOWED_EXTS = new Set([
|
|
6
|
+
'.html', '.htm', '.css', '.js', '.mjs', '.map',
|
|
7
|
+
'.json', '.webmanifest', '.xml', '.txt', '.csv', '.md',
|
|
8
|
+
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.avif', '.svg', '.ico', '.bmp',
|
|
9
|
+
'.woff', '.woff2', '.ttf', '.otf', '.eot',
|
|
10
|
+
'.mp3', '.ogg', '.wav', '.m4a',
|
|
11
|
+
'.mp4', '.webm',
|
|
12
|
+
'.pdf', '.wasm',
|
|
13
|
+
]);
|
|
14
|
+
export const EXECUTABLE_EXTS = new Set([
|
|
15
|
+
'.exe', '.dll', '.msi', '.bat', '.cmd', '.com', '.scr', '.ps1',
|
|
16
|
+
'.sh', '.apk', '.ipa', '.dmg', '.pkg', '.deb', '.rpm', '.jar', '.app',
|
|
17
|
+
]);
|
|
18
|
+
/** İzin listesinde olmayan dosya için sorun döner; izinliyse null. Uzantısız dosya yalnızca .well-known/ altında izinli. */
|
|
19
|
+
export function fileTypeIssue(relPath) {
|
|
20
|
+
const base = relPath.slice(relPath.lastIndexOf('/') + 1);
|
|
21
|
+
const dot = base.lastIndexOf('.');
|
|
22
|
+
const ext = dot > 0 ? base.slice(dot).toLowerCase() : '';
|
|
23
|
+
if (EXECUTABLE_EXTS.has(ext)) {
|
|
24
|
+
return { code: 'executable_file', message: `Executable/package files (${ext}) cannot be uploaded. Remove files the site does not need from the folder.` };
|
|
25
|
+
}
|
|
26
|
+
if (ext === '') {
|
|
27
|
+
return relPath.startsWith('.well-known/') ? null : { code: 'disallowed_file_type', message: 'Files without an extension cannot be uploaded (only allowed under .well-known/).' };
|
|
28
|
+
}
|
|
29
|
+
if (!ALLOWED_EXTS.has(ext)) {
|
|
30
|
+
return { code: 'disallowed_file_type', message: `${ext} files cannot be uploaded; only the types a site needs (pages, scripts, styles, images, fonts, audio, video, pdf, wasm, text/data) are allowed.` };
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
3
34
|
export const DEFAULT_LIMITS = { maxFiles: 10000, maxFileBytes: 100 * 1024 * 1024 };
|
|
4
35
|
export const RESERVED_DIR = '_enformik';
|
|
5
36
|
const MAX_ISSUES = 20;
|
|
37
|
+
/** Worker (site_artifact.go maxSitePathBytes) ile aynı sınır. */
|
|
38
|
+
const MAX_PATH_BYTES = 255;
|
|
6
39
|
// Tarayıcı paketine sızmaması gereken sırlar. Değer ASLA çıktıya yazılmaz; yalnızca dosya yolu ve tür bildirilir.
|
|
7
40
|
// Bilerek dar tutulur (yanlış alarm CI'ı kırar): dağıtım anahtarı, istemci sırrı atamaları, sabit Basic kimlik başlığı.
|
|
8
41
|
const SECRET_PATTERNS = [
|
|
9
|
-
{ kind: '
|
|
10
|
-
{ kind: '
|
|
11
|
-
{ kind: 'ENFORMIK_CLIENT_SECRET
|
|
12
|
-
{ kind: '
|
|
42
|
+
{ kind: 'deploy key (enf_dk_…)', re: /enf_dk_[A-Za-z0-9_-]{16,}/ },
|
|
43
|
+
{ kind: 'client secret (client_secret)', re: /client_?secret["']?\s*[:=]\s*["'`][^"'`\s]{8,}["'`]/i },
|
|
44
|
+
{ kind: 'ENFORMIK_CLIENT_SECRET value', re: /ENFORMIK_CLIENT_SECRET["']?\s*[:=]\s*["'`][^"'`\s]{6,}["'`]/ },
|
|
45
|
+
{ kind: 'hard-coded Basic auth header', re: /Authorization["']?\s*:\s*["'`]Basic\s+[A-Za-z0-9+/=]{16,}/i },
|
|
13
46
|
];
|
|
14
|
-
const SCAN_EXT = new Set(['.js', '.mjs', '.cjs', '.html', '.htm', '.json', '.map', '.css', '.txt', '.xml', '.svg', '.webmanifest']);
|
|
47
|
+
const SCAN_EXT = new Set(['.js', '.mjs', '.cjs', '.html', '.htm', '.json', '.map', '.css', '.txt', '.xml', '.svg', '.webmanifest', '.csv', '.md']);
|
|
15
48
|
const SCAN_MAX_BYTES = 5 * 1024 * 1024;
|
|
49
|
+
/**
|
|
50
|
+
* `.enformikignore` içeriğini yol listesine çevirir: satır başına bir yol (dağıtım klasörüne göre), `#` yorum.
|
|
51
|
+
* `dir/` ile biten satır o dizinin altındaki her dosyayı kapsar; joker yoktur (bilerek dar). Yalnızca sır taramasını
|
|
52
|
+
* atlatır; dosya türü, boyut ve diğer denetimler yine uygulanır.
|
|
53
|
+
*/
|
|
54
|
+
export function parseScanIgnore(text) {
|
|
55
|
+
return text.split(/\r?\n/).map((l) => l.trim().replace(/^\.\//, '')).filter((l) => l && !l.startsWith('#'));
|
|
56
|
+
}
|
|
57
|
+
const isIgnored = (name, ignore) => ignore.some((p) => (p.endsWith('/') ? name.startsWith(p) : name === p));
|
|
16
58
|
/** Metin dosyalarında sır desenlerini arar (dosya başına en çok bir bulgu). */
|
|
17
|
-
export function scanSecrets(files) {
|
|
18
|
-
const out = [];
|
|
59
|
+
export function scanSecrets(files, ignore = []) {
|
|
60
|
+
const out = { issues: [], ignored: [], unscanned: [] };
|
|
19
61
|
for (const f of files) {
|
|
20
62
|
const dot = f.name.lastIndexOf('.');
|
|
21
|
-
if (dot < 0 || !SCAN_EXT.has(f.name.slice(dot).toLowerCase())
|
|
63
|
+
if (dot < 0 || !SCAN_EXT.has(f.name.slice(dot).toLowerCase()))
|
|
64
|
+
continue;
|
|
65
|
+
if (isIgnored(f.name, ignore)) {
|
|
66
|
+
out.ignored.push(f.name);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (f.size > SCAN_MAX_BYTES) {
|
|
70
|
+
out.unscanned.push(f.name);
|
|
22
71
|
continue;
|
|
72
|
+
}
|
|
23
73
|
let text;
|
|
24
74
|
try {
|
|
25
75
|
text = readFileSync(f.path, 'utf8');
|
|
@@ -29,11 +79,11 @@ export function scanSecrets(files) {
|
|
|
29
79
|
}
|
|
30
80
|
const hit = SECRET_PATTERNS.find((p) => p.re.test(text));
|
|
31
81
|
if (hit)
|
|
32
|
-
out.push({ code: 'secret_in_bundle', path: f.name, message: `
|
|
82
|
+
out.issues.push({ code: 'secret_in_bundle', path: f.name, message: `The bundle appears to contain a secret: ${hit.kind}. Secrets must not be placed in files served to the browser; remove the secret from the bundle (and revoke it if it leaked), then rebuild. If this is a false alarm, add the file path to .enformikignore in the project root.` });
|
|
33
83
|
}
|
|
34
84
|
return out;
|
|
35
85
|
}
|
|
36
|
-
export function precheckSite(dir, limits = DEFAULT_LIMITS) {
|
|
86
|
+
export function precheckSite(dir, limits = DEFAULT_LIMITS, opts = {}) {
|
|
37
87
|
const files = [];
|
|
38
88
|
const issues = [];
|
|
39
89
|
const add = (code, path, message) => {
|
|
@@ -49,25 +99,38 @@ export function precheckSite(dir, limits = DEFAULT_LIMITS) {
|
|
|
49
99
|
const relPath = rel ? `${rel}/${ent.name}` : ent.name;
|
|
50
100
|
const absPath = join(abs, ent.name);
|
|
51
101
|
if (relPath.split('/')[0] === RESERVED_DIR) {
|
|
52
|
-
add('reserved_path', relPath, `"${RESERVED_DIR}"
|
|
102
|
+
add('reserved_path', relPath, `The "${RESERVED_DIR}" directory is reserved for the platform; remove it from the archive.`);
|
|
53
103
|
continue;
|
|
54
104
|
}
|
|
55
105
|
if (ent.name.startsWith('.') && ent.name !== '.well-known') {
|
|
56
|
-
add('hidden_file', relPath, `
|
|
106
|
+
add('hidden_file', relPath, `Hidden files/directories cannot be uploaded ("${ent.name}"). Do not include files such as .env or .git in the archive.`);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (Buffer.byteLength(relPath) > MAX_PATH_BYTES) {
|
|
110
|
+
add('unsafe_path', relPath, `A path cannot be longer than ${MAX_PATH_BYTES} bytes.`);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (relPath.includes('\\') || /[\u0000-\u001f\u007f]/.test(relPath)) {
|
|
114
|
+
add('unsafe_path', relPath, 'The path contains an invalid or control character.');
|
|
57
115
|
continue;
|
|
58
116
|
}
|
|
59
117
|
const st = lstatSync(absPath);
|
|
60
118
|
if (st.isSymbolicLink() || (!st.isFile() && !st.isDirectory())) {
|
|
61
|
-
add('unsupported_entry', relPath, '
|
|
119
|
+
add('unsupported_entry', relPath, 'Symbolic links and special files are not supported.');
|
|
62
120
|
continue;
|
|
63
121
|
}
|
|
64
122
|
if (st.isDirectory()) {
|
|
65
123
|
walk(absPath, relPath);
|
|
66
124
|
continue;
|
|
67
125
|
}
|
|
126
|
+
const ft = fileTypeIssue(relPath);
|
|
127
|
+
if (ft) {
|
|
128
|
+
add(ft.code, relPath, ft.message);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
68
131
|
const key = relPath.toLowerCase();
|
|
69
132
|
if (seen.has(key)) {
|
|
70
|
-
add('duplicate_path', relPath, '
|
|
133
|
+
add('duplicate_path', relPath, 'The same path appears more than once, differing only in letter case.');
|
|
71
134
|
continue;
|
|
72
135
|
}
|
|
73
136
|
seen.add(key);
|
|
@@ -77,7 +140,7 @@ export function precheckSite(dir, limits = DEFAULT_LIMITS) {
|
|
|
77
140
|
if (parts.length === 2 && parts[1] === 'index.html')
|
|
78
141
|
nestedIndexDirs.add(parts[0]);
|
|
79
142
|
if (st.size > limits.maxFileBytes) {
|
|
80
|
-
add('file_too_large', relPath, `
|
|
143
|
+
add('file_too_large', relPath, `A single file can be at most ${Math.floor(limits.maxFileBytes / 1048576)} MB.`);
|
|
81
144
|
continue;
|
|
82
145
|
}
|
|
83
146
|
files.push({ name: relPath, path: absPath, size: st.size, mtime: st.mtime });
|
|
@@ -85,22 +148,28 @@ export function precheckSite(dir, limits = DEFAULT_LIMITS) {
|
|
|
85
148
|
}
|
|
86
149
|
};
|
|
87
150
|
walk(dir, '');
|
|
88
|
-
|
|
151
|
+
const scan = scanSecrets(files, opts.scanIgnore);
|
|
152
|
+
for (const s of scan.issues)
|
|
89
153
|
add(s.code, s.path, s.message);
|
|
154
|
+
const warnings = [];
|
|
155
|
+
if (scan.ignored.length > 0)
|
|
156
|
+
warnings.push(`Files skipped by the secret scan because of .enformikignore: ${scan.ignored.join(', ')}`);
|
|
157
|
+
if (scan.unscanned.length > 0)
|
|
158
|
+
warnings.push(`Files not scanned for secrets because they exceed ${SCAN_MAX_BYTES / 1048576} MB: ${scan.unscanned.join(', ')}`);
|
|
90
159
|
if (files.length > limits.maxFiles) {
|
|
91
|
-
add('too_many_files', undefined, `
|
|
160
|
+
add('too_many_files', undefined, `The archive can contain at most ${limits.maxFiles} files (${files.length} found).`);
|
|
92
161
|
}
|
|
93
162
|
if (limits.maxUnpackedBytes && total > limits.maxUnpackedBytes) {
|
|
94
|
-
add('quota_exceeded', undefined, `
|
|
163
|
+
add('quota_exceeded', undefined, `The unpacked size of the site exceeds your environment's disk quota (${total} > ${limits.maxUnpackedBytes} bytes).`);
|
|
95
164
|
}
|
|
96
165
|
if (!hasRootIndex && issues.length < MAX_ISSUES) {
|
|
97
166
|
if (nestedIndexDirs.size === 1) {
|
|
98
167
|
const d = [...nestedIndexDirs][0];
|
|
99
|
-
add('nested_root', `${d}/index.html`, `index.html
|
|
168
|
+
add('nested_root', `${d}/index.html`, `index.html is inside a subfolder ("${d}"). Publish the folder's CONTENTS, not the folder itself (--dir ${join(dir, d)}).`);
|
|
100
169
|
}
|
|
101
170
|
else {
|
|
102
|
-
add('missing_index', 'index.html', '
|
|
171
|
+
add('missing_index', 'index.html', 'index.html must be at the root of the directory.');
|
|
103
172
|
}
|
|
104
173
|
}
|
|
105
|
-
return { files, unpackedBytes: total, issues };
|
|
174
|
+
return { files, unpackedBytes: total, issues, warnings };
|
|
106
175
|
}
|