@enformik/cli 0.5.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 +47 -38
- package/dist/codegen.js +6 -6
- package/dist/commands/ai.js +29 -25
- package/dist/commands/app.js +32 -26
- package/dist/commands/codegen.js +4 -4
- package/dist/commands/link.js +12 -12
- package/dist/commands/schema.js +43 -39
- package/dist/commands/site.js +22 -23
- package/dist/commands/whoami.js +19 -15
- package/dist/config.js +4 -4
- package/dist/context.js +32 -17
- package/dist/errors.js +5 -1
- package/dist/http.js +32 -5
- package/dist/precheck.js +21 -21
- package/dist/schema.js +29 -27
- package/dist/site.js +47 -15
- package/dist/zip.js +3 -3
- package/package.json +2 -2
package/dist/commands/site.js
CHANGED
|
@@ -4,14 +4,14 @@ import { join, resolve } from 'node:path';
|
|
|
4
4
|
import { CliError } from '../errors.js';
|
|
5
5
|
import { formatBytes } from '../output.js';
|
|
6
6
|
import { precheckSite, parseScanIgnore, DEFAULT_LIMITS } from '../precheck.js';
|
|
7
|
-
import { collectMeta, DeployClient, idempotencyKey, isTerminal, MAX_ARCHIVE_BYTES, sha256File, waitForDeployment } from '../site.js';
|
|
7
|
+
import { collectMeta, DeployClient, finalizeDeployment, idempotencyKey, isTerminal, MAX_ARCHIVE_BYTES, sha256File, waitForDeployment } from '../site.js';
|
|
8
8
|
import { writeZipFile } from '../zip.js';
|
|
9
9
|
function client(ctx) {
|
|
10
10
|
const { settings } = ctx;
|
|
11
11
|
if (!settings.consoleUrl)
|
|
12
|
-
throw new CliError('
|
|
12
|
+
throw new CliError('No console URL. Run `enformik link --console-url https://…` or set ENFORMIK_CONSOLE_URL.');
|
|
13
13
|
if (!settings.deployKey)
|
|
14
|
-
throw new CliError('ENFORMIK_DEPLOY_KEY
|
|
14
|
+
throw new CliError('ENFORMIK_DEPLOY_KEY is not set (in the console: Add-on environments → Developer site → Deploy keys).');
|
|
15
15
|
return new DeployClient({ consoleUrl: settings.consoleUrl, deployKey: settings.deployKey, version: ctx.version, fetchImpl: ctx.fetchImpl });
|
|
16
16
|
}
|
|
17
17
|
/** --expect-host verilmişse hedef sitenin ana makine adı eşleşmeli; eşleşmezse hiçbir şey yüklenmeden durur. */
|
|
@@ -25,19 +25,19 @@ function assertExpectedHost(ctx, info) {
|
|
|
25
25
|
}
|
|
26
26
|
catch { /* adres bozuksa eşleşmez */ }
|
|
27
27
|
if (expect.toLowerCase() !== host.toLowerCase()) {
|
|
28
|
-
throw new CliError(`
|
|
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
29
|
}
|
|
30
30
|
}
|
|
31
31
|
/** enformik site status */
|
|
32
32
|
export async function siteStatus(ctx) {
|
|
33
33
|
const info = await client(ctx).getSite();
|
|
34
34
|
assertExpectedHost(ctx, info);
|
|
35
|
-
ctx.out.info(`
|
|
36
|
-
ctx.out.info(`
|
|
35
|
+
ctx.out.info(`Website environment : ${info.environment.name}`);
|
|
36
|
+
ctx.out.info(`URL : ${info.url}`);
|
|
37
37
|
if (info.app)
|
|
38
|
-
ctx.out.info(`
|
|
39
|
-
ctx.out.info(`
|
|
40
|
-
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)}`);
|
|
41
41
|
ctx.out.result(info);
|
|
42
42
|
return 0;
|
|
43
43
|
}
|
|
@@ -51,7 +51,7 @@ export async function siteDeploy(ctx) {
|
|
|
51
51
|
throw new Error();
|
|
52
52
|
}
|
|
53
53
|
catch {
|
|
54
|
-
throw new CliError(`
|
|
54
|
+
throw new CliError(`Build folder not found: ${dir} (build the site first or pass --dir).`);
|
|
55
55
|
}
|
|
56
56
|
const timeoutSec = parseTimeout(ctx.flags.timeout);
|
|
57
57
|
// Sunucunun kotası biliniyorsa ön denetimde kullan (aşımı yüklemeden görmek için).
|
|
@@ -67,7 +67,7 @@ export async function siteDeploy(ctx) {
|
|
|
67
67
|
for (const w of pre.warnings)
|
|
68
68
|
out.warn(w);
|
|
69
69
|
if (pre.issues.length > 0) {
|
|
70
|
-
out.error('
|
|
70
|
+
out.error('The site archive does not meet the rules; nothing was uploaded:');
|
|
71
71
|
for (const i of pre.issues)
|
|
72
72
|
out.error(` - ${i.path ? i.path + ': ' : ''}${i.message}`);
|
|
73
73
|
out.result({ deployed: false, issues: pre.issues });
|
|
@@ -78,46 +78,45 @@ export async function siteDeploy(ctx) {
|
|
|
78
78
|
const zipPath = join(tmp, 'site.zip');
|
|
79
79
|
const z = writeZipFile(pre.files.map((f) => ({ name: f.name, path: f.path, mtime: f.mtime })), zipPath);
|
|
80
80
|
if (z.bytes > MAX_ARCHIVE_BYTES)
|
|
81
|
-
throw new CliError(`
|
|
82
|
-
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)`);
|
|
83
83
|
const sha = sha256File(zipPath);
|
|
84
84
|
const init = await api.init({ sizeBytes: z.bytes, sha256: sha, meta: collectMeta(ctx.env, ctx.cwd) }, idempotencyKey(ctx.env, undefined, sha));
|
|
85
85
|
let dep = init.deployment;
|
|
86
86
|
if (init.replayed)
|
|
87
|
-
out.info(
|
|
87
|
+
out.info(`Found a previous attempt (#${dep.seq}, status: ${dep.status}); resuming where it left off.`);
|
|
88
88
|
// Durumuna göre devam: yükle → sonlandır → bekle (yeniden oynatılan kayıt için de doğru).
|
|
89
89
|
if (dep.status === 'awaiting_upload') {
|
|
90
|
-
out.info(`
|
|
90
|
+
out.info(`Uploading (#${dep.seq})…`);
|
|
91
91
|
await api.upload(dep.deploymentId, zipPath);
|
|
92
92
|
dep = { ...dep, status: 'uploaded' };
|
|
93
93
|
}
|
|
94
94
|
if (dep.status === 'uploaded') {
|
|
95
|
-
await api
|
|
96
|
-
dep = { ...dep, status: 'validating' };
|
|
95
|
+
dep = await finalizeDeployment(api, dep);
|
|
97
96
|
}
|
|
98
97
|
// Terminal durumdaki (failed/expired/live) yeniden oynatılan kayıt --no-wait ile de gerçek sonucu vermeli.
|
|
99
98
|
if (ctx.flags['no-wait'] === true && !isTerminal(dep.status)) {
|
|
100
|
-
out.info(`
|
|
99
|
+
out.info(`Deployment started (#${dep.seq}). To follow its status: console → Developer site → Releases.`);
|
|
101
100
|
out.result({ deployed: false, deploymentId: dep.deploymentId, seq: dep.seq, status: dep.status });
|
|
102
101
|
return 0;
|
|
103
102
|
}
|
|
104
103
|
const final = isTerminal(dep.status) ? dep : await waitForDeployment(api, dep.deploymentId, {
|
|
105
104
|
timeoutMs: timeoutSec * 1000,
|
|
106
|
-
onStatus: (s) => out.info(`
|
|
105
|
+
onStatus: (s) => out.info(` status: ${s}`),
|
|
107
106
|
sleep: ctx.sleep,
|
|
108
107
|
});
|
|
109
108
|
if (final.status === 'live') {
|
|
110
|
-
out.info(out.paint('green', `✓
|
|
109
|
+
out.info(out.paint('green', `✓ Live: ${final.url ?? info.url} (release #${final.seq})`));
|
|
111
110
|
out.result({ deployed: true, deploymentId: final.deploymentId, seq: final.seq, url: final.url ?? info.url });
|
|
112
111
|
return 0;
|
|
113
112
|
}
|
|
114
113
|
if (final.status === 'superseded') {
|
|
115
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.
|
|
116
|
-
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.`);
|
|
117
116
|
out.result({ deployed: false, superseded: true, status: 'superseded', deploymentId: final.deploymentId, seq: final.seq, url: info.url });
|
|
118
117
|
return 0;
|
|
119
118
|
}
|
|
120
|
-
out.error(`✗
|
|
119
|
+
out.error(`✗ Deployment failed (#${final.seq}, ${final.status}): ${final.error ?? 'unknown reason'}`);
|
|
121
120
|
for (const i of final.issues ?? [])
|
|
122
121
|
out.error(` - ${i.path ? i.path + ': ' : ''}${i.message}`);
|
|
123
122
|
out.result({ deployed: false, deploymentId: final.deploymentId, seq: final.seq, status: final.status, error: final.error, issues: final.issues });
|
|
@@ -133,6 +132,6 @@ function parseTimeout(raw) {
|
|
|
133
132
|
return 600;
|
|
134
133
|
const n = typeof raw === 'string' ? Number(raw) : NaN;
|
|
135
134
|
if (!Number.isFinite(n) || n <= 0)
|
|
136
|
-
throw new CliError(`--timeout
|
|
135
|
+
throw new CliError(`--timeout must be a positive number of seconds (got: ${String(raw)}).`);
|
|
137
136
|
return n;
|
|
138
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,28 +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
|
-
out.info(`
|
|
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
20
|
result.schema = { appUrl: settings.appUrl, authorityHost: new URL(app.oidcAuthority).host, clientId: settings.clientId, roles, workspaceId: settings.workspaceId || app.tenantId || null };
|
|
21
21
|
}
|
|
22
22
|
if (settings.consoleUrl && settings.deployKey) {
|
|
23
23
|
any = true;
|
|
24
24
|
const info = await new DeployClient({ consoleUrl: settings.consoleUrl, deployKey: settings.deployKey, version: ctx.version, fetchImpl: ctx.fetchImpl }).getSite();
|
|
25
|
-
out.info('Site
|
|
26
|
-
out.info(`
|
|
27
|
-
out.info(`
|
|
28
|
-
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}`);
|
|
29
29
|
result.site = { consoleUrl: settings.consoleUrl, keyPrefix: settings.deployKey.slice(0, 12), expiresAt: info.key.expiresAt, environment: info.environment.name, url: info.url };
|
|
30
30
|
}
|
|
31
31
|
if (!any) {
|
|
32
|
-
out.info('
|
|
33
|
-
out.info('
|
|
34
|
-
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
|
+
});
|
|
35
39
|
return 1;
|
|
36
40
|
}
|
|
37
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
|
}
|
|
@@ -59,15 +59,42 @@ export async function request(url, opts = {}) {
|
|
|
59
59
|
}
|
|
60
60
|
const e = lastErr;
|
|
61
61
|
if (isRedirectError(e)) {
|
|
62
|
-
throw new CliError(`
|
|
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
63
|
}
|
|
64
|
-
const reason = e?.name === 'TimeoutError' ? '
|
|
65
|
-
throw new CliError(`
|
|
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);
|
|
66
91
|
}
|
|
67
92
|
/** Yanıt başarısızsa ApiError fırlatır; gövdeden {error, message} ya da düz metin okur. */
|
|
68
93
|
export function ensureOk(res) {
|
|
69
94
|
if (res.ok)
|
|
70
95
|
return res;
|
|
96
|
+
if (res.status === 429)
|
|
97
|
+
throw rateLimitedError(res);
|
|
71
98
|
const body = res.json;
|
|
72
99
|
const code = typeof body?.error === 'string' ? body.error : undefined;
|
|
73
100
|
// Ara katmanların (CDN/WAF) HTML hata sayfaları mesaja girmez; yalnızca "HTTP <durum>" kalır.
|
package/dist/precheck.js
CHANGED
|
@@ -21,13 +21,13 @@ export function fileTypeIssue(relPath) {
|
|
|
21
21
|
const dot = base.lastIndexOf('.');
|
|
22
22
|
const ext = dot > 0 ? base.slice(dot).toLowerCase() : '';
|
|
23
23
|
if (EXECUTABLE_EXTS.has(ext)) {
|
|
24
|
-
return { code: 'executable_file', message:
|
|
24
|
+
return { code: 'executable_file', message: `Executable/package files (${ext}) cannot be uploaded. Remove files the site does not need from the folder.` };
|
|
25
25
|
}
|
|
26
26
|
if (ext === '') {
|
|
27
|
-
return relPath.startsWith('.well-known/') ? null : { code: 'disallowed_file_type', message: '
|
|
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
28
|
}
|
|
29
29
|
if (!ALLOWED_EXTS.has(ext)) {
|
|
30
|
-
return { code: 'disallowed_file_type', message: `${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
31
|
}
|
|
32
32
|
return null;
|
|
33
33
|
}
|
|
@@ -39,10 +39,10 @@ const MAX_PATH_BYTES = 255;
|
|
|
39
39
|
// Tarayıcı paketine sızmaması gereken sırlar. Değer ASLA çıktıya yazılmaz; yalnızca dosya yolu ve tür bildirilir.
|
|
40
40
|
// Bilerek dar tutulur (yanlış alarm CI'ı kırar): dağıtım anahtarı, istemci sırrı atamaları, sabit Basic kimlik başlığı.
|
|
41
41
|
const SECRET_PATTERNS = [
|
|
42
|
-
{ kind: '
|
|
43
|
-
{ kind: '
|
|
44
|
-
{ kind: 'ENFORMIK_CLIENT_SECRET
|
|
45
|
-
{ 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 },
|
|
46
46
|
];
|
|
47
47
|
const SCAN_EXT = new Set(['.js', '.mjs', '.cjs', '.html', '.htm', '.json', '.map', '.css', '.txt', '.xml', '.svg', '.webmanifest', '.csv', '.md']);
|
|
48
48
|
const SCAN_MAX_BYTES = 5 * 1024 * 1024;
|
|
@@ -79,7 +79,7 @@ export function scanSecrets(files, ignore = []) {
|
|
|
79
79
|
}
|
|
80
80
|
const hit = SECRET_PATTERNS.find((p) => p.re.test(text));
|
|
81
81
|
if (hit)
|
|
82
|
-
out.issues.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.` });
|
|
83
83
|
}
|
|
84
84
|
return out;
|
|
85
85
|
}
|
|
@@ -99,24 +99,24 @@ export function precheckSite(dir, limits = DEFAULT_LIMITS, opts = {}) {
|
|
|
99
99
|
const relPath = rel ? `${rel}/${ent.name}` : ent.name;
|
|
100
100
|
const absPath = join(abs, ent.name);
|
|
101
101
|
if (relPath.split('/')[0] === RESERVED_DIR) {
|
|
102
|
-
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.`);
|
|
103
103
|
continue;
|
|
104
104
|
}
|
|
105
105
|
if (ent.name.startsWith('.') && ent.name !== '.well-known') {
|
|
106
|
-
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
107
|
continue;
|
|
108
108
|
}
|
|
109
109
|
if (Buffer.byteLength(relPath) > MAX_PATH_BYTES) {
|
|
110
|
-
add('unsafe_path', relPath, `
|
|
110
|
+
add('unsafe_path', relPath, `A path cannot be longer than ${MAX_PATH_BYTES} bytes.`);
|
|
111
111
|
continue;
|
|
112
112
|
}
|
|
113
113
|
if (relPath.includes('\\') || /[\u0000-\u001f\u007f]/.test(relPath)) {
|
|
114
|
-
add('unsafe_path', relPath, '
|
|
114
|
+
add('unsafe_path', relPath, 'The path contains an invalid or control character.');
|
|
115
115
|
continue;
|
|
116
116
|
}
|
|
117
117
|
const st = lstatSync(absPath);
|
|
118
118
|
if (st.isSymbolicLink() || (!st.isFile() && !st.isDirectory())) {
|
|
119
|
-
add('unsupported_entry', relPath, '
|
|
119
|
+
add('unsupported_entry', relPath, 'Symbolic links and special files are not supported.');
|
|
120
120
|
continue;
|
|
121
121
|
}
|
|
122
122
|
if (st.isDirectory()) {
|
|
@@ -130,7 +130,7 @@ export function precheckSite(dir, limits = DEFAULT_LIMITS, opts = {}) {
|
|
|
130
130
|
}
|
|
131
131
|
const key = relPath.toLowerCase();
|
|
132
132
|
if (seen.has(key)) {
|
|
133
|
-
add('duplicate_path', relPath, '
|
|
133
|
+
add('duplicate_path', relPath, 'The same path appears more than once, differing only in letter case.');
|
|
134
134
|
continue;
|
|
135
135
|
}
|
|
136
136
|
seen.add(key);
|
|
@@ -140,7 +140,7 @@ export function precheckSite(dir, limits = DEFAULT_LIMITS, opts = {}) {
|
|
|
140
140
|
if (parts.length === 2 && parts[1] === 'index.html')
|
|
141
141
|
nestedIndexDirs.add(parts[0]);
|
|
142
142
|
if (st.size > limits.maxFileBytes) {
|
|
143
|
-
add('file_too_large', relPath, `
|
|
143
|
+
add('file_too_large', relPath, `A single file can be at most ${Math.floor(limits.maxFileBytes / 1048576)} MB.`);
|
|
144
144
|
continue;
|
|
145
145
|
}
|
|
146
146
|
files.push({ name: relPath, path: absPath, size: st.size, mtime: st.mtime });
|
|
@@ -153,22 +153,22 @@ export function precheckSite(dir, limits = DEFAULT_LIMITS, opts = {}) {
|
|
|
153
153
|
add(s.code, s.path, s.message);
|
|
154
154
|
const warnings = [];
|
|
155
155
|
if (scan.ignored.length > 0)
|
|
156
|
-
warnings.push(
|
|
156
|
+
warnings.push(`Files skipped by the secret scan because of .enformikignore: ${scan.ignored.join(', ')}`);
|
|
157
157
|
if (scan.unscanned.length > 0)
|
|
158
|
-
warnings.push(
|
|
158
|
+
warnings.push(`Files not scanned for secrets because they exceed ${SCAN_MAX_BYTES / 1048576} MB: ${scan.unscanned.join(', ')}`);
|
|
159
159
|
if (files.length > limits.maxFiles) {
|
|
160
|
-
add('too_many_files', undefined, `
|
|
160
|
+
add('too_many_files', undefined, `The archive can contain at most ${limits.maxFiles} files (${files.length} found).`);
|
|
161
161
|
}
|
|
162
162
|
if (limits.maxUnpackedBytes && total > limits.maxUnpackedBytes) {
|
|
163
|
-
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).`);
|
|
164
164
|
}
|
|
165
165
|
if (!hasRootIndex && issues.length < MAX_ISSUES) {
|
|
166
166
|
if (nestedIndexDirs.size === 1) {
|
|
167
167
|
const d = [...nestedIndexDirs][0];
|
|
168
|
-
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)}).`);
|
|
169
169
|
}
|
|
170
170
|
else {
|
|
171
|
-
add('missing_index', 'index.html', '
|
|
171
|
+
add('missing_index', 'index.html', 'index.html must be at the root of the directory.');
|
|
172
172
|
}
|
|
173
173
|
}
|
|
174
174
|
return { files, unpackedBytes: total, issues, warnings };
|
package/dist/schema.js
CHANGED
|
@@ -9,15 +9,15 @@ export function parseManifest(text, path) {
|
|
|
9
9
|
m = JSON.parse(stripBom(text));
|
|
10
10
|
}
|
|
11
11
|
catch (e) {
|
|
12
|
-
throw new CliError(`${path}
|
|
12
|
+
throw new CliError(`${path} is not valid JSON: ${e.message}`);
|
|
13
13
|
}
|
|
14
14
|
if (typeof m !== 'object' || m === null || Array.isArray(m))
|
|
15
|
-
throw new CliError(`${path}
|
|
15
|
+
throw new CliError(`${path} must be an object.`);
|
|
16
16
|
const mod = m.module;
|
|
17
17
|
if (!mod || typeof mod.slug !== 'string' || !mod.slug)
|
|
18
|
-
throw new CliError(`${path}: "module.slug"
|
|
18
|
+
throw new CliError(`${path}: "module.slug" is required.`);
|
|
19
19
|
if (typeof mod.version !== 'string' || !mod.version)
|
|
20
|
-
throw new CliError(`${path}: "module.version"
|
|
20
|
+
throw new CliError(`${path}: "module.version" is required.`);
|
|
21
21
|
return m;
|
|
22
22
|
}
|
|
23
23
|
/**
|
|
@@ -65,19 +65,19 @@ export function typeChanges(diffs) {
|
|
|
65
65
|
return diffs.filter((d) => d.resourceType === 'field' && d.action === 'alter');
|
|
66
66
|
}
|
|
67
67
|
const ACTION_LABEL = {
|
|
68
|
-
create: '+
|
|
68
|
+
create: '+ add', alter: '~ change', drop: '- delete', none: '= same', kept_local: '= kept local',
|
|
69
69
|
};
|
|
70
70
|
export function renderDiff(out, result, opts = {}) {
|
|
71
71
|
const lines = [];
|
|
72
72
|
const pending = pendingChanges(result.diffs);
|
|
73
73
|
if (pending.length > 0) {
|
|
74
|
-
lines.push(out.paint('yellow', out.paint('bold', '⚠
|
|
75
|
-
lines.push(out.paint('yellow', `
|
|
74
|
+
lines.push(out.paint('yellow', out.paint('bold', '⚠ WARNING: SCHEMA CHANGES AFFECT THE ENTIRE APP')));
|
|
75
|
+
lines.push(out.paint('yellow', `This affects ALL workspaces in the ${opts.appHost ? `"${opts.appHost}" ` : ''}app, not just one workspace.`));
|
|
76
76
|
lines.push('');
|
|
77
77
|
}
|
|
78
78
|
const shown = result.diffs.filter((d) => d.action !== 'none');
|
|
79
79
|
if (shown.length === 0) {
|
|
80
|
-
lines.push('
|
|
80
|
+
lines.push('No changes: the module in the app matches the manifest.');
|
|
81
81
|
}
|
|
82
82
|
else {
|
|
83
83
|
const rows = shown.map((d) => {
|
|
@@ -85,17 +85,17 @@ export function renderDiff(out, result, opts = {}) {
|
|
|
85
85
|
const colored = d.action === 'create' ? out.paint('green', label)
|
|
86
86
|
: d.action === 'drop' ? out.paint('red', label)
|
|
87
87
|
: d.action === 'alter' ? out.paint('yellow', label) : out.paint('dim', label);
|
|
88
|
-
const detail = d.action === 'drop' && d.hasData ? '
|
|
88
|
+
const detail = d.action === 'drop' && d.hasData ? 'will be SKIPPED because it contains data' : (d.details || d.warning || '');
|
|
89
89
|
return [d.resourceType, d.slug, colored, detail];
|
|
90
90
|
});
|
|
91
|
-
lines.push(renderTable(out, ['
|
|
91
|
+
lines.push(renderTable(out, ['TYPE', 'NAME', 'ACTION', 'DETAILS'], rows));
|
|
92
92
|
const count = (a) => shown.filter((d) => d.action === a).length;
|
|
93
93
|
lines.push('');
|
|
94
|
-
lines.push(
|
|
95
|
-
(count('kept_local') ? `, ${count('kept_local')}
|
|
94
|
+
lines.push(`Summary: ${count('create')} to add, ${count('alter')} to change, ${count('drop')} to delete` +
|
|
95
|
+
(count('kept_local') ? `, ${count('kept_local')} local customizations kept` : '') + '.');
|
|
96
96
|
}
|
|
97
97
|
for (const e of result.errors ?? [])
|
|
98
|
-
lines.push(out.paint('red', `
|
|
98
|
+
lines.push(out.paint('red', `Error: ${e}`));
|
|
99
99
|
return lines.join('\n');
|
|
100
100
|
}
|
|
101
101
|
export class SchemaClient {
|
|
@@ -125,7 +125,7 @@ export class SchemaClient {
|
|
|
125
125
|
const j = (res.json ?? {});
|
|
126
126
|
// Beklenmeyen yanıt biçimi "/api/modules/undefined/diff" çağrısına dönüşmesin.
|
|
127
127
|
if (typeof j.id !== 'string' || !j.id)
|
|
128
|
-
throw new CliError(`
|
|
128
|
+
throw new CliError(`The server response is not in the expected format: module "${slug}" has no ID.`);
|
|
129
129
|
return { id: j.id, name: j.name ?? '', version: j.version ?? '' };
|
|
130
130
|
}
|
|
131
131
|
/** slug → kurulu modül ya da null (404 module_not_found); diğer hatalar fırlatılır. */
|
|
@@ -177,7 +177,7 @@ export class SchemaClient {
|
|
|
177
177
|
method: 'POST', headers: this.headers({ 'Content-Type': 'application/json' }), body: manifestJson, retries: 1, timeoutMs: 120_000, fetchImpl: this.o.fetchImpl,
|
|
178
178
|
});
|
|
179
179
|
if (res.status === 404 || res.status === 405) {
|
|
180
|
-
throw new CliError('
|
|
180
|
+
throw new CliError('This app version does not support server-side validation (/api/modules/validate). Until the app is updated, run local-only checks with `schema validate --offline`.', 1, 'validate_unsupported');
|
|
181
181
|
}
|
|
182
182
|
try {
|
|
183
183
|
ensureOk(res);
|
|
@@ -225,31 +225,33 @@ function normalizeDiff(j) {
|
|
|
225
225
|
export function describeSchemaError(e, ctx) {
|
|
226
226
|
if (!(e instanceof ApiError))
|
|
227
227
|
return e;
|
|
228
|
+
if (e.status === 429)
|
|
229
|
+
return e; // the rate-limit message is already final (see rateLimitedError); cli.ts prints it
|
|
228
230
|
const body = (e.body ?? {});
|
|
229
231
|
switch (e.code) {
|
|
230
232
|
case 'manifest_requires_admin':
|
|
231
|
-
return new CliError(`
|
|
232
|
-
'
|
|
233
|
+
return new CliError(`This manifest contains sections beyond your client's role: ${(body.sections ?? []).join(', ')}.\n` +
|
|
234
|
+
'These sections (scripts, automations, permission definitions, etc.) must be applied by an administrator; remove them from the manifest or run with a client that has the administrator role.', 1, e.code, e.body);
|
|
233
235
|
case 'type_change_requires_admin':
|
|
234
|
-
return new CliError(`
|
|
236
|
+
return new CliError(`Changing a field type can transform data and is applied only by an administrator: ${(body.fields ?? []).join(', ')}.`, 1, e.code, e.body);
|
|
235
237
|
case 'breaking_change_blocked':
|
|
236
238
|
return new CliError(`${e.message}`, 1, e.code, e.body);
|
|
237
239
|
case 'module_not_found':
|
|
238
|
-
return new CliError(`"${ctx.slug ?? '
|
|
240
|
+
return new CliError(`No module with slug "${ctx.slug ?? 'module'}" is installed in this app. The first installation is done with \`enformik schema install\` (using a client with the "Schema builder" role) or by an administrator; later upgrades are done with \`schema push\`.`, 1, e.code);
|
|
239
241
|
case 'module_exists':
|
|
240
|
-
return new CliError('
|
|
242
|
+
return new CliError('A module with this slug is already installed. Use `enformik schema push` to upgrade the existing one.', 1, e.code);
|
|
241
243
|
case 'payment_required':
|
|
242
|
-
return new CliError('
|
|
244
|
+
return new CliError('The operation was blocked because of the app\'s plan/payment status (402). Check the subscription status in the console.', 1, e.code);
|
|
243
245
|
}
|
|
244
246
|
if (e.status === 401)
|
|
245
|
-
return new CliError('
|
|
247
|
+
return new CliError('Authentication rejected (401): the token is invalid or the client has been revoked.', 1, 'unauthorized');
|
|
246
248
|
if (e.status === 403) {
|
|
247
249
|
return new CliError((ctx.install
|
|
248
|
-
? '
|
|
249
|
-
: '
|
|
250
|
-
(body.required ? ` (
|
|
250
|
+
? 'This client\'s role is not sufficient to install a new module (403). Use an API client whose role in the console is "Schema builder" or "Administrator".'
|
|
251
|
+
: 'This client\'s role is not sufficient to upgrade a module (403). Use an API client whose role in the console is "Schema upgrader", "Schema builder" or "Administrator".') +
|
|
252
|
+
(body.required ? ` (required permission: ${body.required})` : ''), 1, 'forbidden');
|
|
251
253
|
}
|
|
252
254
|
if (e.status === 404)
|
|
253
|
-
return new CliError(`
|
|
254
|
-
return new CliError(`
|
|
255
|
+
return new CliError(`Not found (404): ${e.message}`, 1, 'not_found');
|
|
256
|
+
return new CliError(`Server error (HTTP ${e.status}): ${e.message}`, 1, e.code);
|
|
255
257
|
}
|