@devfn/config 0.1.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 ADDED
@@ -0,0 +1,3 @@
1
+ # @devfn/config
2
+
3
+ Validated DevFn project manifests and organization port-policy contracts. Every manifest can declare lifecycle commands and must be reviewed and explicitly trusted before the DevFn CLI loads its exact digest; executable manifests additionally must be self-contained.
@@ -0,0 +1,3 @@
1
+ import type { DetectionFinding, DevFnConfig, DiscoveryResult } from "./types.js";
2
+ export declare function discoverProject(root?: string): Promise<DiscoveryResult>;
3
+ export declare function renderDevFnConfig(config: DevFnConfig, findings: readonly DetectionFinding[]): string;
@@ -0,0 +1,99 @@
1
+ import { access, readdir, readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ async function exists(filePath) {
4
+ try {
5
+ await access(filePath);
6
+ return true;
7
+ }
8
+ catch {
9
+ return false;
10
+ }
11
+ }
12
+ function slug(value) {
13
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "project";
14
+ }
15
+ async function readPackage(root) {
16
+ try {
17
+ return JSON.parse(await readFile(path.join(root, "package.json"), "utf8"));
18
+ }
19
+ catch {
20
+ return null;
21
+ }
22
+ }
23
+ export async function discoverProject(root = process.cwd()) {
24
+ const resolvedRoot = path.resolve(root);
25
+ const findings = [];
26
+ const processes = {};
27
+ const packageJson = await readPackage(resolvedRoot);
28
+ const baseName = path.basename(resolvedRoot);
29
+ const declaredManager = typeof packageJson?.packageManager === "string" ? packageJson.packageManager : "";
30
+ const unsupportedManager = await exists(path.join(resolvedRoot, "yarn.lock")) || /^yarn(?:@|$)/.test(declaredManager) ? "yarn"
31
+ : await exists(path.join(resolvedRoot, "bun.lockb")) || await exists(path.join(resolvedRoot, "bun.lock")) || /^bun(?:@|$)/.test(declaredManager) ? "bun"
32
+ : undefined;
33
+ let packageManager = unsupportedManager ? undefined : "npm";
34
+ if (!unsupportedManager) {
35
+ if (await exists(path.join(resolvedRoot, "pnpm-lock.yaml")))
36
+ packageManager = "pnpm";
37
+ else if (/^pnpm(?:@|$)/.test(declaredManager))
38
+ packageManager = "pnpm";
39
+ }
40
+ findings.push({
41
+ kind: "package-manager",
42
+ confidence: unsupportedManager ? "proposed" : packageJson ? "confirmed" : "proposed",
43
+ source: packageJson ? "package.json" : "directory contents",
44
+ detail: unsupportedManager ? `${unsupportedManager} is detected but has no native adapter; add an explicit command.` : packageManager,
45
+ });
46
+ const scripts = packageJson && packageJson.scripts && typeof packageJson.scripts === "object" ? packageJson.scripts : {};
47
+ for (const candidate of ["dev", "start"]) {
48
+ if (packageManager && typeof scripts[candidate] === "string") {
49
+ processes.app = { adapter: packageManager, script: candidate, ports: ["app"] };
50
+ findings.push({ kind: "process", confidence: "confirmed", source: `package.json#scripts.${candidate}`, detail: `${packageManager} run ${candidate}` });
51
+ break;
52
+ }
53
+ }
54
+ const entries = await readdir(resolvedRoot).catch(() => []);
55
+ const composeFile = ["compose.yaml", "compose.yml", "docker-compose.yaml", "docker-compose.yml"].find((name) => entries.includes(name));
56
+ if (composeFile)
57
+ findings.push({ kind: "compose", confidence: "confirmed", source: composeFile, detail: "Compose file detected; service selection requires review." });
58
+ const wranglerFile = ["wrangler.toml", "wrangler.json", "wrangler.jsonc"].find((name) => entries.includes(name));
59
+ if (wranglerFile)
60
+ findings.push({ kind: "wrangler", confidence: "confirmed", source: wranglerFile, detail: "Wrangler configuration detected; binding and health settings require review." });
61
+ if (entries.some((name) => name.endsWith(".xcodeproj") || name.endsWith(".xcworkspace"))) {
62
+ findings.push({ kind: "xcode", confidence: "confirmed", source: "repository root", detail: "Xcode project/workspace detected; scheme and simulator require review." });
63
+ }
64
+ if (await exists(path.join(resolvedRoot, "manifest.json")) || await exists(path.join(resolvedRoot, "public", "manifest.json"))) {
65
+ findings.push({ kind: "extension", confidence: "proposed", source: "manifest.json", detail: "Possible browser extension; ExtFn workflow requires review." });
66
+ }
67
+ if (Object.keys(processes).length === 0) {
68
+ findings.push({ kind: "process", confidence: "proposed", source: "no supported script detected", detail: "Add an explicit process command before running devfn up." });
69
+ }
70
+ if (Object.keys(processes).length > 0) {
71
+ findings.push({ kind: "process", confidence: "proposed", source: "framework default", detail: "The initial app port 3000 is a proposal and must be checked against the actual development server." });
72
+ }
73
+ const config = {
74
+ version: 1,
75
+ project: { id: slug(typeof packageJson?.name === "string" ? packageJson.name : baseName), name: typeof packageJson?.name === "string" ? packageJson.name : baseName },
76
+ defaultProfile: "default",
77
+ runtimeDir: ".devfn",
78
+ ports: Object.keys(processes).length ? { app: { preferred: 3000, range: [3000, 3099], env: "PORT" } } : {},
79
+ processes,
80
+ profiles: { default: { processes: Object.keys(processes), services: [] } },
81
+ prerequisites: Object.keys(processes).length && packageManager ? [{ command: packageManager, version: packageManager === "pnpm" ? "project-pinned" : undefined }] : [],
82
+ environmentOutputs: [{ path: ".devfn/runtime.env", format: "dotenv", mode: 0o600 }],
83
+ };
84
+ return { root: resolvedRoot, findings, config };
85
+ }
86
+ function quote(value, indent = 0) {
87
+ const json = JSON.stringify(value, null, 2).replaceAll("\u2028", "\\u2028").replaceAll("\u2029", "\\u2029");
88
+ return json.split("\n").map((line, index) => index === 0 ? line : `${" ".repeat(indent)}${line}`).join("\n");
89
+ }
90
+ export function renderDevFnConfig(config, findings) {
91
+ const uncertain = findings.filter((finding) => finding.confidence === "proposed");
92
+ const header = [
93
+ "// Generated by devfn init. Review every command before trusting this repository.",
94
+ "// Keep executable DevFn manifests self-contained; imports and require() are rejected.",
95
+ ...(uncertain.length ? uncertain.map((finding) => `// REVIEW: ${finding.detail} (${finding.source})`) : []),
96
+ "export default ",
97
+ ];
98
+ return `${header.join("\n")}${quote(config, 2)} as const;\n`;
99
+ }
@@ -0,0 +1,6 @@
1
+ export type ConfigErrorCode = "DEVFN_CONFIG_INVALID" | "DEVFN_CONFIG_NOT_FOUND" | "DEVFN_CONFIG_PATH_ESCAPE";
2
+ export declare class DevFnConfigError extends Error {
3
+ readonly code: ConfigErrorCode;
4
+ readonly path?: string;
5
+ constructor(code: ConfigErrorCode, message: string, path?: string);
6
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,10 @@
1
+ export class DevFnConfigError extends Error {
2
+ code;
3
+ path;
4
+ constructor(code, message, path) {
5
+ super(message);
6
+ this.name = "DevFnConfigError";
7
+ this.code = code;
8
+ this.path = path;
9
+ }
10
+ }
@@ -0,0 +1,10 @@
1
+ export { DevFnConfigError, type ConfigErrorCode } from "./errors.js";
2
+ export { discoverProject, renderDevFnConfig } from "./discovery.js";
3
+ export { DEVFN_CONFIG_CANDIDATES, findDevFnRoot, loadDevFnConfig, loadDevFnPolicy, loadTrustedDevFnConfig, resolveDevFnManifestPath } from "./load.js";
4
+ export { resolveContainedPath } from "./paths.js";
5
+ export { matchesProcessIdentity, processBirthSignature, processExists } from "./process-identity.js";
6
+ export { validateDevFnConfig, validateDevFnPolicy } from "./schema.js";
7
+ export { configDigest, defaultStateDir, isProjectTrusted, readTrustedManifest, trustProject } from "./trust.js";
8
+ export type * from "./types.js";
9
+ export declare function defineDevFnConfig<T extends import("./types.js").DevFnConfig>(config: T): T;
10
+ export declare function defineDevFnPolicy<T extends import("./types.js").DevFnPolicy>(policy: T): T;
package/dist/index.js ADDED
@@ -0,0 +1,9 @@
1
+ export { DevFnConfigError } from "./errors.js";
2
+ export { discoverProject, renderDevFnConfig } from "./discovery.js";
3
+ export { DEVFN_CONFIG_CANDIDATES, findDevFnRoot, loadDevFnConfig, loadDevFnPolicy, loadTrustedDevFnConfig, resolveDevFnManifestPath } from "./load.js";
4
+ export { resolveContainedPath } from "./paths.js";
5
+ export { matchesProcessIdentity, processBirthSignature, processExists } from "./process-identity.js";
6
+ export { validateDevFnConfig, validateDevFnPolicy } from "./schema.js";
7
+ export { configDigest, defaultStateDir, isProjectTrusted, readTrustedManifest, trustProject } from "./trust.js";
8
+ export function defineDevFnConfig(config) { return config; }
9
+ export function defineDevFnPolicy(policy) { return policy; }
package/dist/load.d.ts ADDED
@@ -0,0 +1,31 @@
1
+ import type { DevFnConfig, DevFnPolicy } from "./types.js";
2
+ export declare const DEVFN_CONFIG_CANDIDATES: readonly ["devfn.config.ts", "devfn.config.js", "devfn.config.mjs", "devfn.config.cjs", "devfn.config.json"];
3
+ export declare function findDevFnRoot(start?: string): Promise<string>;
4
+ export declare function resolveDevFnManifestPath(options?: {
5
+ cwd?: string;
6
+ configPath?: string;
7
+ }): Promise<{
8
+ root: string;
9
+ path: string;
10
+ }>;
11
+ export declare function loadDevFnConfig(options?: {
12
+ cwd?: string;
13
+ configPath?: string;
14
+ }): Promise<{
15
+ config: DevFnConfig;
16
+ path: string;
17
+ root: string;
18
+ }>;
19
+ export declare function loadTrustedDevFnConfig(options?: {
20
+ cwd?: string;
21
+ configPath?: string;
22
+ stateDir?: string;
23
+ }): Promise<{
24
+ config: DevFnConfig;
25
+ path: string;
26
+ root: string;
27
+ }>;
28
+ export declare function loadDevFnPolicy(root: string, configuredPath?: string): Promise<{
29
+ policy: DevFnPolicy;
30
+ path: string;
31
+ } | null>;
package/dist/load.js ADDED
@@ -0,0 +1,153 @@
1
+ import { access, readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { createContext, Script } from "node:vm";
4
+ import { ConfigLoaderError, loadConfig } from "@clifn/core";
5
+ import ts from "typescript";
6
+ import { DevFnConfigError } from "./errors.js";
7
+ import { resolveContainedPath } from "./paths.js";
8
+ import { validateDevFnConfig, validateDevFnPolicy } from "./schema.js";
9
+ import { defaultStateDir, readTrustedManifest } from "./trust.js";
10
+ export const DEVFN_CONFIG_CANDIDATES = [
11
+ "devfn.config.ts",
12
+ "devfn.config.js",
13
+ "devfn.config.mjs",
14
+ "devfn.config.cjs",
15
+ "devfn.config.json",
16
+ ];
17
+ async function exists(filePath) {
18
+ try {
19
+ await access(filePath);
20
+ return true;
21
+ }
22
+ catch {
23
+ return false;
24
+ }
25
+ }
26
+ export async function findDevFnRoot(start = process.cwd()) {
27
+ let current = path.resolve(start);
28
+ while (true) {
29
+ for (const candidate of DEVFN_CONFIG_CANDIDATES) {
30
+ if (await exists(path.join(current, candidate)))
31
+ return current;
32
+ }
33
+ const parent = path.dirname(current);
34
+ if (parent === current) {
35
+ throw new DevFnConfigError("DEVFN_CONFIG_NOT_FOUND", `No DevFn manifest found from ${start}.`);
36
+ }
37
+ current = parent;
38
+ }
39
+ }
40
+ export async function resolveDevFnManifestPath(options = {}) {
41
+ if (options.configPath) {
42
+ const resolved = path.resolve(options.cwd ?? process.cwd(), options.configPath);
43
+ if (!await exists(resolved))
44
+ throw new DevFnConfigError("DEVFN_CONFIG_NOT_FOUND", `Config file not found: ${resolved}`);
45
+ return { root: path.dirname(resolved), path: resolved };
46
+ }
47
+ const root = await findDevFnRoot(options.cwd);
48
+ for (const candidate of DEVFN_CONFIG_CANDIDATES) {
49
+ const resolved = path.join(root, candidate);
50
+ if (await exists(resolved))
51
+ return { root, path: resolved };
52
+ }
53
+ throw new DevFnConfigError("DEVFN_CONFIG_NOT_FOUND", `No DevFn manifest found from ${options.cwd ?? process.cwd()}.`);
54
+ }
55
+ export async function loadDevFnConfig(options = {}) {
56
+ try {
57
+ const resolved = await resolveDevFnManifestPath(options);
58
+ const root = resolved.root;
59
+ const loaded = await loadConfig({
60
+ cwd: root,
61
+ configPath: resolved.path,
62
+ candidates: DEVFN_CONFIG_CANDIDATES,
63
+ exportNames: ["config", "devfnConfig"],
64
+ validate: validateDevFnConfig,
65
+ });
66
+ return { ...loaded, root };
67
+ }
68
+ catch (error) {
69
+ if (error instanceof DevFnConfigError)
70
+ throw error;
71
+ if (error instanceof ConfigLoaderError) {
72
+ throw new DevFnConfigError(error.code === "CLIFN_CONFIG_NOT_FOUND" ? "DEVFN_CONFIG_NOT_FOUND" : "DEVFN_CONFIG_INVALID", error.message);
73
+ }
74
+ throw error;
75
+ }
76
+ }
77
+ export async function loadTrustedDevFnConfig(options = {}) {
78
+ const resolved = await resolveDevFnManifestPath(options);
79
+ const snapshot = await readTrustedManifest(resolved.root, resolved.path, options.stateDir ?? defaultStateDir());
80
+ const source = snapshot.bytes.toString("utf8");
81
+ if (resolved.path.endsWith(".json")) {
82
+ try {
83
+ return { config: validateDevFnConfig(JSON.parse(source)), path: resolved.path, root: resolved.root };
84
+ }
85
+ catch (error) {
86
+ if (error instanceof DevFnConfigError)
87
+ throw error;
88
+ throw new DevFnConfigError("DEVFN_CONFIG_INVALID", `Unable to parse trusted JSON manifest ${resolved.path}.`);
89
+ }
90
+ }
91
+ if (hasExternalModuleSyntax(source)) {
92
+ throw new DevFnConfigError("DEVFN_CONFIG_INVALID", "Executable DevFn manifests must be self-contained; imports and require() are not permitted.");
93
+ }
94
+ try {
95
+ return { config: validateDevFnConfig(evaluateTrustedManifest(source, resolved.path)), path: resolved.path, root: resolved.root };
96
+ }
97
+ catch (error) {
98
+ if (error instanceof DevFnConfigError)
99
+ throw error;
100
+ throw new DevFnConfigError("DEVFN_CONFIG_INVALID", `Unable to evaluate trusted manifest ${resolved.path} in the restricted context.`);
101
+ }
102
+ }
103
+ function evaluateTrustedManifest(source, filename) {
104
+ const transpiled = ts.transpileModule(source, {
105
+ fileName: filename,
106
+ reportDiagnostics: true,
107
+ compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: false },
108
+ });
109
+ const diagnostic = transpiled.diagnostics?.find((item) => item.category === ts.DiagnosticCategory.Error);
110
+ if (diagnostic)
111
+ throw new Error(ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"));
112
+ const context = createContext(Object.create(null), { name: "devfn-trusted-manifest", codeGeneration: { strings: false, wasm: false }, microtaskMode: "afterEvaluate" });
113
+ const serialized = new Script(`(() => { const module = { exports: {} }; const exports = module.exports;\n${transpiled.outputText}\nconst value = module.exports; return JSON.stringify(value?.default ?? value?.config ?? value?.devfnConfig ?? value); })()`, { filename }).runInContext(context, { timeout: 1000 });
114
+ if (typeof serialized !== "string")
115
+ throw new Error("Trusted manifest did not export a serializable configuration.");
116
+ return JSON.parse(serialized);
117
+ }
118
+ function hasExternalModuleSyntax(source) {
119
+ const file = ts.createSourceFile("devfn.config.ts", source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
120
+ let forbidden = false;
121
+ const visit = (node) => {
122
+ if (ts.isImportDeclaration(node) || ts.isImportEqualsDeclaration(node) || ts.isImportTypeNode(node) || (ts.isExportDeclaration(node) && Boolean(node.moduleSpecifier)))
123
+ forbidden = true;
124
+ if (ts.isCallExpression(node) && (node.expression.kind === ts.SyntaxKind.ImportKeyword || (ts.isIdentifier(node.expression) && node.expression.text === "require")))
125
+ forbidden = true;
126
+ if (ts.isPropertyAccessExpression(node) && node.name.text === "require")
127
+ forbidden = true;
128
+ if (ts.isElementAccessExpression(node) && ts.isStringLiteral(node.argumentExpression) && node.argumentExpression.text === "require")
129
+ forbidden = true;
130
+ if (!forbidden)
131
+ ts.forEachChild(node, visit);
132
+ };
133
+ visit(file);
134
+ return forbidden;
135
+ }
136
+ export async function loadDevFnPolicy(root, configuredPath) {
137
+ const candidate = configuredPath ?? "devfn.policy.json";
138
+ try {
139
+ const policyPath = await resolveContainedPath(root, candidate, "policy");
140
+ if (!await exists(policyPath)) {
141
+ if (configuredPath)
142
+ throw new DevFnConfigError("DEVFN_CONFIG_NOT_FOUND", `Configured policy file not found: ${policyPath}.`, "policy");
143
+ return null;
144
+ }
145
+ const raw = await readFile(policyPath, "utf8");
146
+ return { policy: validateDevFnPolicy(JSON.parse(raw)), path: policyPath };
147
+ }
148
+ catch (error) {
149
+ if (error instanceof DevFnConfigError)
150
+ throw error;
151
+ throw new DevFnConfigError("DEVFN_CONFIG_INVALID", `Unable to read or parse policy ${candidate}.`);
152
+ }
153
+ }
@@ -0,0 +1 @@
1
+ export declare function resolveContainedPath(root: string, candidate: string, field?: string): Promise<string>;
package/dist/paths.js ADDED
@@ -0,0 +1,33 @@
1
+ import { lstat, realpath } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { DevFnConfigError } from "./errors.js";
4
+ async function nearestExistingParent(candidate) {
5
+ let current = candidate;
6
+ while (true) {
7
+ try {
8
+ await lstat(current);
9
+ return current;
10
+ }
11
+ catch (error) {
12
+ if (error.code !== "ENOENT")
13
+ throw error;
14
+ const parent = path.dirname(current);
15
+ if (parent === current)
16
+ throw error;
17
+ current = parent;
18
+ }
19
+ }
20
+ }
21
+ export async function resolveContainedPath(root, candidate, field = "path") {
22
+ const resolvedRoot = await realpath(root);
23
+ const resolved = path.resolve(resolvedRoot, candidate);
24
+ if (resolved !== resolvedRoot && !resolved.startsWith(`${resolvedRoot}${path.sep}`)) {
25
+ throw new DevFnConfigError("DEVFN_CONFIG_PATH_ESCAPE", `${field} must stay inside the repository.`, field);
26
+ }
27
+ const existingParent = await nearestExistingParent(resolved);
28
+ const canonicalParent = await realpath(existingParent);
29
+ if (canonicalParent !== resolvedRoot && !canonicalParent.startsWith(`${resolvedRoot}${path.sep}`)) {
30
+ throw new DevFnConfigError("DEVFN_CONFIG_PATH_ESCAPE", `${field} resolves outside the repository through a symlink.`, field);
31
+ }
32
+ return resolved;
33
+ }
@@ -0,0 +1,3 @@
1
+ export declare function processBirthSignature(pid: number): Promise<string | undefined>;
2
+ export declare function processExists(pid: number): boolean;
3
+ export declare function matchesProcessIdentity(pid: number, signature?: string): Promise<boolean>;
@@ -0,0 +1,41 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ const execFileAsync = promisify(execFile);
4
+ export async function processBirthSignature(pid) {
5
+ try {
6
+ if (process.platform === "linux") {
7
+ const stat = await import("node:fs/promises").then((fs) => fs.readFile(`/proc/${pid}/stat`, "utf8"));
8
+ const afterCommand = stat.slice(stat.lastIndexOf(")") + 1).trim().split(/\s+/);
9
+ const startTime = afterCommand[19];
10
+ return startTime ? `linux:${startTime}` : undefined;
11
+ }
12
+ if (process.platform === "darwin") {
13
+ const { stdout } = await execFileAsync("ps", ["-o", "lstart=", "-p", String(pid)]);
14
+ return `darwin:${stdout.trim()}`;
15
+ }
16
+ if (process.platform === "win32") {
17
+ const { stdout } = await execFileAsync("powershell", ["-NoProfile", "-Command", `(Get-Process -Id ${pid}).StartTime.ToUniversalTime().ToString('O')`]);
18
+ return `win32:${stdout.trim()}`;
19
+ }
20
+ }
21
+ catch {
22
+ return undefined;
23
+ }
24
+ return undefined;
25
+ }
26
+ export function processExists(pid) {
27
+ try {
28
+ process.kill(pid, 0);
29
+ return true;
30
+ }
31
+ catch (error) {
32
+ return error.code === "EPERM";
33
+ }
34
+ }
35
+ export async function matchesProcessIdentity(pid, signature) {
36
+ if (!processExists(pid))
37
+ return false;
38
+ if (!signature)
39
+ return false;
40
+ return await processBirthSignature(pid) === signature;
41
+ }
@@ -0,0 +1,3 @@
1
+ import type { DevFnConfig, DevFnPolicy } from "./types.js";
2
+ export declare function validateDevFnConfig(value: unknown): DevFnConfig;
3
+ export declare function validateDevFnPolicy(value: unknown): DevFnPolicy;
package/dist/schema.js ADDED
@@ -0,0 +1,412 @@
1
+ import path from "node:path";
2
+ import { DevFnConfigError } from "./errors.js";
3
+ const SENSITIVE_KEY = /(authorization|token|secret|password|cookie|api[-_]?key|session[-_]?id|access[-_]?key|refresh[-_]?token)/i;
4
+ function fail(message, field) {
5
+ throw new DevFnConfigError("DEVFN_CONFIG_INVALID", message, field);
6
+ }
7
+ function record(value, field) {
8
+ if (!value || typeof value !== "object" || Array.isArray(value))
9
+ fail(`${field} must be an object.`, field);
10
+ return value;
11
+ }
12
+ function string(value, field) {
13
+ if (typeof value !== "string" || value.trim() === "")
14
+ fail(`${field} must be a non-empty string.`, field);
15
+ return value;
16
+ }
17
+ function optionalString(value, field) {
18
+ return value === undefined ? undefined : string(value, field);
19
+ }
20
+ function optionalBoolean(value, field) {
21
+ if (value === undefined)
22
+ return undefined;
23
+ if (typeof value !== "boolean")
24
+ fail(`${field} must be a boolean.`, field);
25
+ return value;
26
+ }
27
+ function stringArray(value, field) {
28
+ if (value === undefined)
29
+ return undefined;
30
+ if (!Array.isArray(value))
31
+ fail(`${field} must be an array.`, field);
32
+ return value.map((item, index) => string(item, `${field}[${index}]`));
33
+ }
34
+ function stringMap(value, field) {
35
+ if (value === undefined)
36
+ return undefined;
37
+ return Object.fromEntries(Object.entries(record(value, field)).map(([key, item]) => [key, string(item, `${field}.${key}`)]));
38
+ }
39
+ function integer(value, field, min = 1, max = 65535) {
40
+ if (!Number.isInteger(value) || value < min || value > max) {
41
+ fail(`${field} must be an integer between ${min} and ${max}.`, field);
42
+ }
43
+ return value;
44
+ }
45
+ function range(value, field) {
46
+ if (value === undefined)
47
+ return undefined;
48
+ if (!Array.isArray(value) || value.length !== 2)
49
+ fail(`${field} must be a two-item port range.`, field);
50
+ const start = integer(value[0], `${field}[0]`);
51
+ const end = integer(value[1], `${field}[1]`);
52
+ if (start > end)
53
+ fail(`${field} must be ascending.`, field);
54
+ return [start, end];
55
+ }
56
+ function relativePath(value, field) {
57
+ const result = string(value, field);
58
+ if (path.isAbsolute(result) || result.split(/[\\/]+/).includes("..")) {
59
+ throw new DevFnConfigError("DEVFN_CONFIG_PATH_ESCAPE", `${field} must stay inside the repository.`, field);
60
+ }
61
+ return result;
62
+ }
63
+ function healthTimeout(input, field) {
64
+ const timeoutMs = input.timeoutMs === undefined ? undefined : integer(input.timeoutMs, `${field}.timeoutMs`, 1, 3_600_000);
65
+ return timeoutMs === undefined ? {} : { timeoutMs };
66
+ }
67
+ function httpHealth(input, field) {
68
+ if (input.url === undefined && input.port === undefined)
69
+ fail(`${field} requires url or port.`, field);
70
+ const url = optionalString(input.url, `${field}.url`);
71
+ const port = optionalString(input.port, `${field}.port`);
72
+ const healthPath = optionalString(input.path, `${field}.path`);
73
+ return { type: "http", ...(url ? { url } : {}), ...(port ? { port } : {}), ...(healthPath ? { path: healthPath } : {}), ...(input.expectedStatus === undefined ? {} : { expectedStatus: integer(input.expectedStatus, `${field}.expectedStatus`, 100, 599) }), ...healthTimeout(input, field) };
74
+ }
75
+ function commandHealth(input, field) {
76
+ const command = stringArray(input.command, `${field}.command`);
77
+ if (!command?.length)
78
+ fail(`${field}.command cannot be empty.`, `${field}.command`);
79
+ return { type: "command", command, ...healthTimeout(input, field) };
80
+ }
81
+ function health(value, field) {
82
+ if (value === undefined)
83
+ return undefined;
84
+ const input = record(value, field);
85
+ switch (string(input.type, `${field}.type`)) {
86
+ case "http": return httpHealth(input, field);
87
+ case "tcp": return { type: "tcp", port: string(input.port, `${field}.port`), ...healthTimeout(input, field) };
88
+ case "command": return commandHealth(input, field);
89
+ case "log": return { type: "log", pattern: string(input.pattern, `${field}.pattern`), ...healthTimeout(input, field) };
90
+ default: return fail(`${field}.type is unsupported.`, `${field}.type`);
91
+ }
92
+ }
93
+ function portSpec(value, field) {
94
+ const input = record(value, field);
95
+ const preferred = input.preferred === undefined ? undefined : integer(input.preferred, `${field}.preferred`);
96
+ const configuredRange = range(input.range, `${field}.range`);
97
+ const ephemeral = optionalBoolean(input.ephemeral, `${field}.ephemeral`) ?? false;
98
+ const exact = optionalBoolean(input.exact, `${field}.exact`) ?? false;
99
+ const block = optionalString(input.block, `${field}.block`);
100
+ if (exact && preferred === undefined)
101
+ fail(`${field}.preferred is required when exact is true.`, field);
102
+ if (ephemeral && (preferred !== undefined || configuredRange !== undefined || exact || block !== undefined)) {
103
+ fail(`${field} ephemeral ports cannot also be preferred, ranged, exact, or part of a block.`, field);
104
+ }
105
+ const protocol = input.protocol === undefined ? "tcp" : string(input.protocol, `${field}.protocol`);
106
+ if (protocol !== "tcp" && protocol !== "udp")
107
+ fail(`${field}.protocol must be tcp or udp.`, `${field}.protocol`);
108
+ const exposure = input.exposure === undefined ? "loopback" : string(input.exposure, `${field}.exposure`);
109
+ if (exposure !== "loopback" && exposure !== "public")
110
+ fail(`${field}.exposure must be loopback or public.`, `${field}.exposure`);
111
+ return {
112
+ protocol,
113
+ exposure,
114
+ ...(preferred === undefined ? {} : { preferred }),
115
+ ...(configuredRange === undefined ? {} : { range: configuredRange }),
116
+ ...(exact ? { exact } : {}),
117
+ ...(ephemeral ? { ephemeral } : {}),
118
+ ...(input.internal === undefined ? {} : { internal: integer(input.internal, `${field}.internal`) }),
119
+ ...(block ? { block } : {}),
120
+ ...(optionalString(input.env, `${field}.env`) ? { env: optionalString(input.env, `${field}.env`) } : {}),
121
+ };
122
+ }
123
+ function environmentFields(input, field) {
124
+ const env = stringMap(input.env, `${field}.env`);
125
+ const envAllowlist = stringArray(input.envAllowlist, `${field}.envAllowlist`);
126
+ const secretEnv = stringArray(input.secretEnv, `${field}.secretEnv`);
127
+ for (const key of Object.keys(env ?? {}))
128
+ if (SENSITIVE_KEY.test(key))
129
+ fail(`${field}.env.${key} must not contain a literal secret; inherit it through envAllowlist and declare it in secretEnv.`, `${field}.env.${key}`);
130
+ for (const key of secretEnv ?? [])
131
+ if (!envAllowlist?.includes(key))
132
+ fail(`${field}.secretEnv contains ${key}, which is not present in envAllowlist.`, `${field}.secretEnv`);
133
+ for (const key of envAllowlist ?? [])
134
+ if (SENSITIVE_KEY.test(key) && !secretEnv?.includes(key))
135
+ fail(`${field}.envAllowlist contains sensitive key ${key}; declare it in secretEnv for log redaction.`, `${field}.envAllowlist`);
136
+ return { ...(env ? { env } : {}), ...(envAllowlist ? { envAllowlist } : {}), ...(secretEnv ? { secretEnv } : {}) };
137
+ }
138
+ function processSpec(value, field) {
139
+ const input = record(value, field);
140
+ const adapter = string(input.adapter, `${field}.adapter`);
141
+ if (!["command", "npm", "pnpm", "turbo", "wrangler", "xcode", "extfn"].includes(adapter)) {
142
+ fail(`${field}.adapter is unsupported.`, `${field}.adapter`);
143
+ }
144
+ const command = stringArray(input.command, `${field}.command`);
145
+ const script = optionalString(input.script, `${field}.script`);
146
+ if (adapter === "command" && !command?.length)
147
+ fail(`${field}.command is required for command adapters.`, field);
148
+ if ((adapter === "npm" || adapter === "pnpm") && !script)
149
+ fail(`${field}.script is required for ${adapter}.`, field);
150
+ const exposure = input.exposure === undefined ? "local" : string(input.exposure, `${field}.exposure`);
151
+ if (exposure !== "local" && exposure !== "public")
152
+ fail(`${field}.exposure must be local or public.`, `${field}.exposure`);
153
+ const ports = stringArray(input.ports, `${field}.ports`);
154
+ const dependsOn = stringArray(input.dependsOn, `${field}.dependsOn`);
155
+ const parsedHealth = health(input.health, `${field}.health`);
156
+ return {
157
+ adapter,
158
+ exposure,
159
+ ...(command ? { command } : {}),
160
+ ...(script ? { script } : {}),
161
+ ...(input.cwd === undefined ? {} : { cwd: relativePath(input.cwd, `${field}.cwd`) }),
162
+ ...environmentFields(input, field),
163
+ ...(ports ? { ports } : {}),
164
+ ...(dependsOn ? { dependsOn } : {}),
165
+ ...(parsedHealth ? { health: parsedHealth } : {}),
166
+ ...(input.shutdownTimeoutMs === undefined ? {} : { shutdownTimeoutMs: integer(input.shutdownTimeoutMs, `${field}.shutdownTimeoutMs`, 1, 3_600_000) }),
167
+ };
168
+ }
169
+ function serviceSpec(value, field) {
170
+ const input = record(value, field);
171
+ if (input.adapter !== "compose")
172
+ fail(`${field}.adapter must be compose.`, `${field}.adapter`);
173
+ const ports = input.ports === undefined ? undefined : Object.fromEntries(Object.entries(record(input.ports, `${field}.ports`)).map(([name, internal]) => [name, integer(internal, `${field}.ports.${name}`)]));
174
+ const dependsOn = stringArray(input.dependsOn, `${field}.dependsOn`);
175
+ const parsedHealth = health(input.health, `${field}.health`);
176
+ return {
177
+ adapter: "compose",
178
+ service: string(input.service, `${field}.service`),
179
+ ...(input.file === undefined ? {} : { file: relativePath(input.file, `${field}.file`) }),
180
+ ...(optionalString(input.projectName, `${field}.projectName`) ? { projectName: optionalString(input.projectName, `${field}.projectName`) } : {}),
181
+ ...(ports ? { ports } : {}),
182
+ ...(dependsOn ? { dependsOn } : {}),
183
+ ...(parsedHealth ? { health: parsedHealth } : {}),
184
+ ...(input.persistent === undefined ? {} : { persistent: input.persistent === true }),
185
+ ...environmentFields(input, field),
186
+ };
187
+ }
188
+ function profileSpec(value, field) {
189
+ const input = record(value, field);
190
+ const environment = stringMap(input.environment, `${field}.environment`);
191
+ const proxy = optionalBoolean(input.proxy, `${field}.proxy`);
192
+ for (const key of Object.keys(environment ?? {}))
193
+ if (SENSITIVE_KEY.test(key))
194
+ fail(`${field}.environment.${key} must not contain a secret. Use process envAllowlist and secretEnv.`, `${field}.environment.${key}`);
195
+ return {
196
+ ...(stringArray(input.processes, `${field}.processes`) ? { processes: stringArray(input.processes, `${field}.processes`) } : {}),
197
+ ...(stringArray(input.services, `${field}.services`) ? { services: stringArray(input.services, `${field}.services`) } : {}),
198
+ ...(environment ? { environment } : {}),
199
+ ...(proxy === undefined ? {} : { proxy }),
200
+ };
201
+ }
202
+ function hostnameSpec(value, field) {
203
+ const input = record(value, field);
204
+ const tls = input.tls === undefined ? "off" : string(input.tls, `${field}.tls`);
205
+ if (tls !== "off" && tls !== "internal")
206
+ fail(`${field}.tls must be off or internal.`, `${field}.tls`);
207
+ const hostname = optionalString(input.hostname, `${field}.hostname`);
208
+ const expandedHostname = hostname?.replaceAll("{project}", "project").replaceAll("{instance}", "instance");
209
+ if (hostname && (expandedHostname?.includes("{") || expandedHostname?.includes("}") || !/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+localhost$/i.test(expandedHostname))) {
210
+ fail(`${field}.hostname must be a .localhost hostname using only {project} and {instance} placeholders.`, `${field}.hostname`);
211
+ }
212
+ return {
213
+ target: string(input.target, `${field}.target`),
214
+ ...(hostname ? { hostname } : {}),
215
+ tls,
216
+ ...(stringArray(input.profiles, `${field}.profiles`) ? { profiles: stringArray(input.profiles, `${field}.profiles`) } : {}),
217
+ };
218
+ }
219
+ function prerequisites(value) {
220
+ if (value === undefined)
221
+ return undefined;
222
+ if (!Array.isArray(value))
223
+ fail("prerequisites must be an array.", "prerequisites");
224
+ return value.map((item, index) => {
225
+ const input = record(item, `prerequisites[${index}]`);
226
+ const optional = optionalBoolean(input.optional, `prerequisites[${index}].optional`);
227
+ return {
228
+ command: string(input.command, `prerequisites[${index}].command`),
229
+ ...(optionalString(input.version, `prerequisites[${index}].version`) ? { version: optionalString(input.version, `prerequisites[${index}].version`) } : {}),
230
+ ...(optional === undefined ? {} : { optional }),
231
+ ...(stringArray(input.profiles, `prerequisites[${index}].profiles`) ? { profiles: stringArray(input.profiles, `prerequisites[${index}].profiles`) } : {}),
232
+ };
233
+ });
234
+ }
235
+ function environmentOutputs(value) {
236
+ if (value === undefined)
237
+ return undefined;
238
+ if (!Array.isArray(value))
239
+ fail("environmentOutputs must be an array.", "environmentOutputs");
240
+ return value.map((item, index) => {
241
+ const input = record(item, `environmentOutputs[${index}]`);
242
+ const format = input.format === undefined ? "dotenv" : string(input.format, `environmentOutputs[${index}].format`);
243
+ if (format !== "dotenv" && format !== "json")
244
+ fail("Environment output format must be dotenv or json.");
245
+ return {
246
+ path: relativePath(input.path, `environmentOutputs[${index}].path`),
247
+ format,
248
+ ...(input.mode === undefined ? {} : { mode: (() => {
249
+ const mode = integer(input.mode, `environmentOutputs[${index}].mode`, 0, 0o777);
250
+ if ((mode & 0o077) !== 0)
251
+ fail(`environmentOutputs[${index}].mode must not grant group or other permissions.`, `environmentOutputs[${index}].mode`);
252
+ return mode;
253
+ })() }),
254
+ };
255
+ });
256
+ }
257
+ function named(value, field, parser) {
258
+ return Object.fromEntries(Object.entries(record(value, field)).map(([name, item]) => [name, parser(item, `${field}.${name}`)]));
259
+ }
260
+ function validateProcessReferences(config, ports, nodes) {
261
+ for (const [name, process] of Object.entries(config.processes ?? {})) {
262
+ for (const port of process.ports ?? [])
263
+ if (!ports.has(port))
264
+ fail(`processes.${name} references unknown port ${port}.`);
265
+ const healthPort = process.health && (process.health.type === "http" || process.health.type === "tcp") ? process.health.port : undefined;
266
+ if (healthPort && (!ports.has(healthPort) || !process.ports?.includes(healthPort)))
267
+ fail(`processes.${name}.health references port ${healthPort}, which must also appear in processes.${name}.ports.`);
268
+ if (healthPort && config.ports?.[healthPort]?.protocol === "udp")
269
+ fail(`processes.${name}.health requires TCP port ${healthPort}, but that allocation uses UDP.`);
270
+ for (const dependency of process.dependsOn ?? [])
271
+ if (!nodes.has(dependency))
272
+ fail(`processes.${name} references unknown dependency ${dependency}.`);
273
+ }
274
+ }
275
+ function validateServiceReferences(config, ports, nodes) {
276
+ for (const [name, service] of Object.entries(config.services ?? {})) {
277
+ for (const port of Object.keys(service.ports ?? {}))
278
+ if (!ports.has(port))
279
+ fail(`services.${name} references unknown port ${port}.`);
280
+ const healthPort = service.health && (service.health.type === "http" || service.health.type === "tcp") ? service.health.port : undefined;
281
+ if (healthPort && (!ports.has(healthPort) || service.ports?.[healthPort] === undefined))
282
+ fail(`services.${name}.health references port ${healthPort}, which must also appear in services.${name}.ports.`);
283
+ if (healthPort && config.ports?.[healthPort]?.protocol === "udp")
284
+ fail(`services.${name}.health requires TCP port ${healthPort}, but that allocation uses UDP.`);
285
+ for (const dependency of service.dependsOn ?? [])
286
+ if (!nodes.has(dependency))
287
+ fail(`services.${name} references unknown dependency ${dependency}.`);
288
+ }
289
+ }
290
+ function validateSelectionReferences(config, ports, processes, services) {
291
+ for (const [name, profile] of Object.entries(config.profiles)) {
292
+ for (const process of profile.processes ?? [])
293
+ if (!processes.has(process))
294
+ fail(`profiles.${name} references unknown process ${process}.`);
295
+ for (const service of profile.services ?? [])
296
+ if (!services.has(service))
297
+ fail(`profiles.${name} references unknown service ${service}.`);
298
+ }
299
+ for (const [name, hostname] of Object.entries(config.hostnames ?? {})) {
300
+ if (!ports.has(hostname.target))
301
+ fail(`hostnames.${name} references unknown port ${hostname.target}.`);
302
+ for (const profile of hostname.profiles ?? [])
303
+ if (!config.profiles[profile])
304
+ fail(`hostnames.${name} references unknown profile ${profile}.`);
305
+ const activeProfiles = hostname.profiles ?? Object.keys(config.profiles);
306
+ if (config.ports?.[hostname.target]?.protocol === "udp" && activeProfiles.some((profile) => config.profiles[profile]?.proxy)) {
307
+ fail(`hostnames.${name} cannot target UDP port ${hostname.target} from a proxy-enabled profile.`, `hostnames.${name}.target`);
308
+ }
309
+ }
310
+ if (config.defaultProfile && !config.profiles[config.defaultProfile])
311
+ fail(`defaultProfile references unknown profile ${config.defaultProfile}.`);
312
+ }
313
+ function validateReferences(config) {
314
+ const ports = new Set(Object.keys(config.ports ?? {}));
315
+ const processes = new Set(Object.keys(config.processes ?? {}));
316
+ const services = new Set(Object.keys(config.services ?? {}));
317
+ for (const name of [...processes, ...services])
318
+ if (!/^[A-Za-z0-9_.-]+$/.test(name) || name !== name.trim())
319
+ fail(`Lifecycle node ${name} must use only letters, numbers, dots, underscores, and hyphens.`);
320
+ for (const name of ports)
321
+ if (!/^[A-Za-z0-9_.-]+$/.test(name) || name !== name.trim())
322
+ fail(`Port name ${JSON.stringify(name)} must use only letters, numbers, dots, underscores, and hyphens.`);
323
+ const collision = [...processes].find((name) => services.has(name));
324
+ if (collision)
325
+ fail(`Lifecycle node ${collision} cannot be both a process and a service.`);
326
+ const nodes = new Set([...processes, ...services]);
327
+ validateProcessReferences(config, ports, nodes);
328
+ validateServiceReferences(config, ports, nodes);
329
+ validateSelectionReferences(config, ports, processes, services);
330
+ const environmentOwners = new Map();
331
+ for (const [name, spec] of Object.entries(config.ports ?? {})) {
332
+ if (spec.env?.startsWith("DEVFN_"))
333
+ fail(`ports.${name}.env cannot use reserved DEVFN_ runtime variables.`, `ports.${name}.env`);
334
+ const generated = `DEVFN_PORT_${name.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}`;
335
+ for (const environmentName of new Set([generated, ...(spec.env ? [spec.env] : [])])) {
336
+ const owner = environmentOwners.get(environmentName);
337
+ if (owner !== undefined && owner !== name)
338
+ fail(`Ports ${owner} and ${name} both emit environment variable ${environmentName}.`, `ports.${name}`);
339
+ environmentOwners.set(environmentName, name);
340
+ }
341
+ }
342
+ for (const [name, spec] of Object.entries(config.hostnames ?? {})) {
343
+ const expanded = (spec.hostname ?? `${name}-{instance}.localhost`).replaceAll("{instance}", "instance").replaceAll("{project}", config.project.id);
344
+ if (!/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+localhost$/i.test(expanded))
345
+ fail(`hostnames.${name}.hostname resolves to an invalid .localhost hostname for project ${config.project.id}.`, `hostnames.${name}.hostname`);
346
+ }
347
+ }
348
+ export function validateDevFnConfig(value) {
349
+ const input = record(value, "config");
350
+ if (input.version !== 1)
351
+ fail("config.version must be 1.", "version");
352
+ const project = record(input.project, "project");
353
+ const profiles = named(input.profiles, "profiles", profileSpec);
354
+ if (Object.keys(profiles).length === 0)
355
+ fail("At least one profile is required.", "profiles");
356
+ const config = {
357
+ version: 1,
358
+ project: { id: string(project.id, "project.id"), ...(optionalString(project.name, "project.name") ? { name: optionalString(project.name, "project.name") } : {}) },
359
+ profiles,
360
+ ...(optionalString(input.defaultProfile, "defaultProfile") ? { defaultProfile: optionalString(input.defaultProfile, "defaultProfile") } : {}),
361
+ ...(input.runtimeDir === undefined ? {} : { runtimeDir: relativePath(input.runtimeDir, "runtimeDir") }),
362
+ ...(input.ports === undefined ? {} : { ports: named(input.ports, "ports", portSpec) }),
363
+ ...(input.processes === undefined ? {} : { processes: named(input.processes, "processes", processSpec) }),
364
+ ...(input.services === undefined ? {} : { services: named(input.services, "services", serviceSpec) }),
365
+ ...(input.hostnames === undefined ? {} : { hostnames: named(input.hostnames, "hostnames", hostnameSpec) }),
366
+ ...(prerequisites(input.prerequisites) ? { prerequisites: prerequisites(input.prerequisites) } : {}),
367
+ ...(environmentOutputs(input.environmentOutputs) ? { environmentOutputs: environmentOutputs(input.environmentOutputs) } : {}),
368
+ ...(input.policy === undefined ? {} : { policy: relativePath(input.policy, "policy") }),
369
+ };
370
+ if (!config.defaultProfile && !config.profiles.default) {
371
+ fail("profiles.default is required when defaultProfile is omitted.", "profiles.default");
372
+ }
373
+ validateReferences(config);
374
+ return config;
375
+ }
376
+ function policyEntry(value, field) {
377
+ const input = record(value, field);
378
+ const kind = string(input.kind, `${field}.kind`);
379
+ if (!["protected", "preferred", "excluded"].includes(kind))
380
+ fail(`${field}.kind is unsupported.`);
381
+ const configuredRange = range(input.range, `${field}.range`);
382
+ const port = input.port === undefined ? undefined : integer(input.port, `${field}.port`);
383
+ if ((port === undefined) === (configuredRange === undefined))
384
+ fail(`${field} requires exactly one of port or range.`);
385
+ return {
386
+ name: string(input.name, `${field}.name`),
387
+ kind: kind,
388
+ ...(port === undefined ? {} : { port }),
389
+ ...(configuredRange === undefined ? {} : { range: configuredRange }),
390
+ ...(optionalString(input.project, `${field}.project`) ? { project: optionalString(input.project, `${field}.project`) } : {}),
391
+ ...(optionalString(input.description, `${field}.description`) ? { description: optionalString(input.description, `${field}.description`) } : {}),
392
+ };
393
+ }
394
+ export function validateDevFnPolicy(value) {
395
+ const input = record(value, "policy");
396
+ if (input.version !== 1)
397
+ fail("policy.version must be 1.", "version");
398
+ const ports = input.ports === undefined ? undefined : (() => {
399
+ if (!Array.isArray(input.ports))
400
+ fail("policy.ports must be an array.", "ports");
401
+ return input.ports.map((item, index) => policyEntry(item, `ports[${index}]`));
402
+ })();
403
+ const hostnameSuffix = optionalString(input.hostnameSuffix, "hostnameSuffix");
404
+ if (hostnameSuffix && (!hostnameSuffix.startsWith(".") || !hostnameSuffix.endsWith(".localhost")))
405
+ fail("hostnameSuffix must start with a dot and end in .localhost.", "hostnameSuffix");
406
+ return {
407
+ version: 1,
408
+ ...(range(input.fallbackRange, "fallbackRange") ? { fallbackRange: range(input.fallbackRange, "fallbackRange") } : {}),
409
+ ...(hostnameSuffix ? { hostnameSuffix } : {}),
410
+ ...(ports ? { ports } : {}),
411
+ };
412
+ }
@@ -0,0 +1,8 @@
1
+ export declare function defaultStateDir(env?: NodeJS.ProcessEnv): string;
2
+ export declare function configDigest(configPath: string): Promise<string>;
3
+ export declare function readTrustedManifest(root: string, configPath: string, stateDir?: string): Promise<{
4
+ bytes: Buffer;
5
+ digest: string;
6
+ }>;
7
+ export declare function isProjectTrusted(root: string, configPath: string, stateDir?: string): Promise<boolean>;
8
+ export declare function trustProject(root: string, configPath: string, stateDir?: string): Promise<void>;
package/dist/trust.js ADDED
@@ -0,0 +1,151 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { processBirthSignature, processExists } from "./process-identity.js";
6
+ export function defaultStateDir(env = process.env) {
7
+ return env.XDG_STATE_HOME ? path.join(env.XDG_STATE_HOME, "devfn") : path.join(os.homedir(), ".local", "state", "devfn");
8
+ }
9
+ export async function configDigest(configPath) {
10
+ return createHash("sha256").update(await readFile(configPath)).digest("hex");
11
+ }
12
+ async function readTrust(filePath) {
13
+ try {
14
+ const value = JSON.parse(await readFile(filePath, "utf8"));
15
+ return value.version === 1 && Array.isArray(value.records) ? value : { version: 1, records: [] };
16
+ }
17
+ catch {
18
+ return { version: 1, records: [] };
19
+ }
20
+ }
21
+ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
22
+ async function readTicket(filePath) {
23
+ try {
24
+ return JSON.parse(await readFile(filePath, "utf8"));
25
+ }
26
+ catch (error) {
27
+ if (error.code === "ENOENT")
28
+ return undefined;
29
+ throw error;
30
+ }
31
+ }
32
+ async function staleTicket(filePath, ticket, staleMs) {
33
+ const createdAt = ticket?.createdAt ? Date.parse(ticket.createdAt) : await stat(filePath).then((value) => value.mtimeMs).catch(() => Date.now());
34
+ if (!Number.isFinite(createdAt) || Date.now() - createdAt <= staleMs)
35
+ return false;
36
+ if (!ticket?.pid)
37
+ return true;
38
+ if (!processExists(ticket.pid))
39
+ return true;
40
+ if (!ticket.birthSignature)
41
+ return false;
42
+ const currentSignature = await processBirthSignature(ticket.pid);
43
+ return currentSignature !== undefined && currentSignature !== ticket.birthSignature;
44
+ }
45
+ async function ticketPaths(lockPath) {
46
+ return (await readdir(lockPath)).filter((name) => name.endsWith(".choosing") || name.endsWith(".ticket")).map((name) => path.join(lockPath, name));
47
+ }
48
+ async function cleanStaleTickets(lockPath, ownPaths, staleMs) {
49
+ for (const filePath of await ticketPaths(lockPath)) {
50
+ if (ownPaths.has(filePath))
51
+ continue;
52
+ let ticket;
53
+ try {
54
+ ticket = await readTicket(filePath);
55
+ }
56
+ catch (error) {
57
+ if (await staleTicket(filePath, undefined, staleMs)) {
58
+ await rm(filePath, { force: true });
59
+ continue;
60
+ }
61
+ throw error;
62
+ }
63
+ if (ticket && await staleTicket(filePath, ticket, staleMs))
64
+ await rm(filePath, { force: true });
65
+ }
66
+ }
67
+ async function writeTicket(filePath, ticket) {
68
+ const temp = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
69
+ try {
70
+ await writeFile(temp, JSON.stringify(ticket), { mode: 0o600, flag: "wx" });
71
+ await rename(temp, filePath);
72
+ }
73
+ finally {
74
+ await rm(temp, { force: true }).catch(() => undefined);
75
+ }
76
+ }
77
+ async function withTrustLock(stateDir, action) {
78
+ const lockPath = path.join(stateDir, "trust.lock");
79
+ await mkdir(lockPath, { recursive: true, mode: 0o700 });
80
+ const token = randomUUID();
81
+ const choosingPath = path.join(lockPath, `${token}.choosing`);
82
+ const ticketPath = path.join(lockPath, `${token}.ticket`);
83
+ const ownPaths = new Set([choosingPath, ticketPath]);
84
+ const staleMs = 300_000;
85
+ const deadline = Date.now() + 10_000;
86
+ const baseTicket = { token, pid: process.pid, ...(await processBirthSignature(process.pid).then((birthSignature) => birthSignature ? { birthSignature } : {})), createdAt: new Date().toISOString() };
87
+ await writeTicket(choosingPath, baseTicket);
88
+ try {
89
+ await cleanStaleTickets(lockPath, ownPaths, staleMs);
90
+ const tickets = await Promise.all((await ticketPaths(lockPath)).filter((filePath) => filePath.endsWith(".ticket")).map(readTicket));
91
+ const number = Math.max(0, ...tickets.map((ticket) => Number.isInteger(ticket?.number) ? ticket.number : 0)) + 1;
92
+ await writeTicket(ticketPath, { ...baseTicket, number });
93
+ await rm(choosingPath, { force: true });
94
+ while (true) {
95
+ await cleanStaleTickets(lockPath, ownPaths, staleMs);
96
+ const others = (await ticketPaths(lockPath)).filter((filePath) => !ownPaths.has(filePath));
97
+ let blocked = others.some((filePath) => filePath.endsWith(".choosing"));
98
+ if (!blocked) {
99
+ for (const filePath of others.filter((candidate) => candidate.endsWith(".ticket"))) {
100
+ const ticket = await readTicket(filePath);
101
+ if (!ticket || !Number.isInteger(ticket.number) || ticket.number < number || (ticket.number === number && ticket.token < token)) {
102
+ blocked = true;
103
+ break;
104
+ }
105
+ }
106
+ }
107
+ if (!blocked)
108
+ break;
109
+ if (Date.now() >= deadline)
110
+ throw new Error(`Timed out acquiring trust-state lock ${lockPath}.`);
111
+ await delay(20 + Math.floor(Math.random() * 20));
112
+ }
113
+ return await action();
114
+ }
115
+ finally {
116
+ await rm(choosingPath, { force: true }).catch(() => undefined);
117
+ await rm(ticketPath, { force: true }).catch(() => undefined);
118
+ }
119
+ }
120
+ export async function readTrustedManifest(root, configPath, stateDir = defaultStateDir()) {
121
+ const bytes = await readFile(configPath);
122
+ const digest = createHash("sha256").update(bytes).digest("hex");
123
+ const state = await readTrust(path.join(stateDir, "trust.json"));
124
+ if (!state.records.some((record) => record.root === root && record.configPath === configPath && record.digest === digest)) {
125
+ throw new Error(`DevFn manifest is not trusted: ${configPath}`);
126
+ }
127
+ return { bytes, digest };
128
+ }
129
+ export async function isProjectTrusted(root, configPath, stateDir = defaultStateDir()) {
130
+ const digest = await configDigest(configPath);
131
+ const state = await readTrust(path.join(stateDir, "trust.json"));
132
+ return state.records.some((record) => record.root === root && record.configPath === configPath && record.digest === digest);
133
+ }
134
+ export async function trustProject(root, configPath, stateDir = defaultStateDir()) {
135
+ await mkdir(stateDir, { recursive: true, mode: 0o700 });
136
+ await withTrustLock(stateDir, async () => {
137
+ const filePath = path.join(stateDir, "trust.json");
138
+ const state = await readTrust(filePath);
139
+ const digest = await configDigest(configPath);
140
+ const next = {
141
+ version: 1,
142
+ records: [
143
+ ...state.records.filter((record) => !(record.root === root && record.configPath === configPath)),
144
+ { root, configPath, digest, trustedAt: new Date().toISOString() },
145
+ ],
146
+ };
147
+ const temp = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
148
+ await writeFile(temp, `${JSON.stringify(next, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
149
+ await rename(temp, filePath);
150
+ });
151
+ }
@@ -0,0 +1,124 @@
1
+ export type RuntimeAdapter = "command" | "npm" | "pnpm" | "turbo" | "wrangler" | "xcode" | "extfn";
2
+ export type HealthCheck = {
3
+ type: "http";
4
+ url?: string;
5
+ port?: string;
6
+ path?: string;
7
+ expectedStatus?: number;
8
+ timeoutMs?: number;
9
+ } | {
10
+ type: "tcp";
11
+ port: string;
12
+ timeoutMs?: number;
13
+ } | {
14
+ type: "command";
15
+ command: string[];
16
+ timeoutMs?: number;
17
+ } | {
18
+ type: "log";
19
+ pattern: string;
20
+ timeoutMs?: number;
21
+ };
22
+ export interface PortSpec {
23
+ protocol?: "tcp" | "udp";
24
+ preferred?: number;
25
+ range?: [number, number];
26
+ exact?: boolean;
27
+ ephemeral?: boolean;
28
+ exposure?: "loopback" | "public";
29
+ internal?: number;
30
+ block?: string;
31
+ env?: string;
32
+ }
33
+ export interface ProcessSpec {
34
+ adapter: RuntimeAdapter;
35
+ exposure?: "local" | "public";
36
+ command?: string[];
37
+ script?: string;
38
+ cwd?: string;
39
+ env?: Record<string, string>;
40
+ envAllowlist?: string[];
41
+ secretEnv?: string[];
42
+ ports?: string[];
43
+ dependsOn?: string[];
44
+ health?: HealthCheck;
45
+ shutdownTimeoutMs?: number;
46
+ }
47
+ export interface ComposeServiceSpec {
48
+ adapter: "compose";
49
+ file?: string;
50
+ service: string;
51
+ projectName?: string;
52
+ ports?: Record<string, number>;
53
+ dependsOn?: string[];
54
+ health?: HealthCheck;
55
+ persistent?: boolean;
56
+ env?: Record<string, string>;
57
+ envAllowlist?: string[];
58
+ secretEnv?: string[];
59
+ }
60
+ export interface ProfileSpec {
61
+ processes?: string[];
62
+ services?: string[];
63
+ environment?: Record<string, string>;
64
+ proxy?: boolean;
65
+ }
66
+ export interface HostnameSpec {
67
+ target: string;
68
+ hostname?: string;
69
+ tls?: "off" | "internal";
70
+ profiles?: string[];
71
+ }
72
+ export interface RuntimePrerequisite {
73
+ command: string;
74
+ version?: string;
75
+ optional?: boolean;
76
+ profiles?: string[];
77
+ }
78
+ export interface EnvironmentOutput {
79
+ path: string;
80
+ format?: "dotenv" | "json";
81
+ mode?: number;
82
+ }
83
+ export interface DevFnConfig {
84
+ version: 1;
85
+ project: {
86
+ id: string;
87
+ name?: string;
88
+ };
89
+ defaultProfile?: string;
90
+ runtimeDir?: string;
91
+ ports?: Record<string, PortSpec>;
92
+ processes?: Record<string, ProcessSpec>;
93
+ services?: Record<string, ComposeServiceSpec>;
94
+ profiles: Record<string, ProfileSpec>;
95
+ hostnames?: Record<string, HostnameSpec>;
96
+ prerequisites?: RuntimePrerequisite[];
97
+ environmentOutputs?: EnvironmentOutput[];
98
+ policy?: string;
99
+ }
100
+ export interface PortPolicyEntry {
101
+ name: string;
102
+ port?: number;
103
+ range?: [number, number];
104
+ kind: "protected" | "preferred" | "excluded";
105
+ project?: string;
106
+ description?: string;
107
+ }
108
+ export interface DevFnPolicy {
109
+ version: 1;
110
+ fallbackRange?: [number, number];
111
+ hostnameSuffix?: string;
112
+ ports?: PortPolicyEntry[];
113
+ }
114
+ export interface DetectionFinding {
115
+ kind: "package-manager" | "process" | "compose" | "wrangler" | "xcode" | "extension";
116
+ confidence: "confirmed" | "proposed";
117
+ source: string;
118
+ detail: string;
119
+ }
120
+ export interface DiscoveryResult {
121
+ root: string;
122
+ findings: DetectionFinding[];
123
+ config: DevFnConfig;
124
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "@devfn/config",
3
+ "version": "0.1.0",
4
+ "description": "Validated project manifests, organization policies, discovery, and trust contracts for DevFn",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
9
+ "files": ["dist", "README.md"],
10
+ "scripts": {
11
+ "build": "tsc -p tsconfig.json",
12
+ "typecheck": "tsc --noEmit -p tsconfig.json",
13
+ "test": "vitest run",
14
+ "lint": "echo 'lint not configured'",
15
+ "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\""
16
+ },
17
+ "dependencies": { "@clifn/core": "0.1.0", "@types/node": "^22.0.0", "typescript": "^5.6.0" },
18
+ "devDependencies": { "vitest": "^3.2.4" },
19
+ "engines": { "node": ">=22" },
20
+ "license": "MIT"
21
+ }