okengine 0.2.2 → 0.2.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/package.json +1 -1
- package/src/cli/dev.ts +68 -6
- package/src/cli/doctor.ts +3 -18
- package/src/cli/index.ts +4 -1
- package/src/cli/json-out.test.ts +1 -1
- package/src/cli/load-config.images.test.ts +75 -0
- package/src/cli/load-config.ts +67 -2
- package/src/cli/ports.test.ts +51 -0
- package/src/cli/ports.ts +81 -0
- package/src/cli/registry.help.test.ts +18 -0
- package/src/cli/registry.ts +0 -4
- package/src/cli/safe-defaults.test.ts +3 -3
- package/src/console/server/app.ts +2 -1
- package/src/console/server/flows.ts +1 -0
- package/src/console/server/index.ts +5 -0
- package/src/console/server/operator-db.test.ts +75 -0
- package/src/console/server/operator-db.ts +324 -0
- package/src/console/server/serve.ts +39 -0
- package/src/console/server/state.ts +13 -1
- package/src/console/ui/dist/assets/{index-B71Yl_SS.js → index-Bnf_3Hei.js} +3 -3
- package/src/console/ui/dist/assets/{panel-overview-Bd48d9km.js → panel-overview-Dt_AeXgd.js} +1 -1
- package/src/console/ui/dist/assets/{panel-runs-BwsWqKeB.js → panel-runs-DGstFHeq.js} +1 -1
- package/src/console/ui/dist/assets/{panel-signals-9najbZY2.js → panel-signals-DzEa2Fnt.js} +1 -1
- package/src/console/ui/dist/assets/{panel-store-OHkP2pDp.js → panel-store-BJkbNgxx.js} +1 -1
- package/src/console/ui/dist/assets/{panel-traces-tn2JoY8U.js → panel-traces-BaRVO3gM.js} +1 -1
- package/src/console/ui/dist/assets/style-C8MxEWPd.css +3 -0
- package/src/console/ui/dist/favicon.svg +7 -0
- package/src/console/ui/dist/index.html +3 -2
- package/src/console/ui/shell/App.tsx +34 -2
- package/src/console/ui/shell/components/oke-logo.tsx +40 -0
- package/src/console/ui/shell/index.html +1 -0
- package/src/console/ui/shell/layout/Shell.tsx +2 -3
- package/src/console/ui/shell/panels/overview/OverviewPanel.tsx +8 -0
- package/src/console/ui/shell/public/favicon.svg +7 -0
- package/src/console/ui/shell/setup/Wizard.tsx +5 -2
- package/src/docker/derive.ts +3 -1
- package/src/docker/dockerfile.integration.test.ts +1 -1
- package/src/docker/stack.integration.test.ts +1 -1
- package/src/console/ui/dist/assets/style-Cnl7WLya.css +0 -3
package/package.json
CHANGED
package/src/cli/dev.ts
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
import { clientAdd } from "./client-add.ts";
|
|
32
32
|
import { loadManifest, loadOkeConfig, resolveImages } from "./load-config.ts";
|
|
33
33
|
import { mcpContextFromConsole } from "./mcp-from-console.ts";
|
|
34
|
+
import { resolveDevPorts } from "./ports.ts";
|
|
34
35
|
|
|
35
36
|
/** Max wait for the `bun --hot` app child to bind a port. */
|
|
36
37
|
const APP_READY_TIMEOUT_MS = 30_000;
|
|
@@ -183,9 +184,27 @@ export interface DevResult {
|
|
|
183
184
|
export async function runDev(options: DevOptions = {}): Promise<DevResult> {
|
|
184
185
|
const write = options.write ?? ((t) => process.stdout.write(t));
|
|
185
186
|
const cwd = options.cwd ?? process.cwd();
|
|
186
|
-
const
|
|
187
|
-
const
|
|
188
|
-
const
|
|
187
|
+
const preferredApp = options.appPort ?? Number(Bun.env.PORT ?? APP_PORT);
|
|
188
|
+
const preferredConsole = options.consolePort ?? CONSOLE_PORT;
|
|
189
|
+
const preferredMcp = options.mcpPort ?? MCP_PORT;
|
|
190
|
+
// Explicit overrides (incl. `0` ephemeral) skip probing; otherwise +1 until free.
|
|
191
|
+
const ports =
|
|
192
|
+
options.appPort !== undefined ||
|
|
193
|
+
options.consolePort !== undefined ||
|
|
194
|
+
options.mcpPort !== undefined
|
|
195
|
+
? {
|
|
196
|
+
app: preferredApp,
|
|
197
|
+
console: preferredConsole,
|
|
198
|
+
mcp: preferredMcp,
|
|
199
|
+
}
|
|
200
|
+
: await resolveDevPorts({
|
|
201
|
+
app: preferredApp,
|
|
202
|
+
console: preferredConsole,
|
|
203
|
+
mcp: preferredMcp,
|
|
204
|
+
});
|
|
205
|
+
const appPort = ports.app;
|
|
206
|
+
const consolePort = ports.console;
|
|
207
|
+
const mcpPort = ports.mcp;
|
|
189
208
|
const keepAlive = options.keepAlive ?? true;
|
|
190
209
|
|
|
191
210
|
let entry = options.entry;
|
|
@@ -297,6 +316,20 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
|
|
|
297
316
|
? options.manifest
|
|
298
317
|
: await tryLoadProjectManifest(cwd);
|
|
299
318
|
|
|
319
|
+
async function refreshManifestInto(
|
|
320
|
+
state: ConsoleState | null,
|
|
321
|
+
): Promise<void> {
|
|
322
|
+
if (!state) return;
|
|
323
|
+
try {
|
|
324
|
+
const { extractManifest } = await import("../compiler/extract.ts");
|
|
325
|
+
const { feedManifest } = await import("../console/server/live.ts");
|
|
326
|
+
const next = await extractManifest({ rootDir: cwd });
|
|
327
|
+
feedManifest(state, next);
|
|
328
|
+
} catch {
|
|
329
|
+
// Source may be mid-edit — keep the last good Manifest.
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
300
333
|
const serveConsole =
|
|
301
334
|
options.serveConsole ??
|
|
302
335
|
(async (port) => {
|
|
@@ -402,6 +435,10 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
|
|
|
402
435
|
{ recursive: true },
|
|
403
436
|
() => {
|
|
404
437
|
void regen(appUrl);
|
|
438
|
+
// Only live-extract when the host did not pin a Manifest (tests).
|
|
439
|
+
if (options.manifest === undefined) {
|
|
440
|
+
void refreshManifestInto(consoleState);
|
|
441
|
+
}
|
|
405
442
|
},
|
|
406
443
|
);
|
|
407
444
|
|
|
@@ -453,20 +490,45 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
|
|
|
453
490
|
}
|
|
454
491
|
|
|
455
492
|
/**
|
|
456
|
-
*
|
|
493
|
+
* True when a Manifest has no declared flows (scaffold / empty extract).
|
|
494
|
+
*
|
|
495
|
+
* @param manifest - Candidate
|
|
496
|
+
*/
|
|
497
|
+
function isSparseManifest(manifest: Manifest): boolean {
|
|
498
|
+
const flows = manifest.flows;
|
|
499
|
+
if (!flows || typeof flows !== "object") return true;
|
|
500
|
+
return Object.keys(flows).length === 0;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Load Manifest — prefer AoT extract from `src/` when it has flows;
|
|
505
|
+
* otherwise on-disk JSON snapshots; otherwise a sparse extract.
|
|
457
506
|
*
|
|
458
507
|
* @param cwd - Project root
|
|
459
508
|
*/
|
|
460
509
|
async function tryLoadProjectManifest(
|
|
461
510
|
cwd: string,
|
|
462
511
|
): Promise<Manifest | null | undefined> {
|
|
512
|
+
let extracted: Manifest | undefined;
|
|
513
|
+
try {
|
|
514
|
+
const { extractManifest } = await import("../compiler/extract.ts");
|
|
515
|
+
extracted = await extractManifest({ rootDir: cwd });
|
|
516
|
+
} catch {
|
|
517
|
+
// Fall through to JSON snapshots.
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
let fromDisk: Manifest | undefined;
|
|
463
521
|
for (const name of ["oke.manifest.json", "manifest.oke.json"]) {
|
|
464
522
|
const path = resolve(cwd, name);
|
|
465
523
|
if (await Bun.file(path).exists()) {
|
|
466
|
-
|
|
524
|
+
fromDisk = await loadManifest(path);
|
|
525
|
+
break;
|
|
467
526
|
}
|
|
468
527
|
}
|
|
469
|
-
|
|
528
|
+
|
|
529
|
+
if (extracted && !isSparseManifest(extracted)) return extracted;
|
|
530
|
+
if (fromDisk) return fromDisk;
|
|
531
|
+
return extracted;
|
|
470
532
|
}
|
|
471
533
|
|
|
472
534
|
/**
|
package/src/cli/doctor.ts
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
* `oke doctor` — verify secrets, ports, drivers, tenancy, schema drift.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { createServer } from "node:net";
|
|
6
5
|
import { resolve } from "node:path";
|
|
7
6
|
import type { Manifest } from "../manifest/types.ts";
|
|
8
7
|
import { APP_PORT, CONSOLE_PORT, MCP_PORT } from "../runtime/types.ts";
|
|
@@ -10,8 +9,11 @@ import { hasFlag, wantsJson } from "./args.ts";
|
|
|
10
9
|
import { checkManifestPiiAsks } from "./doctor-pii.ts";
|
|
11
10
|
import { EXIT_OK, EXIT_RUNTIME } from "./exit.ts";
|
|
12
11
|
import { loadManifest } from "./load-config.ts";
|
|
12
|
+
import { isPortInUse } from "./ports.ts";
|
|
13
13
|
import { schemaFingerprint, readSchemaFingerprint } from "./schema.ts";
|
|
14
14
|
|
|
15
|
+
export { isPortInUse } from "./ports.ts";
|
|
16
|
+
|
|
15
17
|
/** One doctor finding. */
|
|
16
18
|
export interface DoctorFinding {
|
|
17
19
|
readonly code:
|
|
@@ -204,20 +206,3 @@ Verify secrets, ports, schema drift, and PII→model egress before serving.
|
|
|
204
206
|
return code;
|
|
205
207
|
}
|
|
206
208
|
|
|
207
|
-
/**
|
|
208
|
-
* True when something is already listening on `port`.
|
|
209
|
-
*
|
|
210
|
-
* @param port - TCP port
|
|
211
|
-
*/
|
|
212
|
-
export function isPortInUse(port: number): Promise<boolean> {
|
|
213
|
-
return new Promise((resolvePromise) => {
|
|
214
|
-
const server = createServer();
|
|
215
|
-
server.once("error", (err: NodeJS.ErrnoException) => {
|
|
216
|
-
resolvePromise(err.code === "EADDRINUSE");
|
|
217
|
-
});
|
|
218
|
-
server.once("listening", () => {
|
|
219
|
-
server.close(() => resolvePromise(false));
|
|
220
|
-
});
|
|
221
|
-
server.listen(port, "127.0.0.1");
|
|
222
|
-
});
|
|
223
|
-
}
|
package/src/cli/index.ts
CHANGED
|
@@ -90,7 +90,10 @@ if (cmd === "completion") {
|
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
if (cmd === undefined || cmd === "--help" || cmd === "-h" || cmd === "help") {
|
|
93
|
-
|
|
93
|
+
// Bare `oke` — commands only. Exit-code table only on explicit --help.
|
|
94
|
+
const help =
|
|
95
|
+
cmd === undefined ? formatOkeHelp() : `${formatOkeHelp()}${EXIT_CODE_HELP}`;
|
|
96
|
+
console.log(help);
|
|
94
97
|
process.exit(cmd ? EXIT_OK : EXIT_USAGE);
|
|
95
98
|
}
|
|
96
99
|
|
package/src/cli/json-out.test.ts
CHANGED
|
@@ -42,7 +42,7 @@ describe("oke --json stdout discipline", () => {
|
|
|
42
42
|
test("stack --json is valid JSON only on stdout", async () => {
|
|
43
43
|
let out = "";
|
|
44
44
|
const code = await runStackPreview({
|
|
45
|
-
images: { "store.sql": "postgres:
|
|
45
|
+
images: { "store.sql": "postgres:18-alpine" },
|
|
46
46
|
json: true,
|
|
47
47
|
write: (t) => {
|
|
48
48
|
out += t;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default image pins derived from prod drivers.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import {
|
|
7
|
+
defaultImagesFromConfig,
|
|
8
|
+
resolveImages,
|
|
9
|
+
} from "./load-config.ts";
|
|
10
|
+
|
|
11
|
+
describe("defaultImagesFromConfig", () => {
|
|
12
|
+
test("maps postgres + redis prod drivers", () => {
|
|
13
|
+
const images = defaultImagesFromConfig({
|
|
14
|
+
drivers: {
|
|
15
|
+
store: {
|
|
16
|
+
sql: { prod: "postgres" },
|
|
17
|
+
kv: { prod: "redis" },
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
expect(images).toEqual({
|
|
22
|
+
"store.sql": "postgres:18-alpine",
|
|
23
|
+
"store.kv": "redis:8-alpine",
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("prefers pgvector image when index uses pgvector", () => {
|
|
28
|
+
const images = defaultImagesFromConfig({
|
|
29
|
+
drivers: {
|
|
30
|
+
store: {
|
|
31
|
+
sql: { prod: "postgres" },
|
|
32
|
+
index: { prod: "pgvector" },
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
expect(images["store.sql"]).toBe("pgvector/pgvector:pg17");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("returns empty when no container drivers", () => {
|
|
40
|
+
expect(defaultImagesFromConfig({})).toEqual({});
|
|
41
|
+
expect(
|
|
42
|
+
defaultImagesFromConfig({
|
|
43
|
+
drivers: { store: { sql: { prod: "sqlite" } } },
|
|
44
|
+
}),
|
|
45
|
+
).toEqual({});
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("resolveImages", () => {
|
|
50
|
+
test("explicit images win over defaults", () => {
|
|
51
|
+
const images = resolveImages({
|
|
52
|
+
drivers: {
|
|
53
|
+
store: {
|
|
54
|
+
sql: { prod: "postgres" },
|
|
55
|
+
kv: { prod: "redis" },
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
images: { "store.sql": "pgvector/pgvector:pg17" },
|
|
59
|
+
});
|
|
60
|
+
expect(images).toEqual({ "store.sql": "pgvector/pgvector:pg17" });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("falls back to driver defaults when images omitted", () => {
|
|
64
|
+
const images = resolveImages({
|
|
65
|
+
drivers: {
|
|
66
|
+
store: {
|
|
67
|
+
sql: { prod: "postgres" },
|
|
68
|
+
kv: { prod: "redis" },
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
expect(images["store.sql"]).toBe("postgres:18-alpine");
|
|
73
|
+
expect(images["store.kv"]).toBe("redis:8-alpine");
|
|
74
|
+
});
|
|
75
|
+
});
|
package/src/cli/load-config.ts
CHANGED
|
@@ -3,9 +3,68 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { resolve } from "node:path";
|
|
6
|
-
import type { OkeConfig } from "../config/index.ts";
|
|
6
|
+
import type { DriverRef, OkeConfig } from "../config/index.ts";
|
|
7
7
|
import type { Manifest } from "../manifest/types.ts";
|
|
8
8
|
|
|
9
|
+
/** Default image pins when `images` is omitted but prod drivers need containers. */
|
|
10
|
+
const DEFAULT_SQL_IMAGE = "postgres:18-alpine";
|
|
11
|
+
const DEFAULT_PGVECTOR_IMAGE = "pgvector/pgvector:pg17";
|
|
12
|
+
const DEFAULT_KV_IMAGE = "redis:8-alpine";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Extract protocol id from a driver ref.
|
|
16
|
+
*
|
|
17
|
+
* @param ref - String or `{ driver }` object
|
|
18
|
+
*/
|
|
19
|
+
function driverId(ref: DriverRef | undefined): string | undefined {
|
|
20
|
+
if (ref === undefined) return undefined;
|
|
21
|
+
return typeof ref === "string" ? ref : ref.driver;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Derive default image pins from prod driver protocols.
|
|
26
|
+
*
|
|
27
|
+
* Used when `oke.config.ts` omits `images` but declares postgres/redis (etc.)
|
|
28
|
+
* so `oke dev -s` / `oke stack` / `oke docker` have something to run.
|
|
29
|
+
*
|
|
30
|
+
* @param config - Loaded config
|
|
31
|
+
*/
|
|
32
|
+
export function defaultImagesFromConfig(
|
|
33
|
+
config: OkeConfig,
|
|
34
|
+
): Readonly<Record<string, string>> {
|
|
35
|
+
const out: Record<string, string> = {};
|
|
36
|
+
const sql = driverId(config.drivers?.store?.sql?.prod);
|
|
37
|
+
const index = driverId(config.drivers?.store?.index?.prod);
|
|
38
|
+
const kv = driverId(config.drivers?.store?.kv?.prod);
|
|
39
|
+
const signal = driverId(config.drivers?.signal?.prod);
|
|
40
|
+
const clock = driverId(config.drivers?.clock?.prod);
|
|
41
|
+
|
|
42
|
+
const needsSql =
|
|
43
|
+
sql === "postgres" ||
|
|
44
|
+
sql === "pgvector" ||
|
|
45
|
+
index === "pgvector" ||
|
|
46
|
+
signal === "postgres" ||
|
|
47
|
+
clock === "postgres" ||
|
|
48
|
+
(config.drivers?.prod ?? []).some(
|
|
49
|
+
(p) => p === "postgres" || p === "pgvector",
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
if (needsSql) {
|
|
53
|
+
out["store.sql"] =
|
|
54
|
+
sql === "pgvector" || index === "pgvector"
|
|
55
|
+
? DEFAULT_PGVECTOR_IMAGE
|
|
56
|
+
: DEFAULT_SQL_IMAGE;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const needsKv =
|
|
60
|
+
kv === "redis" || (config.drivers?.prod ?? []).includes("redis");
|
|
61
|
+
if (needsKv) {
|
|
62
|
+
out["store.kv"] = DEFAULT_KV_IMAGE;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
9
68
|
/** Result of loading project config. */
|
|
10
69
|
export interface LoadedConfig {
|
|
11
70
|
readonly config: OkeConfig;
|
|
@@ -60,6 +119,9 @@ export async function loadManifest(path: string): Promise<Manifest> {
|
|
|
60
119
|
/**
|
|
61
120
|
* Resolve images map from config or Manifest.
|
|
62
121
|
*
|
|
122
|
+
* When neither declares `images`, derive defaults from prod driver protocols
|
|
123
|
+
* so `oke dev -s` works on scaffolded templates without an explicit pin map.
|
|
124
|
+
*
|
|
63
125
|
* @param config - Optional config
|
|
64
126
|
* @param manifest - Optional manifest
|
|
65
127
|
*/
|
|
@@ -67,5 +129,8 @@ export function resolveImages(
|
|
|
67
129
|
config?: OkeConfig,
|
|
68
130
|
manifest?: Manifest,
|
|
69
131
|
): Readonly<Record<string, string>> {
|
|
70
|
-
|
|
132
|
+
const explicit = config?.images ?? manifest?.images;
|
|
133
|
+
if (explicit && Object.keys(explicit).length > 0) return explicit;
|
|
134
|
+
if (config) return defaultImagesFromConfig(config);
|
|
135
|
+
return {};
|
|
71
136
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dev port probing — prefer then +1 until free.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { findFreePort, resolveDevPorts } from "./ports.ts";
|
|
7
|
+
|
|
8
|
+
describe("findFreePort", () => {
|
|
9
|
+
test("returns preferred when free", async () => {
|
|
10
|
+
const port = await findFreePort(6530, new Set(), async () => false);
|
|
11
|
+
expect(port).toBe(6530);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("increments until free", async () => {
|
|
15
|
+
const busy = new Set([6530, 6531]);
|
|
16
|
+
const port = await findFreePort(
|
|
17
|
+
6530,
|
|
18
|
+
new Set(),
|
|
19
|
+
async (p) => busy.has(p),
|
|
20
|
+
);
|
|
21
|
+
expect(port).toBe(6532);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("skips occupied set even when probe says free", async () => {
|
|
25
|
+
const port = await findFreePort(
|
|
26
|
+
6530,
|
|
27
|
+
new Set([6530, 6531]),
|
|
28
|
+
async () => false,
|
|
29
|
+
);
|
|
30
|
+
expect(port).toBe(6532);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("passes through ephemeral 0", async () => {
|
|
34
|
+
expect(await findFreePort(0)).toBe(0);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("resolveDevPorts", () => {
|
|
39
|
+
test("keeps app · console · mcp distinct when preferred collide", async () => {
|
|
40
|
+
// Everything busy except 6531, 6534, 6536 — force increments.
|
|
41
|
+
const busy = new Set([6530, 6533, 6535]);
|
|
42
|
+
const ports = await resolveDevPorts(
|
|
43
|
+
{ app: 6530, console: 6533, mcp: 6535 },
|
|
44
|
+
async (p) => busy.has(p),
|
|
45
|
+
);
|
|
46
|
+
expect(ports.app).toBe(6531);
|
|
47
|
+
expect(ports.console).toBe(6534);
|
|
48
|
+
expect(ports.mcp).toBe(6536);
|
|
49
|
+
expect(new Set([ports.app, ports.console, ports.mcp]).size).toBe(3);
|
|
50
|
+
});
|
|
51
|
+
});
|
package/src/cli/ports.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dev-time port probing — Next.js-style prefer-then-increment.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { createServer } from "node:net";
|
|
6
|
+
|
|
7
|
+
/** Max upward probes from a preferred port before failing. */
|
|
8
|
+
const MAX_PORT_ATTEMPTS = 100;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* True when something is already listening on `port` at 127.0.0.1.
|
|
12
|
+
*
|
|
13
|
+
* @param port - TCP port
|
|
14
|
+
*/
|
|
15
|
+
export function isPortInUse(port: number): Promise<boolean> {
|
|
16
|
+
return new Promise((resolvePromise) => {
|
|
17
|
+
const server = createServer();
|
|
18
|
+
server.once("error", (err: NodeJS.ErrnoException) => {
|
|
19
|
+
resolvePromise(err.code === "EADDRINUSE");
|
|
20
|
+
});
|
|
21
|
+
server.once("listening", () => {
|
|
22
|
+
server.close(() => resolvePromise(false));
|
|
23
|
+
});
|
|
24
|
+
server.listen(port, "127.0.0.1");
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Find the first free port at or above `preferred`, skipping occupied set.
|
|
30
|
+
*
|
|
31
|
+
* @param preferred - Starting port (e.g. 6530)
|
|
32
|
+
* @param occupied - Ports already claimed in this session
|
|
33
|
+
* @param probe - Injectable busy check (tests)
|
|
34
|
+
*/
|
|
35
|
+
export async function findFreePort(
|
|
36
|
+
preferred: number,
|
|
37
|
+
occupied: ReadonlySet<number> = new Set(),
|
|
38
|
+
probe: (port: number) => Promise<boolean> = isPortInUse,
|
|
39
|
+
): Promise<number> {
|
|
40
|
+
if (!Number.isFinite(preferred) || preferred < 0) {
|
|
41
|
+
throw new Error(`oke: invalid preferred port ${preferred}`);
|
|
42
|
+
}
|
|
43
|
+
// Ephemeral (0) — leave to the OS; no probing.
|
|
44
|
+
if (preferred === 0) return 0;
|
|
45
|
+
|
|
46
|
+
for (let i = 0; i < MAX_PORT_ATTEMPTS; i++) {
|
|
47
|
+
const port = preferred + i;
|
|
48
|
+
if (occupied.has(port)) continue;
|
|
49
|
+
if (!(await probe(port))) return port;
|
|
50
|
+
}
|
|
51
|
+
throw new Error(
|
|
52
|
+
`oke: no free port near ${preferred} after ${MAX_PORT_ATTEMPTS} attempts`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Resolve distinct free ports for app · Console · MCP (dev only).
|
|
58
|
+
*
|
|
59
|
+
* @param preferred - Preferred ports
|
|
60
|
+
* @param probe - Injectable busy check (tests)
|
|
61
|
+
*/
|
|
62
|
+
export async function resolveDevPorts(
|
|
63
|
+
preferred: {
|
|
64
|
+
readonly app: number;
|
|
65
|
+
readonly console: number;
|
|
66
|
+
readonly mcp: number;
|
|
67
|
+
},
|
|
68
|
+
probe: (port: number) => Promise<boolean> = isPortInUse,
|
|
69
|
+
): Promise<{
|
|
70
|
+
readonly app: number;
|
|
71
|
+
readonly console: number;
|
|
72
|
+
readonly mcp: number;
|
|
73
|
+
}> {
|
|
74
|
+
const occupied = new Set<number>();
|
|
75
|
+
const app = await findFreePort(preferred.app, occupied, probe);
|
|
76
|
+
if (app !== 0) occupied.add(app);
|
|
77
|
+
const consolePort = await findFreePort(preferred.console, occupied, probe);
|
|
78
|
+
if (consolePort !== 0) occupied.add(consolePort);
|
|
79
|
+
const mcp = await findFreePort(preferred.mcp, occupied, probe);
|
|
80
|
+
return { app, console: consolePort, mcp };
|
|
81
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Top-level help — no Flags/JSON comment footers.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { formatOkeHelp } from "./registry.ts";
|
|
7
|
+
|
|
8
|
+
describe("formatOkeHelp", () => {
|
|
9
|
+
test("lists commands without Flags/JSON commentary", () => {
|
|
10
|
+
const help = formatOkeHelp();
|
|
11
|
+
expect(help).toContain("oke — okengine CLI");
|
|
12
|
+
expect(help).toContain("Commands:");
|
|
13
|
+
expect(help).toContain("dev");
|
|
14
|
+
expect(help).not.toContain("Flags:");
|
|
15
|
+
expect(help).not.toContain("JSON:");
|
|
16
|
+
expect(help).not.toContain("Exit codes:");
|
|
17
|
+
});
|
|
18
|
+
});
|
package/src/cli/registry.ts
CHANGED
|
@@ -373,10 +373,6 @@ export function formatOkeHelp(): string {
|
|
|
373
373
|
}
|
|
374
374
|
}
|
|
375
375
|
lines.push("");
|
|
376
|
-
lines.push("Flags: long form is canonical; short form is convenience only.");
|
|
377
|
-
lines.push("JSON: doctor · stack · images list · gates list accept --json|-j");
|
|
378
|
-
lines.push(" (stdout = JSON only; hints/progress on stderr).");
|
|
379
|
-
lines.push("");
|
|
380
376
|
return `${lines.join("\n")}\n`;
|
|
381
377
|
}
|
|
382
378
|
|
|
@@ -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: { "store.sql": "postgres:
|
|
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
|
|
@@ -25,7 +25,7 @@ describe("oke safe-default overrides", () => {
|
|
|
25
25
|
const off = await runDockerDerive({
|
|
26
26
|
cwd: dir,
|
|
27
27
|
outDir: dir,
|
|
28
|
-
images: { "store.sql": "postgres:
|
|
28
|
+
images: { "store.sql": "postgres:18-alpine" },
|
|
29
29
|
dryRun: true,
|
|
30
30
|
write: () => {},
|
|
31
31
|
});
|
|
@@ -35,7 +35,7 @@ describe("oke safe-default overrides", () => {
|
|
|
35
35
|
const on = await runDockerDerive({
|
|
36
36
|
cwd: dir,
|
|
37
37
|
outDir: dir,
|
|
38
|
-
images: { "store.sql": "postgres:
|
|
38
|
+
images: { "store.sql": "postgres:18-alpine" },
|
|
39
39
|
prod: true,
|
|
40
40
|
dryRun: true,
|
|
41
41
|
write: () => {},
|
|
@@ -49,7 +49,8 @@ export function createConsoleApp(
|
|
|
49
49
|
options: CreateConsoleAppOptions = {},
|
|
50
50
|
): ConsoleAppHandle {
|
|
51
51
|
const state = createConsoleState(options);
|
|
52
|
-
|
|
52
|
+
// Spec §2.5 — claim prints only while setup is open (no operators yet).
|
|
53
|
+
if (!options.silentClaim && !state.setupClosed) {
|
|
53
54
|
printClaimCodeOnce(state.claim);
|
|
54
55
|
}
|
|
55
56
|
|
|
@@ -1771,6 +1771,7 @@ function createSetupClaim(state: ConsoleState) {
|
|
|
1771
1771
|
name: input.name,
|
|
1772
1772
|
password: input.password,
|
|
1773
1773
|
});
|
|
1774
|
+
state.persistOperator(op.id);
|
|
1774
1775
|
const issued = await issueOperatorSession(state, op.id);
|
|
1775
1776
|
fx.log.info("console.setup.claim", { operatorId: op.id });
|
|
1776
1777
|
return sessionPayload(op.id, op.email, op.name, issued);
|
|
@@ -71,6 +71,11 @@ export {
|
|
|
71
71
|
type ClaimCodeState,
|
|
72
72
|
type ClaimVerifyResult,
|
|
73
73
|
} from "./claim.ts";
|
|
74
|
+
export {
|
|
75
|
+
openConsolePersistence,
|
|
76
|
+
resolveConsoleSecret,
|
|
77
|
+
type ConsolePersistence,
|
|
78
|
+
} from "./operator-db.ts";
|
|
74
79
|
export {
|
|
75
80
|
createConsoleBindings,
|
|
76
81
|
} from "./flows.ts";
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable Console operators under `.oke/console.sqlite`.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
6
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { createOperator } from "../../auth/operator.ts";
|
|
10
|
+
import { createConsoleApp } from "./app.ts";
|
|
11
|
+
import {
|
|
12
|
+
openConsolePersistence,
|
|
13
|
+
resolveConsoleSecret,
|
|
14
|
+
} from "./operator-db.ts";
|
|
15
|
+
|
|
16
|
+
describe("console operator persistence", () => {
|
|
17
|
+
const dirs: string[] = [];
|
|
18
|
+
|
|
19
|
+
afterEach(async () => {
|
|
20
|
+
await Promise.all(
|
|
21
|
+
dirs.splice(0).map((d) => rm(d, { recursive: true, force: true })),
|
|
22
|
+
);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("secret is stable across resolveConsoleSecret calls", async () => {
|
|
26
|
+
const cwd = await mkdtemp(join(tmpdir(), "oke-console-secret-"));
|
|
27
|
+
dirs.push(cwd);
|
|
28
|
+
const a = await resolveConsoleSecret(cwd);
|
|
29
|
+
const b = await resolveConsoleSecret(cwd);
|
|
30
|
+
expect(a).toBe(b);
|
|
31
|
+
expect(a.length).toBeGreaterThan(16);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("claim persists and second boot skips claim print", async () => {
|
|
35
|
+
const cwd = await mkdtemp(join(tmpdir(), "oke-console-ops-"));
|
|
36
|
+
dirs.push(cwd);
|
|
37
|
+
|
|
38
|
+
const first = await openConsolePersistence(cwd);
|
|
39
|
+
expect(first.operators.operators.size).toBe(0);
|
|
40
|
+
const op = await createOperator(first.operators, {
|
|
41
|
+
email: "ops@example.com",
|
|
42
|
+
name: "Ops",
|
|
43
|
+
password: "password123",
|
|
44
|
+
});
|
|
45
|
+
first.persistOperator(op.id);
|
|
46
|
+
first.close();
|
|
47
|
+
|
|
48
|
+
const second = await openConsolePersistence(cwd);
|
|
49
|
+
expect(second.operators.operators.size).toBe(1);
|
|
50
|
+
expect(second.operators.operators.get(op.id)?.email).toBe(
|
|
51
|
+
"ops@example.com",
|
|
52
|
+
);
|
|
53
|
+
expect(second.operators.credentials.has(op.id)).toBe(true);
|
|
54
|
+
|
|
55
|
+
const printed: string[] = [];
|
|
56
|
+
const origLog = console.log;
|
|
57
|
+
console.log = (line?: unknown) => {
|
|
58
|
+
printed.push(String(line ?? ""));
|
|
59
|
+
};
|
|
60
|
+
try {
|
|
61
|
+
const app = createConsoleApp({
|
|
62
|
+
cwd,
|
|
63
|
+
secret: second.secret,
|
|
64
|
+
operators: second.operators,
|
|
65
|
+
persistOperator: second.persistOperator,
|
|
66
|
+
silentClaim: false,
|
|
67
|
+
});
|
|
68
|
+
expect(app.state.setupClosed).toBe(true);
|
|
69
|
+
expect(printed.join("\n")).not.toContain("Claim code");
|
|
70
|
+
} finally {
|
|
71
|
+
console.log = origLog;
|
|
72
|
+
second.close();
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
});
|