@kedge/cli 0.0.0-stage → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/bin/kedge.mjs +8 -0
- package/kedge-manifest.json +396 -0
- package/package.json +40 -3
- package/src/cf.ts +451 -0
- package/src/check.ts +44 -0
- package/src/code-lint.ts +339 -0
- package/src/commands.ts +757 -0
- package/src/compose.ts +637 -0
- package/src/deploy.ts +1610 -0
- package/src/doctor.ts +636 -0
- package/src/fork.ts +67 -0
- package/src/index.ts +12 -0
- package/src/invariants.ts +168 -0
- package/src/main.ts +378 -0
- package/src/manifest.ts +163 -0
- package/src/n1.ts +217 -0
- package/src/ops.ts +355 -0
- package/src/order.ts +7 -0
- package/src/plugin-lint.ts +145 -0
- package/src/preview.ts +1011 -0
- package/src/schema.ts +72 -0
- package/src/sqlite-verify.ts +333 -0
- package/src/store.ts +132 -0
- package/src/tar.ts +125 -0
- package/src/tz.ts +79 -0
- package/src/units.ts +185 -0
- package/src/upgrade.ts +235 -0
- package/src/worker-config.ts +81 -0
- package/README.md +0 -3
package/src/code-lint.ts
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
// The code lint (docs/proposals/p0.8-cli-agent-kit.md §4.5, K11, K12): what SQL
|
|
2
|
+
// lint and types can't see. An effect sent around the journal is repeated
|
|
3
|
+
// after a retry or a restore and is invisible to a preview's sinks; a
|
|
4
|
+
// provider's reply or a customer's data in an error or a log line lands where
|
|
5
|
+
// erasure can't reach (customer-erasure.md §3). It parses every local module
|
|
6
|
+
// reachable from kedge.config.ts and the Worker entry, because a hook can be
|
|
7
|
+
// written inline in the config and a helper can live outside src/.
|
|
8
|
+
//
|
|
9
|
+
// It's a lint, so it can be fooled (a `fetch` through an alias passes); a
|
|
10
|
+
// preview without production credentials is the backstop. An exception is
|
|
11
|
+
// `// kedge-allow <rule>: <why>` on the line or the line above, which doctor
|
|
12
|
+
// lists, so an exception is visible rather than impossible.
|
|
13
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
14
|
+
import { dirname, extname, join, relative, resolve } from "node:path";
|
|
15
|
+
import { parse } from "@babel/parser";
|
|
16
|
+
|
|
17
|
+
export const CODE_RULES = [
|
|
18
|
+
"no-direct-network",
|
|
19
|
+
"no-raw-provider-error",
|
|
20
|
+
"no-dynamic-throw",
|
|
21
|
+
"no-console",
|
|
22
|
+
"no-classified-log",
|
|
23
|
+
] as const;
|
|
24
|
+
export type CodeRule = (typeof CODE_RULES)[number];
|
|
25
|
+
|
|
26
|
+
export interface CodeIssue {
|
|
27
|
+
file: string;
|
|
28
|
+
line: number;
|
|
29
|
+
rule: CodeRule;
|
|
30
|
+
message: string;
|
|
31
|
+
/** The `kedge-allow` reason, when the line carries one: listed, not failed. */
|
|
32
|
+
allowed?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Packages that reach a provider over the network: importing one outside a port is a direct call. */
|
|
36
|
+
export const PROVIDER_SDKS: readonly (string | RegExp)[] = [
|
|
37
|
+
"stripe",
|
|
38
|
+
"twilio",
|
|
39
|
+
"nodemailer",
|
|
40
|
+
"resend",
|
|
41
|
+
"postmark",
|
|
42
|
+
"mailgun.js",
|
|
43
|
+
"razorpay",
|
|
44
|
+
"braintree",
|
|
45
|
+
"square",
|
|
46
|
+
"shippo",
|
|
47
|
+
"axios",
|
|
48
|
+
"node-fetch",
|
|
49
|
+
"undici",
|
|
50
|
+
"got",
|
|
51
|
+
"ky",
|
|
52
|
+
"superagent",
|
|
53
|
+
"firebase-admin",
|
|
54
|
+
/^@sendgrid\//,
|
|
55
|
+
/^@aws-sdk\//,
|
|
56
|
+
/^@google-cloud\//,
|
|
57
|
+
/^@paypal\//,
|
|
58
|
+
/^@easypost\//,
|
|
59
|
+
/^@mailchimp\//,
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
/** A port's methods: the only places an external call belongs (K12). */
|
|
63
|
+
const PORT_METHODS = new Set(["call", "observe", "credentialMode"]);
|
|
64
|
+
const NETWORK_CONSTRUCTORS = new Set(["WebSocket", "XMLHttpRequest", "EventSource"]);
|
|
65
|
+
|
|
66
|
+
// biome-ignore lint/suspicious/noExplicitAny: Babel's AST, walked generically
|
|
67
|
+
type Node = any;
|
|
68
|
+
|
|
69
|
+
const isSdk = (source: string) => PROVIDER_SDKS.some((s) => (typeof s === "string" ? source === s : s.test(source)));
|
|
70
|
+
const keyName = (n: Node): string | null =>
|
|
71
|
+
n?.type === "Identifier" ? n.name : n?.type === "StringLiteral" ? n.value : null;
|
|
72
|
+
const isFunction = (n: Node) =>
|
|
73
|
+
n?.type === "FunctionExpression" || n?.type === "ArrowFunctionExpression" || n?.type === "ObjectMethod";
|
|
74
|
+
/** A plain string: a literal, or a template with nothing interpolated. */
|
|
75
|
+
const isLiteralText = (n: Node) =>
|
|
76
|
+
n === undefined || n.type === "StringLiteral" || (n.type === "TemplateLiteral" && n.expressions.length === 0);
|
|
77
|
+
|
|
78
|
+
function calleeName(n: Node): string | null {
|
|
79
|
+
if (n?.type === "Identifier") return n.name;
|
|
80
|
+
if (n?.type === "MemberExpression" && !n.computed) return keyName(n.property);
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Whether `name` appears as an identifier anywhere in `n`. */
|
|
85
|
+
function mentions(n: Node, name: string): boolean {
|
|
86
|
+
let found = false;
|
|
87
|
+
walk(n, [], (m) => {
|
|
88
|
+
if (m.type === "Identifier" && m.name === name) found = true;
|
|
89
|
+
});
|
|
90
|
+
return found;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function walk(n: Node, ancestors: Node[], visit: (n: Node, ancestors: Node[]) => void): void {
|
|
94
|
+
if (!n || typeof n.type !== "string") return;
|
|
95
|
+
visit(n, ancestors);
|
|
96
|
+
ancestors.push(n);
|
|
97
|
+
for (const [k, v] of Object.entries(n)) {
|
|
98
|
+
if (k === "loc" || k === "leadingComments" || k === "trailingComments" || k === "innerComments" || k === "extra")
|
|
99
|
+
continue;
|
|
100
|
+
if (Array.isArray(v)) for (const c of v) walk(c, ancestors, visit);
|
|
101
|
+
else if (v && typeof v === "object") walk(v, ancestors, visit);
|
|
102
|
+
}
|
|
103
|
+
ancestors.pop();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The port method a node is lexically inside, if any. */
|
|
107
|
+
function inPortMethod(ancestors: readonly Node[]): boolean {
|
|
108
|
+
for (let i = ancestors.length - 1; i >= 0; i--) {
|
|
109
|
+
const a = ancestors[i];
|
|
110
|
+
if (!isFunction(a)) continue;
|
|
111
|
+
if (a.type === "ObjectMethod" && PORT_METHODS.has(keyName(a.key) ?? "")) return true;
|
|
112
|
+
const parent = ancestors[i - 1];
|
|
113
|
+
if (parent?.type === "ObjectProperty" && parent.value === a && PORT_METHODS.has(keyName(parent.key) ?? ""))
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* `// kedge-allow <rule>: <why>`: at the end of a line, for that line; on a
|
|
121
|
+
* line of its own, for the whole statement below its run of comments, since a
|
|
122
|
+
* formatter moves a loop body's comment above the loop.
|
|
123
|
+
*/
|
|
124
|
+
function allowances(
|
|
125
|
+
text: string,
|
|
126
|
+
statementEnd: ReadonlyMap<number, number>,
|
|
127
|
+
): { from: number; to: number; rule: string; reason: string }[] {
|
|
128
|
+
const out: { from: number; to: number; rule: string; reason: string }[] = [];
|
|
129
|
+
const lines = text.split("\n");
|
|
130
|
+
const commentOnly = (l: string | undefined) => l !== undefined && /^\s*(\/\/|\/\*|\*)/.test(l);
|
|
131
|
+
lines.forEach((l, i) => {
|
|
132
|
+
for (const m of l.matchAll(/kedge-allow\s+([a-z-]+):\s*(\S[^*]*?)\s*(?:\*\/|$)/g)) {
|
|
133
|
+
let at = i;
|
|
134
|
+
if (commentOnly(l)) while (commentOnly(lines[at])) at++;
|
|
135
|
+
const from = at + 1;
|
|
136
|
+
const to = commentOnly(l) ? (statementEnd.get(from) ?? from) : from;
|
|
137
|
+
out.push({ from, to, rule: m[1] as string, reason: m[2] as string });
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function lintSource(file: string, text: string): CodeIssue[] {
|
|
144
|
+
const ast = parse(text, {
|
|
145
|
+
sourceType: "module",
|
|
146
|
+
plugins: extname(file) === ".tsx" ? ["typescript", "jsx"] : ["typescript"],
|
|
147
|
+
errorRecovery: false,
|
|
148
|
+
});
|
|
149
|
+
const issues: CodeIssue[] = [];
|
|
150
|
+
const add = (n: Node, rule: CodeRule, message: string) =>
|
|
151
|
+
issues.push({ file, line: n.loc?.start.line ?? 0, rule, message });
|
|
152
|
+
const socketConnects = new Set<string>();
|
|
153
|
+
|
|
154
|
+
walk(ast.program, [], (n, ancestors) => {
|
|
155
|
+
switch (n.type) {
|
|
156
|
+
case "ImportDeclaration": {
|
|
157
|
+
const source = n.source.value as string;
|
|
158
|
+
if (source === "cloudflare:sockets")
|
|
159
|
+
for (const s of n.specifiers)
|
|
160
|
+
if (s.imported && keyName(s.imported) === "connect") socketConnects.add(s.local.name);
|
|
161
|
+
if (isSdk(source))
|
|
162
|
+
add(
|
|
163
|
+
n,
|
|
164
|
+
"no-direct-network",
|
|
165
|
+
`imports "${source}", a provider's SDK: call it only inside a port's call, observe or credentialMode`,
|
|
166
|
+
);
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
case "CallExpression": {
|
|
170
|
+
const c = n.callee;
|
|
171
|
+
const direct =
|
|
172
|
+
(c.type === "Identifier" && (c.name === "fetch" || socketConnects.has(c.name))) ||
|
|
173
|
+
(c.type === "MemberExpression" &&
|
|
174
|
+
!c.computed &&
|
|
175
|
+
keyName(c.property) === "fetch" &&
|
|
176
|
+
c.object.type === "Identifier" &&
|
|
177
|
+
["globalThis", "self", "window"].includes(c.object.name));
|
|
178
|
+
if (direct && !inPortMethod(ancestors))
|
|
179
|
+
add(
|
|
180
|
+
n,
|
|
181
|
+
"no-direct-network",
|
|
182
|
+
"calls the network outside a port: an effect sent here bypasses the journal and repeats after a retry or a restore",
|
|
183
|
+
);
|
|
184
|
+
if (c.type === "MemberExpression" && c.object.type === "Identifier" && c.object.name === "console") {
|
|
185
|
+
if (!n.arguments.every(isLiteralText))
|
|
186
|
+
add(
|
|
187
|
+
n,
|
|
188
|
+
"no-console",
|
|
189
|
+
"logs something other than literal text: a log line can carry a customer's data, and erasure can't reach logs",
|
|
190
|
+
);
|
|
191
|
+
const objects = n.arguments.flatMap((a: Node) =>
|
|
192
|
+
a.type === "CallExpression" && calleeName(a.callee) === "stringify" ? a.arguments : [a],
|
|
193
|
+
);
|
|
194
|
+
if (
|
|
195
|
+
objects.some(
|
|
196
|
+
(a: Node) =>
|
|
197
|
+
a.type === "Identifier" ||
|
|
198
|
+
(a.type === "ObjectExpression" && a.properties.some((p: Node) => p.type === "SpreadElement")),
|
|
199
|
+
)
|
|
200
|
+
)
|
|
201
|
+
add(
|
|
202
|
+
n,
|
|
203
|
+
"no-classified-log",
|
|
204
|
+
"logs a whole value: name each field logged, so nothing classified rides along",
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
case "NewExpression": {
|
|
210
|
+
if (n.callee.type === "Identifier" && NETWORK_CONSTRUCTORS.has(n.callee.name) && !inPortMethod(ancestors))
|
|
211
|
+
add(n, "no-direct-network", `opens a ${n.callee.name} outside a port`);
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
case "ThrowStatement": {
|
|
215
|
+
const e = n.argument;
|
|
216
|
+
if (e?.type !== "NewExpression" && e?.type !== "CallExpression") break;
|
|
217
|
+
const name = calleeName(e.callee) ?? "";
|
|
218
|
+
// Two kinds don't reach a log (§4.5's build note): a config error is
|
|
219
|
+
// thrown while a config resolves at boot, from its declarations,
|
|
220
|
+
// before any request exists; and a KedgeError is the requester's own
|
|
221
|
+
// refusal, rendered into its response, while core logs and records
|
|
222
|
+
// only its code (the server's onError, the relay's failureCode).
|
|
223
|
+
// A constructor (`new X()` or `X()` for a class name), not a helper
|
|
224
|
+
// like mapD1Error(e) that returns an error it already built.
|
|
225
|
+
if (!/^(?:[A-Z]\w*)?Error$/.test(name) || /ConfigError$/.test(name) || name === "KedgeError") break;
|
|
226
|
+
const texts = [e.arguments[0]];
|
|
227
|
+
if (!texts.every(isLiteralText))
|
|
228
|
+
add(
|
|
229
|
+
n,
|
|
230
|
+
"no-dynamic-throw",
|
|
231
|
+
`throws a ${name} whose message isn't literal text: an error's message reaches logs and Workflow history, which erasure can't reach`,
|
|
232
|
+
);
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
case "CatchClause": {
|
|
236
|
+
if (!inPortMethod(ancestors) || n.param?.type !== "Identifier") break;
|
|
237
|
+
const p = n.param.name as string;
|
|
238
|
+
walk(n.body, [], (m) => {
|
|
239
|
+
if (m.type === "ThrowStatement" && m.argument?.type === "Identifier" && m.argument.name === p)
|
|
240
|
+
add(m, "no-raw-provider-error", "rethrows a provider's error from a port: its text can name a customer");
|
|
241
|
+
if (
|
|
242
|
+
m.type === "NewExpression" &&
|
|
243
|
+
calleeName(m.callee) === "KedgeProviderError" &&
|
|
244
|
+
m.arguments.some((a: Node) => mentions(a, p))
|
|
245
|
+
)
|
|
246
|
+
add(
|
|
247
|
+
m,
|
|
248
|
+
"no-raw-provider-error",
|
|
249
|
+
"builds a KedgeProviderError from the provider's error: map it to a declared failure code instead",
|
|
250
|
+
);
|
|
251
|
+
if (
|
|
252
|
+
m.type === "CallExpression" &&
|
|
253
|
+
m.callee.type === "MemberExpression" &&
|
|
254
|
+
m.callee.object.type === "Identifier" &&
|
|
255
|
+
m.callee.object.name === "console" &&
|
|
256
|
+
m.arguments.some((a: Node) => mentions(a, p))
|
|
257
|
+
)
|
|
258
|
+
add(m, "no-raw-provider-error", "logs the provider's error from a port");
|
|
259
|
+
});
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// Where each statement starting on a line ends: the outermost one wins.
|
|
266
|
+
const statementEnd = new Map<number, number>();
|
|
267
|
+
walk(ast.program, [], (n) => {
|
|
268
|
+
if (!/(Statement|Declaration)$/.test(n.type) || !n.loc) return;
|
|
269
|
+
const { start, end } = n.loc;
|
|
270
|
+
statementEnd.set(start.line, Math.max(statementEnd.get(start.line) ?? 0, end.line));
|
|
271
|
+
});
|
|
272
|
+
const allowed = allowances(text, statementEnd);
|
|
273
|
+
return issues.map((i) => {
|
|
274
|
+
const a = allowed.find((x) => x.rule === i.rule && i.line >= x.from && i.line <= x.to);
|
|
275
|
+
return a ? { ...i, allowed: a.reason } : i;
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const RESOLVE = ["", ".ts", ".tsx", ".mts", "/index.ts", "/index.tsx"];
|
|
280
|
+
|
|
281
|
+
/** A relative import to its file, or null: packages and anything outside the store aren't the store's code. */
|
|
282
|
+
export function resolveImport(from: string, spec: string, root: string): string | null {
|
|
283
|
+
if (!spec.startsWith(".")) return null;
|
|
284
|
+
const base = resolve(dirname(from), spec);
|
|
285
|
+
const candidates = [...RESOLVE.map((x) => base + x), base.replace(/\.js$/, ".ts"), base.replace(/\.mjs$/, ".mts")];
|
|
286
|
+
for (const c of candidates) {
|
|
287
|
+
if (relative(root, c).startsWith("..") || c.includes(`${"/"}node_modules/`)) continue;
|
|
288
|
+
if (existsSync(c) && statSync(c).isFile()) return c;
|
|
289
|
+
}
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Every local module reachable from the entries by static, re-export and literal dynamic imports. */
|
|
294
|
+
export function reachableModules(entries: readonly string[], root: string): string[] {
|
|
295
|
+
const seen = new Set<string>();
|
|
296
|
+
const queue = entries.filter((e) => existsSync(e));
|
|
297
|
+
while (queue.length) {
|
|
298
|
+
const file = queue.pop() as string;
|
|
299
|
+
if (seen.has(file)) continue;
|
|
300
|
+
seen.add(file);
|
|
301
|
+
let ast: Node;
|
|
302
|
+
try {
|
|
303
|
+
ast = parse(readFileSync(file, "utf8"), { sourceType: "module", plugins: ["typescript", "jsx"] });
|
|
304
|
+
} catch {
|
|
305
|
+
continue; // reported by lintSource
|
|
306
|
+
}
|
|
307
|
+
walk(ast.program, [], (n) => {
|
|
308
|
+
const spec =
|
|
309
|
+
n.type === "ImportDeclaration" || n.type === "ExportNamedDeclaration" || n.type === "ExportAllDeclaration"
|
|
310
|
+
? n.source?.value
|
|
311
|
+
: n.type === "CallExpression" && n.callee.type === "Import" && n.arguments[0]?.type === "StringLiteral"
|
|
312
|
+
? n.arguments[0].value
|
|
313
|
+
: n.type === "ImportExpression" && n.source?.type === "StringLiteral"
|
|
314
|
+
? n.source.value
|
|
315
|
+
: null;
|
|
316
|
+
if (typeof spec !== "string") return;
|
|
317
|
+
const r = resolveImport(file, spec, root);
|
|
318
|
+
if (r && !seen.has(r)) queue.push(r);
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
return [...seen].sort();
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Lints the store's code: the config, the Worker entry and everything local they reach. */
|
|
325
|
+
export function lintStore(root: string, entries: readonly string[]): CodeIssue[] {
|
|
326
|
+
const out: CodeIssue[] = [];
|
|
327
|
+
for (const f of reachableModules(
|
|
328
|
+
entries.map((e) => join(root, e)),
|
|
329
|
+
root,
|
|
330
|
+
)) {
|
|
331
|
+
const rel = relative(root, f);
|
|
332
|
+
try {
|
|
333
|
+
out.push(...lintSource(rel, readFileSync(f, "utf8")));
|
|
334
|
+
} catch (e) {
|
|
335
|
+
out.push({ file: rel, line: 0, rule: "no-direct-network", message: `doesn't parse: ${(e as Error).message}` });
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return out;
|
|
339
|
+
}
|