@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.
@@ -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('Konsol adresi yok. `enformik link --console-url https://…` çalıştırın ya da ENFORMIK_CONSOLE_URL tanımlayın.');
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 tanımlı değil (konsolda Ek ortamlar → Geliştirici sitesi → Dağıtım anahtarları).');
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.out.info(`Web sitesi ortamı : ${info.environment.name}`);
23
- ctx.out.info(`Adres : ${info.url}`);
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(`Uygulama : ${info.app.name}${info.app.workspaceId ? ` (çalışma alanı ${info.app.workspaceId})` : ''}`);
26
- ctx.out.info(`Canlı sürüm : ${info.live ? `#${info.live.seq} (${info.live.at})` : 'henüz dağıtım yok'}`);
27
- ctx.out.info(`Anahtar bitişi : ${info.key.expiresAt.slice(0, 10)}`);
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(`Derleme klasörü bulunamadı: ${dir} (önce siteyi derleyin ya da --dir verin).`);
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
- const pre = precheckSite(dir, { ...DEFAULT_LIMITS, maxFiles: info.limits.maxFiles, maxFileBytes: info.limits.maxFileBytes, maxUnpackedBytes: info.limits.maxUnpackedBytes });
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('Site arşivi kurallara uymuyor; yükleme yapılmadı:');
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(`Sıkıştırılmış arşiv ${formatBytes(z.bytes)}; en çok ${formatBytes(MAX_ARCHIVE_BYTES)} olabilir.`);
60
- out.info(`${pre.files.length} dosya, ${formatBytes(pre.unpackedBytes)} → ${formatBytes(z.bytes)} (zip)`);
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(`Önceki deneme bulundu (#${dep.seq}, durum: ${dep.status}); kaldığı yerden devam ediliyor.`);
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(`Yükleniyor (#${dep.seq})…`);
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.finalize(dep.deploymentId);
74
- dep = { ...dep, status: 'validating' };
95
+ dep = await finalizeDeployment(api, dep);
75
96
  }
76
- if (ctx.flags['no-wait'] === true) {
77
- out.info(`Dağıtım başlatıldı (#${dep.seq}). Durum için: konsol → Geliştirici sitesi → Sürümler.`);
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(` durum: ${s}`),
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', `✓ Yayında: ${final.url ?? info.url} (sürüm #${final.seq})`));
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(`Uyarı: bu dağıtım (#${final.seq}) daha yeni bir sürümle değiştirildi; canlı olan daha yeni sürümdür.`);
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(`✗ Dağıtım başarısız (#${final.seq}, ${final.status}): ${final.error ?? 'bilinmeyen neden'}`);
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 pozitif bir saniye sayısı olmalı (verilen: ${String(raw)}).`);
135
+ throw new CliError(`--timeout must be a positive number of seconds (got: ${String(raw)}).`);
114
136
  return n;
115
137
  }
@@ -1,6 +1,6 @@
1
1
  import { discoverApp, fetchAccessToken, rolesFromClaims } from '../auth.js';
2
2
  import { DeployClient } from '../site.js';
3
- /** enformik whoami: hangi kimlikle, hangi hedefe bağlanıldığını gösterir (sır yazdırmaz). */
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('Şema yükseltme (API istemcisi)');
14
- out.info(` Uygulama : ${settings.appUrl}`);
15
- out.info(` İstemci : ${settings.clientId}`);
16
- out.info(` Roller : ${roles.join(', ') || '(belirteçte rol yok)'}`);
17
- out.info(` Çalışma alanı : ${settings.workspaceId || app.tenantId || '(belirtilmedi)'}`);
18
- out.info(` Belirteç : geçerli (${Math.round(tok.expiresInSec / 60)} dk)`);
19
- result.schema = { appUrl: settings.appUrl, clientId: settings.clientId, roles, workspaceId: settings.workspaceId || app.tenantId || null };
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 dağıtımı (dağıtım anahtarı)');
25
- out.info(` Konsol : ${settings.consoleUrl}`);
26
- out.info(` Anahtar : ${settings.deployKey.slice(0, 12)}… (bitiş ${info.key.expiresAt.slice(0, 10)})`);
27
- out.info(` Ortam / adres : ${info.environment.name} → ${info.url}`);
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('Tanımlı kimlik bilgisi yok. Gerekenler:');
32
- out.info(' Şema yükseltme : ENFORMIK_APP_URL (ya da link) + ENFORMIK_CLIENT_ID + ENFORMIK_CLIENT_SECRET');
33
- out.info(' Site dağıtımı : ENFORMIK_CONSOLE_URL (ya da link) + ENFORMIK_DEPLOY_KEY');
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(`Yapılandırma okunamadı: ${path}`);
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(`Yapılandırma geçerli JSON değil: ${path}`);
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(`Yapılandırma bir nesne olmalı: ${path}`);
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} sır içeriyor gibi görünüyor. Sırlar yapılandırma dosyasına değil, ortam değişkenlerine (ENFORMIK_CLIENT_SECRET, ENFORMIK_DEPLOY_KEY) yazılır; dosyadan çıkarın.`);
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 rl = createInterface({ input: process.stdin, output: process.stderr });
6
- try {
7
- const a = (await rl.question(`${question} [e/H]: `)).trim().toLowerCase();
8
- return a === 'e' || a === 'evet' || a === 'y' || a === 'yes';
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 rl = createInterface({ input: process.stdin, output: process.stderr });
16
- try {
17
- const a = (await rl.question(def ? `${question} [${def}]: ` : `${question}: `)).trim();
18
- return a || def || '';
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
- constructor(status, message, code, body) {
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} geçerli bir adres değil: ${raw}`);
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 olmalı (kimlik bilgileri açık metin gönderilmez): ${raw}`);
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
- const reason = e?.name === 'TimeoutError' ? 'zaman aşımı' : (e?.cause?.code ?? e?.message ?? 'bilinmeyen hata');
58
- throw new CliError(`Sunucuya ulaşılamadı (${new URL(url).host}): ${reason}`);
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
- const message = body?.message ?? (typeof body?.error === 'string' ? body.error : res.text.trim().slice(0, 300)) ?? '';
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 && !(json && !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: 'dağıtım anahtarı (enf_dk_…)', re: /enf_dk_[A-Za-z0-9_-]{16,}/ },
10
- { kind: 'istemci sırrı (client_secret)', re: /client_?secret["']?\s*[:=]\s*["'`][^"'`\s]{8,}["'`]/i },
11
- { kind: 'ENFORMIK_CLIENT_SECRET değeri', re: /ENFORMIK_CLIENT_SECRET["']?\s*[:=]\s*["'`][^"'`\s]{6,}["'`]/ },
12
- { kind: 'sabit Basic kimlik başlığı', re: /Authorization["']?\s*:\s*["'`]Basic\s+[A-Za-z0-9+/=]{16,}/i },
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()) || f.size > SCAN_MAX_BYTES)
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: `Pakette sır var gibi görünüyor: ${hit.kind}. Tarayıcıya giden dosyalara sır konmaz; sırrı paketten çıkarıp (ve sızdıysa iptal edip) yeniden derleyin.` });
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}" dizini platform için ayrılmıştır; arşivden çıkarın.`);
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, `Gizli dosya/dizin yüklenemez ("${ent.name}"). .env, .git gibi dosyaları arşive koymayın.`);
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, 'Sembolik bağ veya özel dosya desteklenmez.');
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, 'Aynı yol büyük/küçük harf farkıyla birden fazla var.');
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, `Tek dosya en fazla ${Math.floor(limits.maxFileBytes / 1048576)} MB olabilir.`);
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
- for (const s of scanSecrets(files))
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, `Arşivde en fazla ${limits.maxFiles} dosya olabilir (${files.length} bulundu).`);
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, `Sitenin açılmış boyutu ortamınızın disk kotasını aşıyor (${total} > ${limits.maxUnpackedBytes} bayt).`);
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 bir üst klasörün ("${d}") içinde. Klasörün kendisini değil, İÇERİĞİNİ yayınlayın (--dir ${join(dir, d)}).`);
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', 'Dizinin kökünde index.html bulunmalı.');
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
  }