orionfold-relay 0.15.1 → 0.15.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/dist/cli.js CHANGED
@@ -27,18 +27,37 @@ var __copyProps = (to, from, except, desc16) => {
27
27
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
28
28
 
29
29
  // src/lib/utils/app-root.ts
30
- import { existsSync as existsSync2 } from "fs";
31
- import { join as join2 } from "path";
30
+ import { existsSync as existsSync2, readFileSync } from "fs";
31
+ import { join as join2, dirname as dirname2 } from "path";
32
32
  function getAppRoot(metaDirname, depth) {
33
33
  if (metaDirname) {
34
34
  const candidate = join2(metaDirname, ...Array(depth).fill(".."));
35
- if (existsSync2(join2(candidate, "package.json"))) return candidate;
35
+ if (isPackageRoot(candidate)) return candidate;
36
+ let dir = metaDirname;
37
+ while (true) {
38
+ if (isPackageRoot(dir)) return dir;
39
+ const parent = dirname2(dir);
40
+ if (parent === dir) break;
41
+ dir = parent;
42
+ }
36
43
  }
37
44
  return process.cwd();
38
45
  }
46
+ function isPackageRoot(dir) {
47
+ const pkgPath = join2(dir, "package.json");
48
+ if (!existsSync2(pkgPath)) return false;
49
+ try {
50
+ const pkg2 = JSON.parse(readFileSync(pkgPath, "utf-8"));
51
+ return pkg2.name === PKG_NAME;
52
+ } catch {
53
+ return false;
54
+ }
55
+ }
56
+ var PKG_NAME;
39
57
  var init_app_root = __esm({
40
58
  "src/lib/utils/app-root.ts"() {
41
59
  "use strict";
60
+ PKG_NAME = "orionfold-relay";
42
61
  }
43
62
  });
44
63
 
@@ -1203,7 +1222,7 @@ var init_types = __esm({
1203
1222
 
1204
1223
  // src/lib/environment/parsers/utils.ts
1205
1224
  import { createHash } from "crypto";
1206
- import { readFileSync as readFileSync2, statSync } from "fs";
1225
+ import { readFileSync as readFileSync3, statSync } from "fs";
1207
1226
  function computeHash(content) {
1208
1227
  const truncated = content.slice(0, MAX_HASH_BYTES);
1209
1228
  return createHash("sha256").update(truncated).digest("hex");
@@ -1220,7 +1239,7 @@ function safeStat(path19) {
1220
1239
  }
1221
1240
  function safeReadFile(path19) {
1222
1241
  try {
1223
- return readFileSync2(path19, "utf-8");
1242
+ return readFileSync3(path19, "utf-8");
1224
1243
  } catch {
1225
1244
  return null;
1226
1245
  }
@@ -5744,7 +5763,7 @@ __export(crypto_exports, {
5744
5763
  getOrCreateKeyfile: () => getOrCreateKeyfile
5745
5764
  });
5746
5765
  import { randomBytes, createCipheriv, createDecipheriv } from "crypto";
5747
- import { readFileSync as readFileSync3, writeFileSync, existsSync as existsSync5, mkdirSync as mkdirSync2, chmodSync } from "fs";
5766
+ import { readFileSync as readFileSync4, writeFileSync, existsSync as existsSync5, mkdirSync as mkdirSync2, chmodSync } from "fs";
5748
5767
  import { join as join7 } from "path";
5749
5768
  function getKeyfilePath() {
5750
5769
  return join7(dataDir(), ".keyfile");
@@ -5752,7 +5771,7 @@ function getKeyfilePath() {
5752
5771
  function getOrCreateKeyfile() {
5753
5772
  const keyfilePath = getKeyfilePath();
5754
5773
  if (existsSync5(keyfilePath)) {
5755
- const key2 = readFileSync3(keyfilePath);
5774
+ const key2 = readFileSync4(keyfilePath);
5756
5775
  if (key2.length !== KEY_LENGTH) {
5757
5776
  throw new Error(`Invalid keyfile: expected ${KEY_LENGTH} bytes, got ${key2.length}`);
5758
5777
  }
@@ -6718,7 +6737,7 @@ __export(workspace_context_exports, {
6718
6737
  getLaunchCwd: () => getLaunchCwd,
6719
6738
  getWorkspaceContext: () => getWorkspaceContext
6720
6739
  });
6721
- import { basename, dirname as dirname2 } from "path";
6740
+ import { basename, dirname as dirname3 } from "path";
6722
6741
  import { homedir as homedir4 } from "os";
6723
6742
  import { execFileSync as execFileSync2 } from "child_process";
6724
6743
  import { statSync as statSync2 } from "fs";
@@ -6730,7 +6749,7 @@ function getWorkspaceContext() {
6730
6749
  const cwd = getLaunchCwd();
6731
6750
  const home = homedir4();
6732
6751
  const folderName = basename(cwd);
6733
- const parent = dirname2(cwd);
6752
+ const parent = dirname3(cwd);
6734
6753
  const parentPath = parent.startsWith(home) ? "~" + parent.slice(home.length) : parent;
6735
6754
  let gitBranch = null;
6736
6755
  try {
@@ -12811,7 +12830,7 @@ var list_fused_profiles_exports = {};
12811
12830
  __export(list_fused_profiles_exports, {
12812
12831
  listFusedProfiles: () => listFusedProfiles
12813
12832
  });
12814
- import { readdirSync, readFileSync as readFileSync4, statSync as statSync3, existsSync as existsSync6 } from "fs";
12833
+ import { readdirSync, readFileSync as readFileSync5, statSync as statSync3, existsSync as existsSync6 } from "fs";
12815
12834
  import { join as join11 } from "path";
12816
12835
  import { homedir as homedir5 } from "os";
12817
12836
  function parseFrontmatter2(content) {
@@ -12836,7 +12855,7 @@ function loadFilesystemSkills(skillsDir, origin, projectRootDir) {
12836
12855
  if (!statSync3(skillPath).isDirectory()) continue;
12837
12856
  const skillMdPath = join11(skillPath, "SKILL.md");
12838
12857
  if (!existsSync6(skillMdPath)) continue;
12839
- const content = readFileSync4(skillMdPath, "utf8");
12858
+ const content = readFileSync5(skillMdPath, "utf8");
12840
12859
  const fm = parseFrontmatter2(content);
12841
12860
  if (!fm || !fm.name) {
12842
12861
  console.warn(
@@ -15552,7 +15571,7 @@ var init_active_skills = __esm({
15552
15571
  });
15553
15572
 
15554
15573
  // src/lib/environment/parsers/skill.ts
15555
- import { readdirSync as readdirSync2, readFileSync as readFileSync5 } from "fs";
15574
+ import { readdirSync as readdirSync2, readFileSync as readFileSync6 } from "fs";
15556
15575
  import { join as join12, basename as basename3 } from "path";
15557
15576
  function parseSkillDir(dirPath, tool, scope, baseDir) {
15558
15577
  const stat2 = safeStat(dirPath);
@@ -15566,7 +15585,7 @@ function parseSkillDir(dirPath, tool, scope, baseDir) {
15566
15585
  const skillFile = files.find((f) => f === "SKILL.md") || files.find((f) => f.endsWith(".md")) || files[0];
15567
15586
  if (skillFile) {
15568
15587
  mainFile = join12(dirPath, skillFile);
15569
- content = readFileSync5(mainFile, "utf-8");
15588
+ content = readFileSync6(mainFile, "utf-8");
15570
15589
  }
15571
15590
  } catch {
15572
15591
  return null;
@@ -16202,7 +16221,7 @@ __export(list_skills_exports, {
16202
16221
  listSkills: () => listSkills,
16203
16222
  listSkillsEnriched: () => listSkillsEnriched
16204
16223
  });
16205
- import { readFileSync as readFileSync6, readdirSync as readdirSync6, statSync as statSync4 } from "fs";
16224
+ import { readFileSync as readFileSync7, readdirSync as readdirSync6, statSync as statSync4 } from "fs";
16206
16225
  import { join as join17 } from "path";
16207
16226
  function listSkills(options = {}) {
16208
16227
  const projectDir = options.projectDir ?? getLaunchCwd();
@@ -16227,7 +16246,7 @@ function getSkill(id, options = {}) {
16227
16246
  const filePath = resolveSkillFile(hit.absPath);
16228
16247
  if (!filePath) return null;
16229
16248
  try {
16230
- const content = readFileSync6(filePath, "utf8");
16249
+ const content = readFileSync7(filePath, "utf8");
16231
16250
  return { ...hit, content };
16232
16251
  } catch {
16233
16252
  return null;
@@ -19151,7 +19170,7 @@ var init_codex_app_server_client = __esm({
19151
19170
 
19152
19171
  // src/lib/agents/runtime/openai-codex-auth.ts
19153
19172
  import { mkdir as mkdir2, readFile as readFile7, rm, writeFile } from "fs/promises";
19154
- import { dirname as dirname3 } from "path";
19173
+ import { dirname as dirname4 } from "path";
19155
19174
  function parseRateLimitWindow(value) {
19156
19175
  if (!value || typeof value !== "object") return null;
19157
19176
  return {
@@ -19211,7 +19230,7 @@ async function ensureCodexHomeConfig() {
19211
19230
  const codexDir = getAinativeCodexDir();
19212
19231
  const configPath = getAinativeCodexConfigPath();
19213
19232
  await mkdir2(codexDir, { recursive: true });
19214
- await mkdir2(dirname3(configPath), { recursive: true });
19233
+ await mkdir2(dirname4(configPath), { recursive: true });
19215
19234
  let current = "";
19216
19235
  try {
19217
19236
  current = await readFile7(configPath, "utf8");
@@ -25183,6 +25202,9 @@ import { execFileSync as execFileSync3 } from "child_process";
25183
25202
  import yaml12 from "js-yaml";
25184
25203
  import semver from "semver";
25185
25204
  function relayCoreVersion() {
25205
+ if (semver.valid("0.15.3")) {
25206
+ return "0.15.3";
25207
+ }
25186
25208
  try {
25187
25209
  const root = getAppRoot(import.meta.dirname, 3);
25188
25210
  const pkg2 = JSON.parse(
@@ -25551,13 +25573,13 @@ var init_cli = __esm({
25551
25573
 
25552
25574
  // bin/cli.ts
25553
25575
  import { program } from "commander";
25554
- import { basename as basename5, dirname as dirname4, join as join20 } from "path";
25576
+ import { basename as basename5, dirname as dirname5, join as join20 } from "path";
25555
25577
  import { homedir as homedir8 } from "os";
25556
25578
  import { fileURLToPath as fileURLToPath3 } from "url";
25557
25579
  import {
25558
25580
  mkdirSync as mkdirSync5,
25559
25581
  existsSync as existsSync12,
25560
- readFileSync as readFileSync7,
25582
+ readFileSync as readFileSync8,
25561
25583
  writeFileSync as writeFileSync5,
25562
25584
  cpSync as cpSync2,
25563
25585
  unlinkSync as unlinkSync2
@@ -25623,20 +25645,26 @@ function buildNextLaunchArgs({
25623
25645
  function buildSidecarUrl(port, host = SIDECAR_LOOPBACK_HOST) {
25624
25646
  return `http://${host}:${port}`;
25625
25647
  }
25648
+ function isNonLoopbackHost(host) {
25649
+ const h = host.trim().toLowerCase();
25650
+ if (h === "localhost" || h === "::1") return false;
25651
+ if (/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(h)) return false;
25652
+ return true;
25653
+ }
25626
25654
 
25627
25655
  // bin/cli.ts
25628
25656
  init_ainative_paths();
25629
25657
  init_bootstrap();
25630
25658
 
25631
25659
  // src/lib/utils/migrate-to-ainative.ts
25632
- import { existsSync as existsSync3, renameSync, cpSync, rmSync, readFileSync } from "fs";
25660
+ import { existsSync as existsSync3, renameSync, cpSync, rmSync, readFileSync as readFileSync2 } from "fs";
25633
25661
  import { join as join5 } from "path";
25634
25662
  import { homedir as homedir2 } from "os";
25635
25663
  import Database from "better-sqlite3";
25636
25664
  function hasSqliteHeader(path19) {
25637
25665
  const SQLITE_MAGIC = "SQLite format 3\0";
25638
25666
  try {
25639
- const header = readFileSync(path19, { encoding: null });
25667
+ const header = readFileSync2(path19, { encoding: null });
25640
25668
  return header.length >= 16 && header.subarray(0, 16).toString("binary") === SQLITE_MAGIC;
25641
25669
  } catch {
25642
25670
  return false;
@@ -25758,7 +25786,7 @@ async function migrateLegacyData(options = {}) {
25758
25786
 
25759
25787
  // bin/cli.ts
25760
25788
  init_detect();
25761
- var __dirname = dirname4(fileURLToPath3(import.meta.url));
25789
+ var __dirname = dirname5(fileURLToPath3(import.meta.url));
25762
25790
  var appDir = join20(__dirname, "..");
25763
25791
  var launchCwd2 = process.cwd();
25764
25792
  var _envLocalPath = join20(launchCwd2, ".env.local");
@@ -25766,18 +25794,31 @@ var _firstRunNeedsEnv = !existsSync12(_envLocalPath) && !process.env.RELAY_DATA_
25766
25794
  if (_firstRunNeedsEnv) {
25767
25795
  const folderName = basename5(launchCwd2);
25768
25796
  const autoDataDir = join20(homedir8(), `.${folderName}`);
25769
- writeFileSync5(
25770
- _envLocalPath,
25771
- `# Auto-created by orionfold-relay on first run.
25797
+ try {
25798
+ writeFileSync5(
25799
+ _envLocalPath,
25800
+ `# Auto-created by orionfold-relay on first run.
25772
25801
  # Points this folder's install at an isolated data directory.
25773
25802
  RELAY_DATA_DIR=${autoDataDir}
25774
25803
  `,
25775
- "utf-8"
25776
- );
25777
- console.log(`First run \u2014 wrote ${_envLocalPath} (RELAY_DATA_DIR=${autoDataDir}).`);
25804
+ "utf-8"
25805
+ );
25806
+ console.log(`First run \u2014 wrote ${_envLocalPath} (RELAY_DATA_DIR=${autoDataDir}).`);
25807
+ } catch (e) {
25808
+ const reason = e instanceof Error ? e.message : String(e);
25809
+ console.warn(
25810
+ `Warning: could not write ${_envLocalPath} (${reason}).
25811
+ Continuing with the default data directory (~/.relay). This folder will not get an isolated database.`
25812
+ );
25813
+ if (/^([A-Za-z]:)?[\\/]Windows([\\/]|$)/.test(launchCwd2)) {
25814
+ console.warn(
25815
+ `It looks like you launched from a Windows UNC path under WSL, so the working directory defaulted to "${launchCwd2}". Run relay from your Linux filesystem instead \u2014 e.g. \`cd ~\` first, or run it from a WSL home directory rather than a \\\\wsl.localhost\\... path.`
25816
+ );
25817
+ }
25818
+ }
25778
25819
  }
25779
25820
  if (existsSync12(_envLocalPath)) {
25780
- for (const line of readFileSync7(_envLocalPath, "utf-8").split("\n")) {
25821
+ for (const line of readFileSync8(_envLocalPath, "utf-8").split("\n")) {
25781
25822
  const trimmed = line.trim();
25782
25823
  if (!trimmed || trimmed.startsWith("#")) continue;
25783
25824
  const eqIdx = trimmed.indexOf("=");
@@ -25789,7 +25830,7 @@ if (existsSync12(_envLocalPath)) {
25789
25830
  }
25790
25831
  }
25791
25832
  }
25792
- var pkg = JSON.parse(readFileSync7(join20(appDir, "package.json"), "utf-8"));
25833
+ var pkg = JSON.parse(readFileSync8(join20(appDir, "package.json"), "utf-8"));
25793
25834
  function getHelpText() {
25794
25835
  const dir = getAinativeDataDir();
25795
25836
  const db3 = getAinativeDbPath();
@@ -25807,6 +25848,7 @@ Environment variables:
25807
25848
 
25808
25849
  Examples:
25809
25850
  node dist/cli.js --port 3210 --no-open
25851
+ node dist/cli.js --hostname 0.0.0.0 --port 3000 # expose on the LAN (see warning)
25810
25852
  node dist/cli.js --data-dir ~/.relay-dogfood --port 3100
25811
25853
  node dist/cli.js plugin dry-run my-plugin # print confinement policy
25812
25854
  node dist/cli.js pack add ./my-pack # install a Relay pack (folder or git url)
@@ -25814,7 +25856,11 @@ Examples:
25814
25856
  node dist/cli.js pack remove my-pack # uninstall a pack
25815
25857
  `;
25816
25858
  }
25817
- program.name("relay").description("Orionfold Relay \u2014 a local-first, multi-agent orchestration runtime and builder scaffold for AI-native work.").version(pkg.version).addHelpText("after", getHelpText).option("-p, --port <number>", "port to start on", "3000").option("--data-dir <path>", "custom data directory (overrides RELAY_DATA_DIR)").option("--reset", "delete the local database before starting").option("--no-open", "don't auto-open browser").option("--safe-mode", "disable Kind-1 plugin MCP servers; Kind-5 primitives bundles still load");
25859
+ program.name("relay").description("Orionfold Relay \u2014 a local-first, multi-agent orchestration runtime and builder scaffold for AI-native work.").version(pkg.version).addHelpText("after", getHelpText).option("-p, --port <number>", "port to start on", "3000").option(
25860
+ "--hostname <host>",
25861
+ "host to bind to (default 127.0.0.1; use 0.0.0.0 to expose on the network)",
25862
+ "127.0.0.1"
25863
+ ).option("--data-dir <path>", "custom data directory (overrides RELAY_DATA_DIR)").option("--reset", "delete the local database before starting").option("--no-open", "don't auto-open browser").option("--safe-mode", "disable Kind-1 plugin MCP servers; Kind-5 primitives bundles still load");
25818
25864
  var firstArg = process.argv[2];
25819
25865
  var isPluginSubcommand = firstArg === "plugin";
25820
25866
  var isPackSubcommand = firstArg === "pack";
@@ -25910,8 +25956,8 @@ async function main() {
25910
25956
  let effectiveCwd = appDir;
25911
25957
  const localNm = join20(appDir, "node_modules");
25912
25958
  if (!existsSync12(join20(localNm, "next", "package.json"))) {
25913
- let searchDir = dirname4(appDir);
25914
- while (searchDir !== dirname4(searchDir)) {
25959
+ let searchDir = dirname5(appDir);
25960
+ while (searchDir !== dirname5(searchDir)) {
25915
25961
  const candidate = join20(searchDir, "node_modules", "next", "package.json");
25916
25962
  if (existsSync12(candidate)) {
25917
25963
  const hoistedRoot = searchDir;
@@ -25933,22 +25979,29 @@ async function main() {
25933
25979
  const dest = join20(hoistedRoot, name);
25934
25980
  const src = join20(appDir, name);
25935
25981
  if (!existsSync12(dest) && existsSync12(src)) {
25936
- writeFileSync5(dest, readFileSync7(src));
25982
+ writeFileSync5(dest, readFileSync8(src));
25937
25983
  }
25938
25984
  }
25939
25985
  effectiveCwd = hoistedRoot;
25940
25986
  break;
25941
25987
  }
25942
- searchDir = dirname4(searchDir);
25988
+ searchDir = dirname5(searchDir);
25943
25989
  }
25944
25990
  }
25945
25991
  const nextEntrypoint = resolveNextEntrypoint(effectiveCwd);
25946
25992
  const isPrebuilt = existsSync12(join20(effectiveCwd, ".next", "BUILD_ID"));
25993
+ const bindHost = opts.hostname || "127.0.0.1";
25994
+ if (isNonLoopbackHost(bindHost)) {
25995
+ console.warn(
25996
+ `\u26A0 Binding to ${bindHost} \u2014 Relay will be reachable from other machines on the network. It is designed for local-first, single-user use and has no network authentication. Only do this on a trusted network, and put a reverse proxy with auth in front if exposing it more broadly.`
25997
+ );
25998
+ }
25947
25999
  const nextArgs = buildNextLaunchArgs({
25948
26000
  isPrebuilt,
25949
- port: actualPort
26001
+ port: actualPort,
26002
+ host: bindHost
25950
26003
  });
25951
- const sidecarUrl = buildSidecarUrl(actualPort);
26004
+ const sidecarUrl = buildSidecarUrl(actualPort, bindHost);
25952
26005
  console.log(`Orionfold Relay ${pkg.version} \u2014 Community Edition`);
25953
26006
  console.log(`Data dir: ${DATA_DIR}`);
25954
26007
  console.log(`Mode: ${isPrebuilt ? "production" : "development"}`);
@@ -25967,10 +26020,11 @@ async function main() {
25967
26020
  }
25968
26021
  });
25969
26022
  if (opts.open !== false) {
26023
+ const openUrl = isNonLoopbackHost(bindHost) ? buildSidecarUrl(actualPort, "127.0.0.1") : sidecarUrl;
25970
26024
  setTimeout(async () => {
25971
26025
  try {
25972
26026
  const open = (await import("open")).default;
25973
- await open(sidecarUrl);
26027
+ await open(openUrl);
25974
26028
  } catch {
25975
26029
  }
25976
26030
  }, 3e3);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orionfold-relay",
3
- "version": "0.15.1",
3
+ "version": "0.15.3",
4
4
  "description": "Orionfold Relay — a local-first, multi-agent orchestration runtime and builder scaffold for AI-native work.",
5
5
  "keywords": [
6
6
  "ai",
@@ -13,6 +13,7 @@ export async function GET() {
13
13
  name: projects.name,
14
14
  description: projects.description,
15
15
  workingDirectory: projects.workingDirectory,
16
+ customerId: projects.customerId,
16
17
  status: projects.status,
17
18
  createdAt: projects.createdAt,
18
19
  updatedAt: projects.updatedAt,
@@ -42,6 +43,7 @@ export async function POST(req: NextRequest) {
42
43
  name: parsed.data.name,
43
44
  description: parsed.data.description ?? null,
44
45
  workingDirectory: parsed.data.workingDirectory ?? null,
46
+ customerId: parsed.data.customerId ?? null,
45
47
  status: "active",
46
48
  createdAt: now,
47
49
  updatedAt: now,
@@ -13,6 +13,7 @@ export default async function ProjectsPage() {
13
13
  name: projects.name,
14
14
  description: projects.description,
15
15
  workingDirectory: projects.workingDirectory,
16
+ customerId: projects.customerId,
16
17
  status: projects.status,
17
18
  createdAt: projects.createdAt,
18
19
  updatedAt: projects.updatedAt,
@@ -19,7 +19,7 @@ import {
19
19
  SelectTrigger,
20
20
  SelectValue,
21
21
  } from "@/components/ui/select";
22
- import { FolderOpen, AlignLeft, FolderCode, Trash2, Paperclip, Plus, X } from "lucide-react";
22
+ import { FolderOpen, AlignLeft, FolderCode, Trash2, Paperclip, Plus, X, Building2 } from "lucide-react";
23
23
  import { toast } from "sonner";
24
24
  import { Badge } from "@/components/ui/badge";
25
25
  import { ConfirmDialog } from "@/components/shared/confirm-dialog";
@@ -31,9 +31,18 @@ interface Project {
31
31
  name: string;
32
32
  description: string | null;
33
33
  workingDirectory: string | null;
34
+ customerId: string | null;
34
35
  status: string;
35
36
  }
36
37
 
38
+ interface CustomerOption {
39
+ id: string;
40
+ name: string;
41
+ }
42
+
43
+ // Radix Select cannot use an empty-string value; this sentinel maps to null.
44
+ const NO_CUSTOMER = "__none__";
45
+
37
46
  interface ProjectFormSheetProps {
38
47
  mode: "create" | "edit";
39
48
  project?: Project | null;
@@ -53,6 +62,8 @@ export function ProjectFormSheet({
53
62
  const [description, setDescription] = useState("");
54
63
  const [workingDirectory, setWorkingDirectory] = useState("");
55
64
  const [status, setStatus] = useState("active");
65
+ const [customerId, setCustomerId] = useState<string>(NO_CUSTOMER);
66
+ const [customers, setCustomers] = useState<CustomerOption[]>([]);
56
67
  const [loading, setLoading] = useState(false);
57
68
  const [error, setError] = useState<string | null>(null);
58
69
  const [confirmDelete, setConfirmDelete] = useState(false);
@@ -60,6 +71,17 @@ export function ProjectFormSheet({
60
71
  const [selectedDocs, setSelectedDocs] = useState<Array<{ id: string; originalName: string; mimeType: string; size: number }>>([]);
61
72
  const [pickerOpen, setPickerOpen] = useState(false);
62
73
 
74
+ // Load selectable customers whenever the sheet opens
75
+ useEffect(() => {
76
+ if (!open) return;
77
+ fetch("/api/customers")
78
+ .then((r) => r.json())
79
+ .then((rows: Array<{ id: string; name: string }>) => {
80
+ setCustomers(rows.map((c) => ({ id: c.id, name: c.name })));
81
+ })
82
+ .catch(() => setCustomers([]));
83
+ }, [open]);
84
+
63
85
  // Pre-fill form in edit mode
64
86
  useEffect(() => {
65
87
  if (mode === "edit" && project) {
@@ -67,6 +89,7 @@ export function ProjectFormSheet({
67
89
  setDescription(project.description ?? "");
68
90
  setWorkingDirectory(project.workingDirectory ?? "");
69
91
  setStatus(project.status);
92
+ setCustomerId(project.customerId ?? NO_CUSTOMER);
70
93
  // Load existing default documents
71
94
  fetch(`/api/projects/${project.id}/documents`)
72
95
  .then((r) => r.json())
@@ -91,6 +114,7 @@ export function ProjectFormSheet({
91
114
  setDescription("");
92
115
  setWorkingDirectory("");
93
116
  setStatus("active");
117
+ setCustomerId(NO_CUSTOMER);
94
118
  setSelectedDocIds(new Set());
95
119
  setSelectedDocs([]);
96
120
  }
@@ -120,6 +144,7 @@ export function ProjectFormSheet({
120
144
  name: name.trim(),
121
145
  description: description.trim() || undefined,
122
146
  workingDirectory: workingDirectory.trim() || undefined,
147
+ customerId: customerId === NO_CUSTOMER ? null : customerId,
123
148
  documentIds: selectedDocIds.size > 0 ? [...selectedDocIds] : undefined,
124
149
  }),
125
150
  });
@@ -140,6 +165,7 @@ export function ProjectFormSheet({
140
165
  description: description.trim() || undefined,
141
166
  workingDirectory: workingDirectory.trim() || undefined,
142
167
  status,
168
+ customerId: customerId === NO_CUSTOMER ? null : customerId,
143
169
  documentIds: [...selectedDocIds],
144
170
  }),
145
171
  });
@@ -241,6 +267,33 @@ export function ProjectFormSheet({
241
267
  </p>
242
268
  </div>
243
269
 
270
+ <div className="space-y-2">
271
+ <Label htmlFor="proj-customer" className="flex items-center gap-1.5">
272
+ <Building2 className="h-3.5 w-3.5 text-muted-foreground" />
273
+ Customer
274
+ </Label>
275
+ <Select value={customerId} onValueChange={setCustomerId}>
276
+ <SelectTrigger id="proj-customer">
277
+ <SelectValue placeholder="No customer" />
278
+ </SelectTrigger>
279
+ <SelectContent>
280
+ <SelectItem value={NO_CUSTOMER}>
281
+ <span className="text-muted-foreground">No customer</span>
282
+ </SelectItem>
283
+ {customers.map((c) => (
284
+ <SelectItem key={c.id} value={c.id}>
285
+ {c.name}
286
+ </SelectItem>
287
+ ))}
288
+ </SelectContent>
289
+ </Select>
290
+ <p className="text-xs text-muted-foreground">
291
+ {customers.length === 0
292
+ ? "No customers yet — create one to attribute this project's AI spend."
293
+ : "Link to a customer so this project's AI spend rolls up per customer."}
294
+ </p>
295
+ </div>
296
+
244
297
  {isEdit && (
245
298
  <div className="space-y-2">
246
299
  <Label htmlFor="proj-status">Status</Label>
@@ -14,6 +14,7 @@ interface Project {
14
14
  name: string;
15
15
  description: string | null;
16
16
  workingDirectory: string | null;
17
+ customerId: string | null;
17
18
  status: string;
18
19
  taskCount: number;
19
20
  docCount: number;
@@ -3,6 +3,22 @@ export async function registerNodeInstrumentation() {
3
3
  const { migrateLegacyData } = await import("@/lib/utils/migrate-to-ainative");
4
4
  await migrateLegacyData();
5
5
 
6
+ // ainative→relay hop: the runtime now publishes chat/compose tools as
7
+ // mcp__relay__* (engine.ts server key), so previously-saved allow-lists and
8
+ // "Always Allow" records — matched by exact string — must be rewritten too.
9
+ // Runs against the live DB; idempotent, never throws.
10
+ const { migrateMcpNamespace } = await import("@/lib/utils/migrate-mcp-namespace");
11
+ const { sqlite } = await import("@/lib/db");
12
+ const nsReport = migrateMcpNamespace(sqlite);
13
+ if (nsReport.profilesUpdated > 0 || nsReport.permissionsUpdated > 0) {
14
+ console.log(
15
+ `[migrate] mcp namespace ainative→relay: ${nsReport.profilesUpdated} profile(s), ${nsReport.permissionsUpdated} permission set(s)`,
16
+ );
17
+ }
18
+ for (const err of nsReport.errors) {
19
+ console.error(`[migrate] mcp namespace: ${err}`);
20
+ }
21
+
6
22
  // Instance bootstrap — creates local branch, handles dev-mode gates, consent flow.
7
23
  // Runs BEFORE other startup so instance config is available downstream.
8
24
  // Safe in the canonical Relay dev repo thanks to RELAY_DEV_MODE=true
@@ -505,7 +505,10 @@ export async function* sendMessage(
505
505
  // Keep only last 50 chunks to avoid unbounded memory
506
506
  if (stderrChunks.length > 50) stderrChunks.shift();
507
507
  },
508
- mcpServers: { ainative: ainativeServer, ...browserServers, ...externalServers },
508
+ // Server key = tool namespace: the Agent SDK derives `mcp__<key>__*`
509
+ // from THIS map key. Must be `relay` so published tools match the
510
+ // allow-list (:510) and the auto-allow gate (:533) below.
511
+ mcpServers: { relay: ainativeServer, ...browserServers, ...externalServers },
509
512
  allowedTools: [
510
513
  "mcp__relay__*",
511
514
  ...browserToolPatterns,
@@ -83,3 +83,21 @@ export function buildNextLaunchArgs({
83
83
  export function buildSidecarUrl(port: number, host = SIDECAR_LOOPBACK_HOST): string {
84
84
  return `http://${host}:${port}`;
85
85
  }
86
+
87
+ /**
88
+ * True when `host` is NOT a loopback address — i.e. binding to it exposes the
89
+ * server beyond the local machine. Relay is local-first with light auth, so the
90
+ * CLI warns before binding to such a host (`--hostname 0.0.0.0`, a LAN IP, a
91
+ * hostname, etc.). This is an advisory gate, not access control, so it errs
92
+ * toward warning: anything not provably loopback returns true.
93
+ *
94
+ * Loopback = `localhost`, IPv6 `::1`, or any `127.0.0.0/8` address (Linux lets
95
+ * you bind e.g. `127.0.0.5`). `0.0.0.0` / `::` are INADDR_ANY ("all
96
+ * interfaces") — the most exposing choice — and are treated as non-loopback.
97
+ */
98
+ export function isNonLoopbackHost(host: string): boolean {
99
+ const h = host.trim().toLowerCase();
100
+ if (h === "localhost" || h === "::1") return false;
101
+ if (/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(h)) return false;
102
+ return true;
103
+ }
@@ -19,14 +19,28 @@ import {
19
19
  type ResolvedPackFile,
20
20
  } from "./format";
21
21
 
22
- // The current relay-core version. Sourced from package.json at module load.
23
- // Kept here (not inlined) so the compat gate has a single point of truth.
22
+ // Compile-time core version, embedded by tsup's `define` (see tsup.config.ts).
23
+ // Present ONLY in the bundled CLI; `undefined` in dev/test/Next.js builds,
24
+ // where the runtime lookup below takes over. Declared as a global so the
25
+ // reference type-checks in the non-bundled builds where it doesn't exist.
26
+ declare const __RELAY_CORE_VERSION__: string | undefined;
27
+
28
+ // The current relay-core version. Kept here (not inlined) so the compat gate
29
+ // has a single point of truth.
24
30
  function relayCoreVersion(): string {
25
- // Resolve the app root via getAppRoot (NOT process.cwd() under npx that is
26
- // the user's launch dir, not the app, which would read the wrong
27
- // package.json; see npx-process-cwd.test.ts). install.ts lives at
28
- // src/lib/packs/, so the app root is 3 levels up from this file's dir
29
- // same depth ainative-paths.ts uses.
31
+ // 1. Bundle path: the version baked in at build time. This eliminates the
32
+ // runtime package.json lookup entirely in the shipped CLI the source of
33
+ // the "0.0.0" bug, where the flattened dist/ layout broke the depth-based
34
+ // getAppRoot resolution and fell back to the user's launch dir.
35
+ if (typeof __RELAY_CORE_VERSION__ === "string" && semver.valid(__RELAY_CORE_VERSION__)) {
36
+ return __RELAY_CORE_VERSION__;
37
+ }
38
+
39
+ // 2. Dev/test/Next.js path: resolve the app root via getAppRoot (NOT
40
+ // process.cwd() — under npx that is the user's launch dir; see
41
+ // npx-process-cwd.test.ts) and read package.json. getAppRoot is now
42
+ // bundle-aware (walks up to the orionfold-relay package.json), so this
43
+ // path is also correct in the bundle even if the define is ever dropped.
30
44
  try {
31
45
  const root = getAppRoot(import.meta.dirname, 3);
32
46
  const pkg = JSON.parse(
@@ -1,11 +1,30 @@
1
- import { existsSync } from "node:fs";
2
- import { join } from "node:path";
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join, dirname } from "node:path";
3
+
4
+ const PKG_NAME = "orionfold-relay";
3
5
 
4
6
  /**
5
7
  * Resolve the app root directory.
6
8
  * - import.meta.dirname works under npx (real path to installed package)
7
9
  * - Turbopack compiles it to /ROOT/... (virtual, doesn't exist) → fall back to process.cwd()
8
10
  *
11
+ * Two layouts must resolve correctly:
12
+ * 1. Source tree — the `depth` a caller passes lands exactly on the repo
13
+ * root (e.g. src/lib/packs/ → depth 3). This is the fast path and stays
14
+ * byte-identical to the original behavior.
15
+ * 2. Bundled dist/cli.js — tsup flattens every module into one file, so
16
+ * `import.meta.dirname` is `dist/` for ALL callers regardless of the
17
+ * depth they pass (a source-tree assumption). Depth then overshoots and
18
+ * the depth candidate misses. Rather than fix depth math at every call
19
+ * site (fragile — 5 sites, different depths), we walk UP from the caller
20
+ * until we find the `orionfold-relay` package.json. This makes the passed
21
+ * `depth` a hint, not a hard requirement, so all call sites resolve in the
22
+ * bundle without per-site changes.
23
+ *
24
+ * The upward walk verifies `name === "orionfold-relay"` (not just any
25
+ * package.json) so that under npx it anchors to OUR package, never a foreign
26
+ * package.json in the user's launch dir.
27
+ *
9
28
  * Uses static `node:` built-in imports. These resolve natively in every
10
29
  * server context that consumes this helper — Next.js server modules, the
11
30
  * instrumentation hook, and the tsup ESM CLI bundle. (An earlier version used
@@ -17,8 +36,30 @@ import { join } from "node:path";
17
36
  */
18
37
  export function getAppRoot(metaDirname: string | undefined, depth: number): string {
19
38
  if (metaDirname) {
39
+ // Fast path — source tree: the passed depth lands on the package root.
20
40
  const candidate = join(metaDirname, ...Array(depth).fill(".."));
21
- if (existsSync(join(candidate, "package.json"))) return candidate;
41
+ if (isPackageRoot(candidate)) return candidate;
42
+
43
+ // Bundle fallback: walk up until we hit the orionfold-relay package root.
44
+ let dir = metaDirname;
45
+ while (true) {
46
+ if (isPackageRoot(dir)) return dir;
47
+ const parent = dirname(dir);
48
+ if (parent === dir) break; // reached filesystem root
49
+ dir = parent;
50
+ }
22
51
  }
23
52
  return process.cwd();
24
53
  }
54
+
55
+ /** True if `dir` holds the orionfold-relay package.json. */
56
+ function isPackageRoot(dir: string): boolean {
57
+ const pkgPath = join(dir, "package.json");
58
+ if (!existsSync(pkgPath)) return false;
59
+ try {
60
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as { name?: string };
61
+ return pkg.name === PKG_NAME;
62
+ } catch {
63
+ return false;
64
+ }
65
+ }
@@ -0,0 +1,94 @@
1
+ import type Database from "better-sqlite3";
2
+
3
+ const OLD_PREFIX = "mcp__ainative__";
4
+ const NEW_PREFIX = "mcp__relay__";
5
+
6
+ export interface McpNamespaceMigrationReport {
7
+ /** Rows in agent_profiles whose allowed_tools were rewritten. */
8
+ profilesUpdated: number;
9
+ /** 1 if the permissions.allow settings row was rewritten, else 0. */
10
+ permissionsUpdated: number;
11
+ errors: string[];
12
+ }
13
+
14
+ /**
15
+ * Rewrites the chat/compose MCP tool namespace from the legacy `mcp__ainative__`
16
+ * prefix to `mcp__relay__` in persisted data. Companion to the `engine.ts`
17
+ * server-key flip: that change makes the runtime *publish* tools as
18
+ * `mcp__relay__*`; this migration makes previously-saved allow-lists and
19
+ * "Always Allow" records match the new names, which are compared by exact
20
+ * string (see `permissions.ts:matchesPermission`).
21
+ *
22
+ * Backing stores rewritten:
23
+ * 1. `settings` row `key='permissions.allow'` — a JSON array stored as a
24
+ * string. This is the store that actually carries the namespace today
25
+ * (saved "Always Allow" records). A string-level REPLACE is safe: it
26
+ * rewrites the substring inside the serialized array without parsing it.
27
+ * 2. `agent_profiles.allowed_tools` — a JSON array column. NOTE: in current
28
+ * Relay, profiles are file-based (profile.yaml on disk) and there is no
29
+ * `agent_profiles` table, so this branch is a defensive no-op (guarded by
30
+ * a table-exists check). It is kept so that if a DB-backed profile store
31
+ * is ever reintroduced, the namespace hop is already covered. Shipped
32
+ * profile files carry no `mcp__ainative__` strings (verified), so no file
33
+ * rewrite is needed.
34
+ *
35
+ * Idempotent (a second run matches nothing) and never throws — a missing
36
+ * table (fresh/partial schema) is an expected no-op, not an error. Any real
37
+ * SQL failure is collected in `report.errors` so boot continues visibly.
38
+ *
39
+ * Takes the DB handle explicitly so it can be unit-tested against an in-memory
40
+ * database; production passes the live `sqlite` export.
41
+ */
42
+ export function migrateMcpNamespace(
43
+ db: Database.Database,
44
+ ): McpNamespaceMigrationReport {
45
+ const report: McpNamespaceMigrationReport = {
46
+ profilesUpdated: 0,
47
+ permissionsUpdated: 0,
48
+ errors: [],
49
+ };
50
+
51
+ if (!tableExists(db, "agent_profiles")) {
52
+ // Fresh install — nothing to migrate.
53
+ } else {
54
+ try {
55
+ const r = db
56
+ .prepare(
57
+ `UPDATE agent_profiles
58
+ SET allowed_tools = REPLACE(allowed_tools, ?, ?)
59
+ WHERE allowed_tools LIKE '%' || ? || '%'`,
60
+ )
61
+ .run(OLD_PREFIX, NEW_PREFIX, OLD_PREFIX);
62
+ report.profilesUpdated = r.changes;
63
+ } catch (err) {
64
+ // allowed_tools column may be absent in an older schema — record, don't crash.
65
+ report.errors.push(`agent_profiles rewrite failed: ${String(err)}`);
66
+ }
67
+ }
68
+
69
+ if (!tableExists(db, "settings")) {
70
+ // Fresh install — no saved permissions yet.
71
+ } else {
72
+ try {
73
+ const r = db
74
+ .prepare(
75
+ `UPDATE settings
76
+ SET value = REPLACE(value, ?, ?)
77
+ WHERE key = 'permissions.allow' AND value LIKE '%' || ? || '%'`,
78
+ )
79
+ .run(OLD_PREFIX, NEW_PREFIX, OLD_PREFIX);
80
+ report.permissionsUpdated = r.changes;
81
+ } catch (err) {
82
+ report.errors.push(`settings permissions.allow rewrite failed: ${String(err)}`);
83
+ }
84
+ }
85
+
86
+ return report;
87
+ }
88
+
89
+ function tableExists(db: Database.Database, name: string): boolean {
90
+ const row = db
91
+ .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = ?")
92
+ .get(name);
93
+ return row !== undefined;
94
+ }
@@ -4,6 +4,8 @@ export const createProjectSchema = z.object({
4
4
  name: z.string().min(1, "Name is required").max(100),
5
5
  description: z.string().max(500).optional(),
6
6
  workingDirectory: z.string().max(500).optional(),
7
+ // FK to customers.id; null/absent = unlinked. Attributes AI spend to a customer.
8
+ customerId: z.string().min(1).nullish(),
7
9
  });
8
10
 
9
11
  export const updateProjectSchema = z.object({
@@ -11,6 +13,8 @@ export const updateProjectSchema = z.object({
11
13
  description: z.string().max(500).optional(),
12
14
  workingDirectory: z.string().max(500).optional(),
13
15
  status: z.enum(["active", "paused", "completed"]).optional(),
16
+ // FK to customers.id; null clears the link, absent leaves it unchanged.
17
+ customerId: z.string().min(1).nullish(),
14
18
  });
15
19
 
16
20
  export type CreateProjectInput = z.infer<typeof createProjectSchema>;