okengine 0.10.1 → 0.10.3
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 +1 -1
- package/package.json +1 -1
- package/site/content/docs/ai/mcp.mdx +0 -1
- package/site/content/docs/elements/ai.mdx +0 -6
- package/site/content/docs/elements/channel.mdx +0 -6
- package/site/content/docs/elements/clock.mdx +2 -8
- package/site/content/docs/elements/flow.mdx +10 -17
- package/site/content/docs/elements/gate.mdx +4 -10
- package/site/content/docs/elements/signal.mdx +4 -13
- package/site/content/docs/elements/store.mdx +9 -16
- package/site/content/docs/elements/vault.mdx +1 -7
- package/site/content/docs/get-started/basic-usage.mdx +1 -9
- package/site/content/docs/get-started/introduction.mdx +1 -1
- package/site/content/docs/get-started/why.mdx +1 -2
- package/site/content/docs/index.mdx +0 -1
- package/site/content/docs/meta.json +0 -1
- package/site/content/docs/reference/cli.md +12 -12
- package/site/content/docs/reference/fx.mdx +6 -7
- package/src/auth/auth.test.ts +1 -2
- package/src/auth/bindings.ts +5 -15
- package/src/auth/gate-auth.test.ts +2 -2
- package/src/cli/ai-setup/ai-setup.test.ts +3 -1
- package/src/cli/ai-setup/apply.ts +13 -0
- package/src/cli/build.test.ts +67 -0
- package/src/cli/build.ts +33 -0
- package/src/cli/db-seed.ts +1 -1
- package/src/cli/dev-controls.test.ts +43 -48
- package/src/cli/dev-controls.ts +23 -172
- package/src/cli/dev.test.ts +90 -5
- package/src/cli/dev.ts +39 -16
- package/src/cli/doctor.ts +98 -5
- package/src/cli/load-config.images.test.ts +31 -1
- package/src/cli/load-config.ts +2 -2
- package/src/cli/safe-defaults.test.ts +1 -1
- package/src/client/notes-contract.test.ts +3 -4
- package/src/compiler/aot.test.ts +1 -2
- package/src/compiler/extract.test.ts +76 -21
- package/src/compiler/extract.ts +15 -3
- package/src/compiler/fixtures/raw/raw-unannotated.ts +2 -4
- package/src/compiler/fixtures/skyport/oke.config.ts +4 -2
- package/src/compiler/fixtures/skyport/src/flows/bookings/index.ts +4 -8
- package/src/compiler/fixtures/skyport/src/flows/payments/index.ts +1 -2
- package/src/compiler/fixtures/skyport/src/flows/support/index.ts +1 -2
- package/src/compiler/fixtures/skyport.expected.json +3 -3
- package/src/compiler/fixtures/triggers/five-triggers.ts +5 -10
- package/src/compiler/generate-adopt.test.ts +85 -0
- package/src/compiler/generate-adopt.ts +111 -0
- package/src/config/driver-defaults.test.ts +122 -0
- package/src/config/driver-defaults.ts +186 -0
- package/src/config/index.ts +97 -7
- package/src/console/server/dry-run.audit.test.ts +2 -2
- package/src/console/server/flows.ts +50 -150
- package/src/docker/compose.ts +13 -3
- package/src/docker/images-config.test.ts +168 -0
- package/src/docker/recipes/llama-cpp.ts +10 -1
- package/src/docker/stack-id.ts +1 -0
- package/src/elements/channel/declare.ts +27 -1
- package/src/elements/clock/chaos-child.ts +3 -6
- package/src/elements/clock/chaos.test.ts +1 -2
- package/src/elements/clock.test.ts +2 -4
- package/src/elements/signal/declare.ts +25 -1
- package/src/elements/store/declare.ts +27 -1
- package/src/elements/store/resource.ts +6 -11
- package/src/elements/store/upsert-app.test.ts +1 -2
- package/src/elements/vault/declare.ts +29 -1
- package/src/kernel/adopt-barrel-fresh.test.ts +103 -0
- package/src/kernel/app.ts +66 -9
- package/src/kernel/auto-registry.test.ts +198 -0
- package/src/kernel/boot-bind/channel.ts +12 -4
- package/src/kernel/boot-bind/clock.ts +3 -3
- package/src/kernel/boot-bind/gate.ts +7 -4
- package/src/kernel/boot-bind/honor-config.test.ts +5 -5
- package/src/kernel/boot-bind/journal.ts +3 -3
- package/src/kernel/boot-bind/signal.ts +3 -1
- package/src/kernel/boot-bind/store.test.ts +19 -0
- package/src/kernel/boot-bind/store.ts +24 -9
- package/src/kernel/boot-bind/vault.ts +3 -3
- package/src/kernel/boot.test.ts +19 -15
- package/src/kernel/boot.ts +81 -1
- package/src/kernel/call.test.ts +5 -10
- package/src/kernel/compensate.test.ts +3 -6
- package/src/kernel/concurrency.test.ts +3 -5
- package/src/kernel/correlation.test.ts +4 -8
- package/src/kernel/edge.test.ts +1 -1
- package/src/kernel/effects-stamping.test.ts +4 -7
- package/src/kernel/element-registries.ts +27 -0
- package/src/kernel/errors.ts +11 -0
- package/src/kernel/flow.test.ts +8 -15
- package/src/kernel/flow.ts +12 -14
- package/src/kernel/hook-timing.test.ts +2 -2
- package/src/kernel/hooks.test.ts +5 -10
- package/src/kernel/horizontal-child.ts +5 -10
- package/src/kernel/journal-boot.test.ts +2 -4
- package/src/kernel/pipeline.test.ts +6 -12
- package/src/kernel/plugin/capabilities.test.ts +1 -1
- package/src/kernel/plugin/decorate.test.ts +3 -8
- package/src/kernel/plugin/scoping.test.ts +6 -16
- package/src/kernel/plugin-elements.test.ts +1 -1
- package/src/kernel/plugin-needs.test.ts +1 -1
- package/src/kernel/ready.test.ts +1 -2
- package/src/kernel/registry-isolation.test.ts +5 -5
- package/src/kernel/triggers.ts +1 -1
- package/src/plugins/anonymous.ts +1 -3
- package/src/plugins/compression.test.ts +6 -8
- package/src/plugins/config-source.test.ts +1 -1
- package/src/plugins/config-source.ts +1 -2
- package/src/plugins/cors.test.ts +10 -10
- package/src/plugins/csrf.test.ts +2 -2
- package/src/plugins/headers.test.ts +14 -15
- package/src/plugins/ip-allowlist.test.ts +9 -9
- package/src/plugins/magic-link.ts +2 -6
- package/src/plugins/maintenance-mode.test.ts +6 -6
- package/src/plugins/otp.ts +5 -15
- package/src/plugins/passkey.ts +4 -12
- package/src/plugins/two-factor.ts +3 -9
- package/src/plugins/username.ts +2 -6
- package/src/release/measure.ts +1 -1
- package/src/runs/runs.test.ts +3 -5
- package/src/runtime/serve.test.ts +2 -4
- package/src/test/create-test-app.test.ts +3 -7
- package/src/test/provisions.integration.test.ts +4 -9
- package/src/test/reset-element-registries.ts +31 -0
- package/site/content/docs/console/access.mdx +0 -29
- package/site/content/docs/console/ai.mdx +0 -35
- package/site/content/docs/console/architecture.mdx +0 -35
- package/site/content/docs/console/channels.mdx +0 -37
- package/site/content/docs/console/clock.mdx +0 -51
- package/site/content/docs/console/flows.mdx +0 -31
- package/site/content/docs/console/gates.mdx +0 -73
- package/site/content/docs/console/index.mdx +0 -54
- package/site/content/docs/console/manifest-diff.mdx +0 -34
- package/site/content/docs/console/meta.json +0 -24
- package/site/content/docs/console/overview.mdx +0 -40
- package/site/content/docs/console/plugins.mdx +0 -41
- package/site/content/docs/console/privacy.mdx +0 -32
- package/site/content/docs/console/runs.mdx +0 -50
- package/site/content/docs/console/signals.mdx +0 -40
- package/site/content/docs/console/store.mdx +0 -32
- package/site/content/docs/console/tenancy.mdx +0 -32
- package/site/content/docs/console/traces.mdx +0 -38
- package/site/content/docs/console/vault.mdx +0 -41
package/src/cli/dev.test.ts
CHANGED
|
@@ -7,9 +7,14 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { afterEach, describe, expect, test } from "bun:test";
|
|
10
|
-
import { mkdtemp } from "node:fs/promises";
|
|
10
|
+
import { mkdir, mkdtemp, readdir } from "node:fs/promises";
|
|
11
11
|
import { tmpdir } from "node:os";
|
|
12
12
|
import { join, resolve } from "node:path";
|
|
13
|
+
import {
|
|
14
|
+
ADOPT_BARREL_FILE,
|
|
15
|
+
generateAdoptBarrel,
|
|
16
|
+
writeAdoptBarrel,
|
|
17
|
+
} from "../compiler/generate-adopt.ts";
|
|
13
18
|
import type { Manifest } from "../manifest/types.ts";
|
|
14
19
|
import type { WideEvent } from "../runs/types.ts";
|
|
15
20
|
import { isDataEnvelope, MCP_DATA_KIND } from "../mcp/data.ts";
|
|
@@ -398,13 +403,11 @@ async function writePingApp(dir: string, version: string): Promise<void> {
|
|
|
398
403
|
join(dir, "src/flows/ping.ts"),
|
|
399
404
|
`import { on, flow, http, gate } from ${JSON.stringify(OKE_INDEX)};
|
|
400
405
|
|
|
401
|
-
export const ping = on(http.get("/ping").gate(gate.public), flow({
|
|
402
|
-
name: "ping",
|
|
406
|
+
export const ping = on(http.get("/ping").gate(gate.public), flow("ping", {
|
|
403
407
|
do: () => ({ version: ${JSON.stringify(version)} as const }),
|
|
404
408
|
}));
|
|
405
409
|
|
|
406
|
-
export const slow = on(http.get("/slow").gate(gate.public), flow({
|
|
407
|
-
name: "slow",
|
|
410
|
+
export const slow = on(http.get("/slow").gate(gate.public), flow("slow", {
|
|
408
411
|
do: async () => {
|
|
409
412
|
const started = ${JSON.stringify(version)};
|
|
410
413
|
await Bun.sleep(800);
|
|
@@ -546,6 +549,88 @@ describe("oke dev hot reload", () => {
|
|
|
546
549
|
}, 60_000);
|
|
547
550
|
});
|
|
548
551
|
|
|
552
|
+
describe("oke dev syncAdoptBarrel atomic write", () => {
|
|
553
|
+
test("default path regenerates generated.ts and leaves no .tmp behind", async () => {
|
|
554
|
+
const dir = await mkdtemp(join(tmpdir(), "oke-dev-adopt-atomic-"));
|
|
555
|
+
await mkdir(join(dir, "src/flows/notes"), { recursive: true });
|
|
556
|
+
await Bun.write(join(dir, "src/flows/notes/index.ts"), "export {};\n");
|
|
557
|
+
await Bun.write(join(dir, "src/app.ts"), "export {};\n");
|
|
558
|
+
await Bun.write(join(dir, "src/flows", ADOPT_BARREL_FILE), "// stale stub\n");
|
|
559
|
+
|
|
560
|
+
const { code } = await runDev({
|
|
561
|
+
cwd: dir,
|
|
562
|
+
dryRun: true,
|
|
563
|
+
stdinIsTTY: false,
|
|
564
|
+
write: () => {},
|
|
565
|
+
});
|
|
566
|
+
expect(code).toBe(0);
|
|
567
|
+
|
|
568
|
+
const barrelPath = join(dir, "src/flows", ADOPT_BARREL_FILE);
|
|
569
|
+
const text = await Bun.file(barrelPath).text();
|
|
570
|
+
expect(text).toContain('export * as notes from "./notes/index.ts";');
|
|
571
|
+
expect(text).not.toContain("stale stub");
|
|
572
|
+
expect(await Bun.file(`${barrelPath}.tmp`).exists()).toBe(false);
|
|
573
|
+
const leftovers = (await readdir(join(dir, "src/flows"))).filter((f) => f.includes(".tmp"));
|
|
574
|
+
expect(leftovers).toEqual([]);
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
test("writeAdoptBarrel uses temp then rename — concurrent readers never see a torn file", async () => {
|
|
578
|
+
const dir = await mkdtemp(join(tmpdir(), "oke-dev-adopt-race-"));
|
|
579
|
+
const flowsDir = join(dir, "src/flows");
|
|
580
|
+
await mkdir(flowsDir, { recursive: true });
|
|
581
|
+
const target = join(flowsDir, ADOPT_BARREL_FILE);
|
|
582
|
+
|
|
583
|
+
// Two complete, same-length payloads so a mid-write truncate would yield
|
|
584
|
+
// mixed or short content that fails both equality checks.
|
|
585
|
+
const markerA = "A";
|
|
586
|
+
const markerB = "B";
|
|
587
|
+
const body = (marker: string) => `// ${marker}\n` + `${marker.repeat(64)}\n`.repeat(2_000);
|
|
588
|
+
const payloadA = body(markerA);
|
|
589
|
+
const payloadB = body(markerB);
|
|
590
|
+
expect(payloadA.length).toBe(payloadB.length);
|
|
591
|
+
expect(payloadA).not.toBe(payloadB);
|
|
592
|
+
|
|
593
|
+
await writeAdoptBarrel(dir, payloadA);
|
|
594
|
+
expect(await Bun.file(target).text()).toBe(payloadA);
|
|
595
|
+
|
|
596
|
+
const reads: string[] = [];
|
|
597
|
+
let stop = false;
|
|
598
|
+
const reader = (async () => {
|
|
599
|
+
while (!stop) {
|
|
600
|
+
try {
|
|
601
|
+
reads.push(await Bun.file(target).text());
|
|
602
|
+
} catch {
|
|
603
|
+
// Target briefly missing only if rename races a delete — ignore.
|
|
604
|
+
}
|
|
605
|
+
// Yield so writers interleave; Bun.sleep(0) is enough on this loop.
|
|
606
|
+
await Bun.sleep(0);
|
|
607
|
+
}
|
|
608
|
+
})();
|
|
609
|
+
|
|
610
|
+
for (let i = 0; i < 80; i++) {
|
|
611
|
+
await writeAdoptBarrel(dir, i % 2 === 0 ? payloadB : payloadA);
|
|
612
|
+
}
|
|
613
|
+
stop = true;
|
|
614
|
+
await reader;
|
|
615
|
+
|
|
616
|
+
expect(reads.length).toBeGreaterThan(10);
|
|
617
|
+
for (const sample of reads) {
|
|
618
|
+
// Every observation is a complete prior or complete next payload —
|
|
619
|
+
// never a truncated prefix, never a mix of A/B markers.
|
|
620
|
+
const ok = sample === payloadA || sample === payloadB;
|
|
621
|
+
expect(ok).toBe(true);
|
|
622
|
+
expect(sample.length).toBe(payloadA.length);
|
|
623
|
+
expect(sample.includes(markerA) && sample.includes(markerB)).toBe(false);
|
|
624
|
+
}
|
|
625
|
+
expect(await Bun.file(`${target}.tmp`).exists()).toBe(false);
|
|
626
|
+
|
|
627
|
+
// Same helper the default `oke dev` path uses end-to-end.
|
|
628
|
+
const { source } = await generateAdoptBarrel({ rootDir: dir });
|
|
629
|
+
await writeAdoptBarrel(dir, source);
|
|
630
|
+
expect(await Bun.file(target).text()).toBe(source);
|
|
631
|
+
});
|
|
632
|
+
});
|
|
633
|
+
|
|
549
634
|
describe("oke dev mode resolution", () => {
|
|
550
635
|
test("non-TTY + unset mode → local, zero ask, zero docker, no save", async () => {
|
|
551
636
|
const dir = await mkdtemp(join(tmpdir(), "oke-dev-mode-nontty-"));
|
package/src/cli/dev.ts
CHANGED
|
@@ -47,7 +47,6 @@ import { clientAdd } from "./client-add.ts";
|
|
|
47
47
|
import { resolveDriverId } from "../config/index.ts";
|
|
48
48
|
import { askDevMode, type AskDevModeFn } from "./ask-dev-mode.ts";
|
|
49
49
|
import {
|
|
50
|
-
controlServicesFromStack,
|
|
51
50
|
formatDevControlsHint,
|
|
52
51
|
startDevControls,
|
|
53
52
|
type DevComposeControlAction,
|
|
@@ -177,6 +176,19 @@ export interface DevOptions {
|
|
|
177
176
|
* @param filename - Relative path from the watcher
|
|
178
177
|
*/
|
|
179
178
|
readonly onDbAutoPush?: (filename: string) => void;
|
|
179
|
+
/**
|
|
180
|
+
* Injectable `.adopt()` barrel regeneration (tests). Default: real
|
|
181
|
+
* `generateAdoptBarrel` + atomic write to `<cwd>/src/flows/generated.ts`
|
|
182
|
+
* (`generated.ts.tmp` → rename). Runs once per `oke dev` session, before
|
|
183
|
+
* the entry is resolved/imported — a real file on disk, so `oke dev`'s
|
|
184
|
+
* runtime `import()` and `oke build`'s `Bun.build()` resolve it
|
|
185
|
+
* identically (unlike a virtual-module Bun plugin, which does not — see
|
|
186
|
+
* `compiler/generate-adopt.ts`). Atomic so a concurrent import during
|
|
187
|
+
* `bun --hot` never sees a torn mid-write file.
|
|
188
|
+
*
|
|
189
|
+
* @param cwd - Project root
|
|
190
|
+
*/
|
|
191
|
+
readonly syncAdoptBarrel?: (cwd: string) => Promise<readonly string[]>;
|
|
180
192
|
/** Watch project source changes (injectable for tests). */
|
|
181
193
|
readonly watchFs?: DevWatchFn;
|
|
182
194
|
readonly images?: Readonly<Record<string, string>>;
|
|
@@ -334,6 +346,28 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
|
|
|
334
346
|
console.warn = previousWarn;
|
|
335
347
|
};
|
|
336
348
|
const cwd = options.cwd ?? process.cwd();
|
|
349
|
+
|
|
350
|
+
// One-shot `.adopt()` barrel regen for the session — real file on disk
|
|
351
|
+
// (`src/flows/generated.ts`), written atomically (tmp → rename) before the
|
|
352
|
+
// entry is resolved/imported below so a freshly-added flows unit is
|
|
353
|
+
// adoptable without a hand edit. Best-effort like `syncDevSchema` below:
|
|
354
|
+
// a project with no `src/flows` yet (or a synthetic test tree) never
|
|
355
|
+
// blocks the dev session.
|
|
356
|
+
const syncAdoptBarrel =
|
|
357
|
+
options.syncAdoptBarrel ??
|
|
358
|
+
(async (root: string) => {
|
|
359
|
+
const { generateAdoptBarrel, writeAdoptBarrel } =
|
|
360
|
+
await import("../compiler/generate-adopt.ts");
|
|
361
|
+
const { source, units } = await generateAdoptBarrel({ rootDir: root });
|
|
362
|
+
await writeAdoptBarrel(root, source);
|
|
363
|
+
return units;
|
|
364
|
+
});
|
|
365
|
+
try {
|
|
366
|
+
await syncAdoptBarrel(cwd);
|
|
367
|
+
} catch {
|
|
368
|
+
/* best-effort — boot-time assertAdoptBarrelFresh (rootDir opt-in) is the real gate */
|
|
369
|
+
}
|
|
370
|
+
|
|
337
371
|
const preferredApp = options.appPort ?? Number(Bun.env.PORT ?? APP_PORT);
|
|
338
372
|
const preferredConsole = options.consolePort ?? CONSOLE_PORT;
|
|
339
373
|
const preferredMcp = options.mcpPort ?? MCP_PORT;
|
|
@@ -1388,29 +1422,18 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
|
|
|
1388
1422
|
process.exit(0);
|
|
1389
1423
|
},
|
|
1390
1424
|
onRefresh: () => refreshChrome(),
|
|
1391
|
-
// Refresh first so help
|
|
1425
|
+
// Refresh first so help never sits in the middle of request logs.
|
|
1392
1426
|
onShowPanel: async (body) => {
|
|
1393
1427
|
await refreshChrome();
|
|
1394
1428
|
write(body);
|
|
1395
1429
|
},
|
|
1396
1430
|
onComposeSettled: () => syncComposeBoard(),
|
|
1397
|
-
|
|
1398
|
-
statusOf: (serviceName) => liveComposeHealth.get(serviceName),
|
|
1399
|
-
composeAction: async (action: DevComposeControlAction, serviceNames) => {
|
|
1431
|
+
composeAction: async (action: DevComposeControlAction) => {
|
|
1400
1432
|
const files = started.files;
|
|
1401
1433
|
const finalArgs =
|
|
1402
|
-
action === "up"
|
|
1434
|
+
action === "up"
|
|
1403
1435
|
? ["compose", ...files.flatMap((f) => ["-f", f]), "up", "-d"]
|
|
1404
|
-
:
|
|
1405
|
-
? [
|
|
1406
|
-
"compose",
|
|
1407
|
-
...files.flatMap((f) => ["-f", f]),
|
|
1408
|
-
"up",
|
|
1409
|
-
"-d",
|
|
1410
|
-
"--no-deps",
|
|
1411
|
-
...serviceNames,
|
|
1412
|
-
]
|
|
1413
|
-
: ["compose", ...files.flatMap((f) => ["-f", f]), action, ...serviceNames];
|
|
1436
|
+
: ["compose", ...files.flatMap((f) => ["-f", f]), action];
|
|
1414
1437
|
const proc = Bun.spawn(["docker", ...finalArgs], {
|
|
1415
1438
|
cwd: started.cwd,
|
|
1416
1439
|
stdout: "pipe",
|
package/src/cli/doctor.ts
CHANGED
|
@@ -3,17 +3,70 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { resolve } from "node:path";
|
|
6
|
+
import type { ConfigEnv, DriverRef, DriversConfig, EnvDriverMap } from "../config/index.ts";
|
|
7
|
+
import { resolveEffectiveDrivers, type EffectiveDriversConfig } from "../config/driver-defaults.ts";
|
|
6
8
|
import type { Manifest } from "../manifest/types.ts";
|
|
7
9
|
import { APP_PORT, CONSOLE_PORT, MCP_PORT } from "../runtime/types.ts";
|
|
8
10
|
import { hasFlag, wantsJson } from "./args.ts";
|
|
9
11
|
import { checkManifestPiiAsks } from "./doctor-pii.ts";
|
|
10
12
|
import { EXIT_OK, EXIT_RUNTIME } from "./exit.ts";
|
|
11
|
-
import { loadManifest } from "./load-config.ts";
|
|
13
|
+
import { loadManifest, loadOkeConfig } from "./load-config.ts";
|
|
12
14
|
import { isPortInUse } from "./ports.ts";
|
|
13
15
|
import { schemaFingerprint, readSchemaFingerprint } from "./schema.ts";
|
|
14
16
|
|
|
15
17
|
export { isPortInUse } from "./ports.ts";
|
|
16
18
|
|
|
19
|
+
/**
|
|
20
|
+
* `label → merged EnvDriverMap` pairs, in the fixed order shown by
|
|
21
|
+
* {@link formatDriversSummary} / the `drivers` JSON key.
|
|
22
|
+
*
|
|
23
|
+
* @param drivers - Fully-resolved drivers config
|
|
24
|
+
*/
|
|
25
|
+
function flattenEffectiveDrivers(
|
|
26
|
+
drivers: EffectiveDriversConfig,
|
|
27
|
+
): ReadonlyArray<readonly [string, EnvDriverMap]> {
|
|
28
|
+
return [
|
|
29
|
+
["store.sql", drivers.store.sql],
|
|
30
|
+
["store.kv", drivers.store.kv],
|
|
31
|
+
["store.files", drivers.store.files],
|
|
32
|
+
["signal", drivers.signal],
|
|
33
|
+
["clock", drivers.clock],
|
|
34
|
+
["journal", drivers.journal],
|
|
35
|
+
["vault", drivers.vault],
|
|
36
|
+
["channel.email", drivers.channel.email],
|
|
37
|
+
["channel.sms", drivers.channel.sms],
|
|
38
|
+
];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param ref - Driver ref (string id, rich `{ driver }` object, or unset)
|
|
43
|
+
*/
|
|
44
|
+
function driverRefId(ref: DriverRef | undefined): string {
|
|
45
|
+
if (ref === undefined) return "—";
|
|
46
|
+
return typeof ref === "string" ? ref : ref.driver;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Render the fully-resolved drivers config as aligned text lines — every
|
|
51
|
+
* default plus every override, per env, for a developer whose own
|
|
52
|
+
* `oke.config.ts` only pins a handful of keys.
|
|
53
|
+
*
|
|
54
|
+
* @param drivers - Fully-resolved drivers config
|
|
55
|
+
* @param configEnv - Active env (marked in the active-driver column)
|
|
56
|
+
*/
|
|
57
|
+
function formatDriversSummary(drivers: EffectiveDriversConfig, configEnv: ConfigEnv): string {
|
|
58
|
+
const rows = flattenEffectiveDrivers(drivers);
|
|
59
|
+
const nameWidth = Math.max(...rows.map(([name]) => name.length));
|
|
60
|
+
const activeWidth = Math.max(...rows.map(([, map]) => driverRefId(map[configEnv]).length));
|
|
61
|
+
const lines = [`drivers (env=${configEnv}):`];
|
|
62
|
+
for (const [name, map] of rows) {
|
|
63
|
+
const active = driverRefId(map[configEnv]);
|
|
64
|
+
const full = `local=${driverRefId(map.local)} docker=${driverRefId(map.docker)} test=${driverRefId(map.test)} prod=${driverRefId(map.prod)}`;
|
|
65
|
+
lines.push(` ${name.padEnd(nameWidth)} ${active.padEnd(activeWidth)} { ${full} }`);
|
|
66
|
+
}
|
|
67
|
+
return lines.join("\n");
|
|
68
|
+
}
|
|
69
|
+
|
|
17
70
|
/** One doctor finding. */
|
|
18
71
|
export interface DoctorFinding {
|
|
19
72
|
readonly code:
|
|
@@ -55,6 +108,19 @@ export interface DoctorOptions {
|
|
|
55
108
|
}>;
|
|
56
109
|
/** Skip live DB drift probe (default false). */
|
|
57
110
|
readonly skipDbDrift?: boolean;
|
|
111
|
+
/**
|
|
112
|
+
* Inject the `drivers` block directly (tests) — skips loading
|
|
113
|
+
* `oke.config.ts` from disk. When omitted, `oke doctor` best-effort loads
|
|
114
|
+
* `oke.config.ts` from {@link cwd}; a project without one just gets the
|
|
115
|
+
* real, untouched defaults in the drivers summary.
|
|
116
|
+
*/
|
|
117
|
+
readonly driversConfig?: DriversConfig;
|
|
118
|
+
/**
|
|
119
|
+
* Active {@link ConfigEnv} for the drivers summary. Default: `docker` when
|
|
120
|
+
* `OKE_DOCKER=1` (via {@link env}), else `local` — the same resolution
|
|
121
|
+
* `oke boot` uses without an explicit `env`.
|
|
122
|
+
*/
|
|
123
|
+
readonly configEnv?: ConfigEnv;
|
|
58
124
|
readonly write?: (text: string) => void;
|
|
59
125
|
/** Write hints / progress (defaults to stderr). */
|
|
60
126
|
readonly writeErr?: (text: string) => void;
|
|
@@ -92,6 +158,20 @@ export async function runDoctor(options: DoctorOptions = {}): Promise<{
|
|
|
92
158
|
const env = options.env ?? ((k) => Bun.env[k] ?? process.env[k]);
|
|
93
159
|
const secretNames = options.secrets ?? (manifest?.vault ? Object.keys(manifest.vault) : []);
|
|
94
160
|
|
|
161
|
+
// Fully-resolved `drivers.*` — real defaults + every override merged, so
|
|
162
|
+
// the picture is complete even when `oke.config.ts` only pins one key.
|
|
163
|
+
let driversConfig = options.driversConfig;
|
|
164
|
+
if (driversConfig === undefined) {
|
|
165
|
+
try {
|
|
166
|
+
driversConfig = (await loadOkeConfig(cwd)).config.drivers;
|
|
167
|
+
} catch {
|
|
168
|
+
// No oke.config.ts (or it failed to load) — show real defaults only.
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const configEnv: ConfigEnv =
|
|
172
|
+
options.configEnv ?? (env("OKE_DOCKER") === "1" ? "docker" : "local");
|
|
173
|
+
const effectiveDrivers = resolveEffectiveDrivers(driversConfig);
|
|
174
|
+
|
|
95
175
|
for (const name of secretNames) {
|
|
96
176
|
const value = env(name);
|
|
97
177
|
if (value === undefined || value.length === 0) {
|
|
@@ -177,7 +257,9 @@ export async function runDoctor(options: DoctorOptions = {}): Promise<{
|
|
|
177
257
|
const code = ok ? EXIT_OK : EXIT_RUNTIME;
|
|
178
258
|
|
|
179
259
|
if (json) {
|
|
180
|
-
write(
|
|
260
|
+
write(
|
|
261
|
+
`${JSON.stringify({ ok, findings, drivers: { env: configEnv, resolved: effectiveDrivers } }, null, 2)}\n`,
|
|
262
|
+
);
|
|
181
263
|
if (!ok) {
|
|
182
264
|
writeErr("Hint: fix error-severity findings, then re-run oke doctor.\n");
|
|
183
265
|
}
|
|
@@ -186,6 +268,7 @@ export async function runDoctor(options: DoctorOptions = {}): Promise<{
|
|
|
186
268
|
|
|
187
269
|
if (findings.length === 0) {
|
|
188
270
|
write("oke doctor: ok\n");
|
|
271
|
+
write(`${formatDriversSummary(effectiveDrivers, configEnv)}\n`);
|
|
189
272
|
return { code: EXIT_OK, findings };
|
|
190
273
|
}
|
|
191
274
|
|
|
@@ -193,6 +276,7 @@ export async function runDoctor(options: DoctorOptions = {}): Promise<{
|
|
|
193
276
|
for (const f of findings) {
|
|
194
277
|
write(` [${f.severity}] ${f.code}: ${f.message}\n`);
|
|
195
278
|
}
|
|
279
|
+
write(`${formatDriversSummary(effectiveDrivers, configEnv)}\n`);
|
|
196
280
|
return { code, findings };
|
|
197
281
|
}
|
|
198
282
|
|
|
@@ -208,16 +292,25 @@ export async function doctorCli(args: readonly string[]): Promise<number> {
|
|
|
208
292
|
}
|
|
209
293
|
|
|
210
294
|
let manifestPath: string | undefined;
|
|
295
|
+
let configEnv: ConfigEnv | undefined;
|
|
211
296
|
const json = wantsJson(args);
|
|
212
297
|
for (let i = 0; i < args.length; i++) {
|
|
213
298
|
const a = args[i]!;
|
|
214
299
|
if (a === "--manifest" || a === "-m") manifestPath = args[++i];
|
|
215
|
-
else if (a === "--
|
|
216
|
-
|
|
300
|
+
else if (a === "--env" || a === "-e") {
|
|
301
|
+
const value = args[++i];
|
|
302
|
+
if (value === "local" || value === "docker" || value === "test" || value === "prod") {
|
|
303
|
+
configEnv = value;
|
|
304
|
+
}
|
|
305
|
+
} else if (a === "--help" || a === "-h") {
|
|
306
|
+
console.log(`oke doctor [--manifest|-m path] [--env|-e local|docker|test|prod] [--json|-j]
|
|
217
307
|
oke doctor --diff|-d [--before|-b <path> --after|-a <path>] [--base|-B <branch>]
|
|
218
308
|
|
|
219
309
|
Verify secrets, ports, schema drift, and PII→model egress before serving.
|
|
310
|
+
Also prints drivers.* fully resolved (every default + every override merged)
|
|
311
|
+
for the active env, from oke.config.ts.
|
|
220
312
|
|
|
313
|
+
--env Env to resolve drivers for (default: docker when OKE_DOCKER=1, else local).
|
|
221
314
|
--diff CI gate: block undeclared contract breaks (Manifest Diff).
|
|
222
315
|
Default baseline is git merge-base (main/master) vs the working
|
|
223
316
|
tree; pass --before/--after for an explicit comparison.
|
|
@@ -227,6 +320,6 @@ Verify secrets, ports, schema drift, and PII→model egress before serving.
|
|
|
227
320
|
return EXIT_OK;
|
|
228
321
|
}
|
|
229
322
|
}
|
|
230
|
-
const { code } = await runDoctor({ manifestPath, json });
|
|
323
|
+
const { code } = await runDoctor({ manifestPath, configEnv, json });
|
|
231
324
|
return code;
|
|
232
325
|
}
|
|
@@ -3,12 +3,42 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { flattenImagesConfig } from "../config/index.ts";
|
|
6
7
|
import {
|
|
7
8
|
defaultImagesFromConfig,
|
|
8
9
|
resolveImages,
|
|
9
10
|
dockerDevDriverMismatches,
|
|
10
11
|
} from "./load-config.ts";
|
|
11
12
|
|
|
13
|
+
describe("flattenImagesConfig", () => {
|
|
14
|
+
test("flattens store/channel nesting to dotted keys; flat roles pass through", () => {
|
|
15
|
+
expect(
|
|
16
|
+
flattenImagesConfig({
|
|
17
|
+
store: { sql: "postgres:18-alpine", kv: "redis:8-alpine", files: "rustfs/rustfs:1.0.0" },
|
|
18
|
+
channel: { email: "axllent/mailpit:v1.22.3" },
|
|
19
|
+
vault: "openbao/openbao:2.6.1",
|
|
20
|
+
ai: "ollama/ollama:0.32.6",
|
|
21
|
+
pgdog: "ghcr.io/pgdogdev/pgdog:v0.1.51",
|
|
22
|
+
proxy: "caddy:2-alpine",
|
|
23
|
+
}),
|
|
24
|
+
).toEqual({
|
|
25
|
+
"store.sql": "postgres:18-alpine",
|
|
26
|
+
"store.kv": "redis:8-alpine",
|
|
27
|
+
"store.files": "rustfs/rustfs:1.0.0",
|
|
28
|
+
"channel.email": "axllent/mailpit:v1.22.3",
|
|
29
|
+
vault: "openbao/openbao:2.6.1",
|
|
30
|
+
ai: "ollama/ollama:0.32.6",
|
|
31
|
+
pgdog: "ghcr.io/pgdogdev/pgdog:v0.1.51",
|
|
32
|
+
proxy: "caddy:2-alpine",
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("undefined / empty input yields empty map", () => {
|
|
37
|
+
expect(flattenImagesConfig(undefined)).toEqual({});
|
|
38
|
+
expect(flattenImagesConfig({})).toEqual({});
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
12
42
|
describe("defaultImagesFromConfig", () => {
|
|
13
43
|
test("maps postgres + redis prod drivers", () => {
|
|
14
44
|
const images = defaultImagesFromConfig({
|
|
@@ -82,7 +112,7 @@ describe("resolveImages", () => {
|
|
|
82
112
|
kv: { prod: "redis" },
|
|
83
113
|
},
|
|
84
114
|
},
|
|
85
|
-
images: {
|
|
115
|
+
images: { store: { sql: "pgvector/pgvector:pg17" } },
|
|
86
116
|
});
|
|
87
117
|
expect(images).toEqual({ "store.sql": "pgvector/pgvector:pg17" });
|
|
88
118
|
});
|
package/src/cli/load-config.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { resolve } from "node:path";
|
|
6
|
-
import type
|
|
6
|
+
import { flattenImagesConfig, type DriverRef, type OkeConfig } from "../config/index.ts";
|
|
7
7
|
import type { Manifest } from "../manifest/types.ts";
|
|
8
8
|
|
|
9
9
|
/** Default image pins when `images` is omitted but prod drivers need containers. */
|
|
@@ -145,7 +145,7 @@ export function resolveImages(
|
|
|
145
145
|
config?: OkeConfig,
|
|
146
146
|
manifest?: Manifest,
|
|
147
147
|
): Readonly<Record<string, string>> {
|
|
148
|
-
const explicit = config?.images
|
|
148
|
+
const explicit = config?.images ? flattenImagesConfig(config.images) : manifest?.images;
|
|
149
149
|
if (explicit && Object.keys(explicit).length > 0) return explicit;
|
|
150
150
|
if (config) return defaultImagesFromConfig(config);
|
|
151
151
|
return {};
|
|
@@ -16,7 +16,7 @@ describe("oke safe-default overrides", () => {
|
|
|
16
16
|
const dir = await mkdtemp(join(tmpdir(), "oke-safe-docker-"));
|
|
17
17
|
await Bun.write(
|
|
18
18
|
join(dir, "oke.config.ts"),
|
|
19
|
-
`export default { images: {
|
|
19
|
+
`export default { images: { store: { sql: "postgres:18-alpine" } } }\n`,
|
|
20
20
|
);
|
|
21
21
|
// Without --prod, derive still runs but prod overlays are not requested.
|
|
22
22
|
// We assert the parser default by calling runDockerDerive via dockerCli
|
|
@@ -37,7 +37,7 @@ const NotFound = z.object({});
|
|
|
37
37
|
|
|
38
38
|
const create = on(
|
|
39
39
|
http.post("/notes"),
|
|
40
|
-
flow({
|
|
40
|
+
flow("notes.create", {
|
|
41
41
|
in: NewNote,
|
|
42
42
|
out: NoteId,
|
|
43
43
|
do: (input) => ({ id: `n_${input.title}` }),
|
|
@@ -46,7 +46,7 @@ const create = on(
|
|
|
46
46
|
|
|
47
47
|
const get = on(
|
|
48
48
|
http.get("/notes/:id"),
|
|
49
|
-
flow({
|
|
49
|
+
flow("notes.get", {
|
|
50
50
|
in: NoteId,
|
|
51
51
|
out: Note,
|
|
52
52
|
errors: { NotFound },
|
|
@@ -250,8 +250,7 @@ describe("Notes — typeof app carries contracts", () => {
|
|
|
250
250
|
});
|
|
251
251
|
|
|
252
252
|
test("untriggered adopted flow is RPC-only on the client", async () => {
|
|
253
|
-
const stats = flow({
|
|
254
|
-
name: "notes.stats",
|
|
253
|
+
const stats = flow("notes.stats", {
|
|
255
254
|
in: NoteId,
|
|
256
255
|
out: z.object({ clicks: z.number() }),
|
|
257
256
|
do: () => ({ clicks: 7 }),
|
package/src/compiler/aot.test.ts
CHANGED
|
@@ -120,8 +120,7 @@ describe("typed error narrowing end-to-end", () => {
|
|
|
120
120
|
|
|
121
121
|
on(
|
|
122
122
|
http.post("/bookings").gate(gate.public),
|
|
123
|
-
flow({
|
|
124
|
-
name: "bookings.create",
|
|
123
|
+
flow("bookings.create", {
|
|
125
124
|
in: Booking,
|
|
126
125
|
out: z.object({ id: z.string() }),
|
|
127
126
|
errors: { FlightFull },
|
|
@@ -123,8 +123,7 @@ describe("extractManifest — performance", () => {
|
|
|
123
123
|
parts.push(`
|
|
124
124
|
export const flow_${i} = on(
|
|
125
125
|
http.get("/f/${i}"),
|
|
126
|
-
flow({
|
|
127
|
-
name: "synth.flow_${i}",
|
|
126
|
+
flow("synth.flow_${i}", {
|
|
128
127
|
do: async (input, fx) => {
|
|
129
128
|
await fx.store(db).insert(items).values(input);
|
|
130
129
|
const rows = await fx.store(db).select().from(items);
|
|
@@ -166,8 +165,7 @@ export const daily = { name: "daily" };
|
|
|
166
165
|
|
|
167
166
|
export const listLinks = on(
|
|
168
167
|
http.get("/links"),
|
|
169
|
-
flow({
|
|
170
|
-
name: "rel.root",
|
|
168
|
+
flow("rel.root", {
|
|
171
169
|
do: async (_input, fx) => {
|
|
172
170
|
return fx.store(db).select().from(links);
|
|
173
171
|
},
|
|
@@ -178,8 +176,7 @@ export const listLinks = on(
|
|
|
178
176
|
// relations, so the related table must NOT appear in effects.
|
|
179
177
|
export const withDaily = on(
|
|
180
178
|
http.get("/links/with-daily"),
|
|
181
|
-
flow({
|
|
182
|
-
name: "rel.with",
|
|
179
|
+
flow("rel.with", {
|
|
183
180
|
do: async (_input, fx) => {
|
|
184
181
|
return fx.store(db).findMany({ with: { daily: true } });
|
|
185
182
|
},
|
|
@@ -354,9 +351,7 @@ export const files = store.files("uploads");
|
|
|
354
351
|
|
|
355
352
|
export const attach = on(
|
|
356
353
|
http.post("/attach").gate(gate.public),
|
|
357
|
-
flow({
|
|
358
|
-
name: "notes.attach",
|
|
359
|
-
unit: "notes",
|
|
354
|
+
flow("notes.attach", {
|
|
360
355
|
do: async (input, fx) => {
|
|
361
356
|
await fx.store(files).put("key", input.text);
|
|
362
357
|
return { ok: true };
|
|
@@ -377,9 +372,7 @@ export const files = store.files("uploads");
|
|
|
377
372
|
|
|
378
373
|
export const attach = on(
|
|
379
374
|
http.post("/attach").gate(gate.public),
|
|
380
|
-
flow({
|
|
381
|
-
name: "notes.attachImage",
|
|
382
|
-
unit: "notes",
|
|
375
|
+
flow("notes.attachImage", {
|
|
383
376
|
do: async (input, fx) => {
|
|
384
377
|
await fx.store(files).putImage("key", input.bytes);
|
|
385
378
|
return { ok: true };
|
|
@@ -400,9 +393,7 @@ export const files = store.files("uploads");
|
|
|
400
393
|
|
|
401
394
|
export const sweep = on(
|
|
402
395
|
every("1d"),
|
|
403
|
-
flow({
|
|
404
|
-
name: "notes.sweep",
|
|
405
|
-
unit: "notes",
|
|
396
|
+
flow("notes.sweep", {
|
|
406
397
|
do: async (_input, fx) => {
|
|
407
398
|
const keys = await fx.store(files).list();
|
|
408
399
|
return { count: keys.length };
|
|
@@ -429,9 +420,7 @@ export const noteCreatedMail = mail.template("note-created", {
|
|
|
429
420
|
|
|
430
421
|
export const onCreated = on(
|
|
431
422
|
http.post("/hook").gate(gate.public),
|
|
432
|
-
flow({
|
|
433
|
-
name: "notes.onCreated",
|
|
434
|
-
unit: "notes",
|
|
423
|
+
flow("notes.onCreated", {
|
|
435
424
|
do: async (payload, fx) => {
|
|
436
425
|
await fx.send(noteCreatedMail, { to: "you@localhost", data: payload });
|
|
437
426
|
},
|
|
@@ -454,9 +443,7 @@ export const otpTemplate = otp.template("otp-code", {});
|
|
|
454
443
|
|
|
455
444
|
export const send = on(
|
|
456
445
|
http.post("/otp").gate(gate.public),
|
|
457
|
-
flow({
|
|
458
|
-
name: "notes.sendOtp",
|
|
459
|
-
unit: "notes",
|
|
446
|
+
flow("notes.sendOtp", {
|
|
460
447
|
do: async (payload, fx) => {
|
|
461
448
|
await fx.send(otpTemplate, { to: "+10000000000", data: payload });
|
|
462
449
|
},
|
|
@@ -469,3 +456,71 @@ export const send = on(
|
|
|
469
456
|
expect(manifest.channels?.["otp-code"]?.medium).toBe("sms");
|
|
470
457
|
});
|
|
471
458
|
});
|
|
459
|
+
|
|
460
|
+
describe("extractManifest — flow(name, options) positional signature", () => {
|
|
461
|
+
test("bare flow(name, {...}) extracts name, in, out, effects", async () => {
|
|
462
|
+
const source = `
|
|
463
|
+
import { flow } from "okengine";
|
|
464
|
+
|
|
465
|
+
export const chargeOrder = flow("checkout.chargeOrder", {
|
|
466
|
+
in: ChargeIn,
|
|
467
|
+
out: ChargeOut,
|
|
468
|
+
effects: { reads: ["sql:orders"], writes: ["sql:orders"] },
|
|
469
|
+
do: async (input, fx) => {
|
|
470
|
+
return { ok: true };
|
|
471
|
+
},
|
|
472
|
+
});
|
|
473
|
+
`;
|
|
474
|
+
const manifest = await extractFromSources({ "src/flows/checkout.ts": source });
|
|
475
|
+
|
|
476
|
+
expect(manifest.flows?.["checkout.chargeOrder"]).toBeDefined();
|
|
477
|
+
expect(manifest.flows?.["checkout.chargeOrder"]?.in).toBeDefined();
|
|
478
|
+
expect(manifest.flows?.["checkout.chargeOrder"]?.out).toBeDefined();
|
|
479
|
+
expect(manifest.flows?.["checkout.chargeOrder"]?.effects?.reads).toEqual(["sql:orders"]);
|
|
480
|
+
expect(manifest.flows?.["checkout.chargeOrder"]?.effects?.writes).toEqual(["sql:orders"]);
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
test("on(trigger, flow(name, {...})) extracts the same shape as bare flow()", async () => {
|
|
484
|
+
const source = `
|
|
485
|
+
import { on, http, flow } from "okengine";
|
|
486
|
+
|
|
487
|
+
export const getOrder = on(
|
|
488
|
+
http.get("/orders/:id"),
|
|
489
|
+
flow("checkout.getOrder", {
|
|
490
|
+
out: OrderOut,
|
|
491
|
+
effects: { reads: ["sql:orders"] },
|
|
492
|
+
do: async (input, fx) => {
|
|
493
|
+
return { id: input.id };
|
|
494
|
+
},
|
|
495
|
+
}),
|
|
496
|
+
);
|
|
497
|
+
`;
|
|
498
|
+
const manifest = await extractFromSources({ "src/flows/checkout.ts": source });
|
|
499
|
+
|
|
500
|
+
expect(manifest.flows?.["checkout.getOrder"]).toBeDefined();
|
|
501
|
+
expect(manifest.flows?.["checkout.getOrder"]?.trigger?.http?.method).toBe("GET");
|
|
502
|
+
expect(manifest.flows?.["checkout.getOrder"]?.out).toBeDefined();
|
|
503
|
+
expect(manifest.flows?.["checkout.getOrder"]?.effects?.reads).toEqual(["sql:orders"]);
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
test("a stray `name:` key inside the options object is ignored — the positional arg wins", async () => {
|
|
507
|
+
// Regression guard for the exact class of AST-shape assumption that broke
|
|
508
|
+
// silently before (channel-alias tracking, `.put()` inference): the
|
|
509
|
+
// compiler must read the flow's identity from the first positional
|
|
510
|
+
// argument, never from a `name` property on the options object, even
|
|
511
|
+
// when one happens to be present.
|
|
512
|
+
const source = `
|
|
513
|
+
import { flow } from "okengine";
|
|
514
|
+
|
|
515
|
+
export const decoy = flow("checkout.realName", {
|
|
516
|
+
name: "checkout.decoyName",
|
|
517
|
+
effects: { reads: ["sql:orders"] },
|
|
518
|
+
do: async () => ({ ok: true }),
|
|
519
|
+
});
|
|
520
|
+
`;
|
|
521
|
+
const manifest = await extractFromSources({ "src/flows/checkout.ts": source });
|
|
522
|
+
|
|
523
|
+
expect(manifest.flows?.["checkout.realName"]).toBeDefined();
|
|
524
|
+
expect(manifest.flows?.["checkout.decoyName"]).toBeUndefined();
|
|
525
|
+
});
|
|
526
|
+
});
|