Skip to content

Commit a14b7d7

Browse files
committed
fix(contract): rename local-config, which a global gitignore pattern was silently dropping
The module was never committed. `local-config.*` is a common global-gitignore pattern, so every commit silently omitted the file while the local tree built fine — CI then failed on a module that did not exist in the repo. The clean-clone reproduction is what found it: dist/ had orb-broker and public-api but no local-config, because the source was not there. Renamed to cli-config, which no common pattern matches, with a note not to rename it back. Also hoists the contract build above the drift checks, which import the tool registry.
1 parent 40a2667 commit a14b7d7

6 files changed

Lines changed: 148 additions & 15 deletions

File tree

.github/workflows/ci.yml

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,14 @@ jobs:
352352
# .loopover.yml.example, a dependency the other two generated-artifact-drift checks don't share, so
353353
# deliberately NOT kept uniform with them here (test/unit/ci-generated-artifact-drift-checks.test.ts
354354
# asserts backend||ui is a SUBSET of every condition, not that all three are byte-identical).
355+
# HOISTED above the drift checks (#9521): docs:drift-check imports the tool registry from
356+
# @loopover/contract, so it needs the contract's dist before line-~500 where this step used to sit.
357+
# Unconditional rather than gated on a `contract` path filter: every consumer of it (backend, mcp,
358+
# miner, ui, and now the drift checks) can pull it into scope, and building a zod-only leaf package
359+
# with no dependencies of its own is cheap enough that gating it would buy nothing but a class of
360+
# skipped-build failures.
361+
- name: Build contract package
362+
run: npx turbo run build --filter=@loopover/contract
355363
- name: Docs drift check
356364
if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' || needs.changes.outputs.ui == 'true' || needs.changes.outputs.engine == 'true' }}
357365
run: npm run docs:drift-check
@@ -494,14 +502,8 @@ jobs:
494502
key: turbo-code-${{ hashFiles('package-lock.json') }}-${{ github.run_id }}
495503
restore-keys: |
496504
turbo-code-${{ hashFiles('package-lock.json') }}-
497-
# @loopover/contract's "types" resolve to packages/loopover-contract/dist/index.d.ts, and src/ +
498-
# packages/loopover-mcp both import it -- so like the engine below, typecheck cannot run until it
499-
# has been built. Unconditional rather than gated on a `contract` path filter: every consumer of it
500-
# (backend, mcp, miner, ui) can pull it into the typecheck surface, and building a zod-only leaf
501-
# package with no dependencies of its own is cheap enough that gating it would buy nothing but a
502-
# class of skipped-build failures.
503-
- name: Build contract package
504-
run: npx turbo run build --filter=@loopover/contract
505+
# ("Build contract package" now runs earlier in this job, above the drift checks -- see its own
506+
# comment there. Typecheck below still depends on it, same as before, just built sooner.)
505507
# mcp/miner are in this gate because "Typecheck" below already runs for them, and typecheck's real
506508
# surface reaches @loopover/engine -- test/** imports it directly, and its "types" resolve to
507509
# packages/loopover-engine/dist/index.d.ts, which only exists once this step has run. An mcp-only or

packages/loopover-contract/package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,9 @@
4444
"default": "./dist/agent-specs.js"
4545
},
4646
"./package.json": "./package.json",
47-
"./local-config": {
48-
"types": "./dist/local-config.d.ts",
49-
"default": "./dist/local-config.js"
47+
"./cli-config": {
48+
"types": "./dist/cli-config.d.ts",
49+
"default": "./dist/cli-config.js"
5050
},
5151
"./orb-broker": {
5252
"types": "./dist/orb-broker.d.ts",
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
/**
2+
* The on-disk loopover CLI config, resolved the same way by every bin that reads it (#9521).
3+
*
4+
* `@loopover/mcp` owns this file (it is what `loopover-mcp login` writes) and `@loopover/miner` reads
5+
* it, but the two are separately-installable CLIs on purpose -- installing AMS must not drag in the
6+
* MCP wrapper just to parse a config format. So the miner hand-copied the resolution and said so in a
7+
* header comment ("kept in sync by hand -- there is no shared module to import"). This is that module:
8+
* both packages already depend on @loopover/contract, so it is the one home both can reach.
9+
*
10+
* Everything here is PURE -- no node: imports, no I/O. That is not incidental: this package's tsconfig
11+
* sets `"types": []` precisely so a node builtin cannot compile here and then fail later in the
12+
* Cloudflare bundle, and the root entry is imported by the Worker. So the caller does the reading
13+
* (`existsSync`/`readFileSync`, `homedir`, `join`) and this module owns the POLICY -- the path
14+
* template, the name pattern, the API-URL precedence, the constants. Those are what actually drifted
15+
* between the two copies; the four lines of fs around them never did.
16+
*
17+
* Scope is the READ side only. Writing the config (profile create/switch/remove, redaction,
18+
* persistence) stays in `@loopover/mcp`, which is the only package that writes it.
19+
*
20+
* Named `cli-config`, NOT `local-config`: a common global-gitignore pattern (`local-config.*`) matches the
21+
* latter, and this file's first incarnation was silently dropped from every commit by exactly that -- the
22+
* repo built locally, and CI failed on a module that had never been committed. Do not rename it back.
23+
*/
24+
25+
export const DEFAULT_LOOPOVER_API_URL = "https://api.loopover.ai";
26+
27+
/**
28+
* API URLs that used to be the shipped default. A config still naming one is a stale artifact of an
29+
* older install, not a deliberate override, so resolution SKIPS them rather than honoring them.
30+
*/
31+
export const LEGACY_LOOPOVER_API_URLS: ReadonlySet<string> = new Set([
32+
"https://gittensory-api.zeronode.workers.dev",
33+
"https://gittensory-api.aethereal.dev",
34+
]);
35+
36+
export const DEFAULT_PROFILE_NAME = "default";
37+
38+
/** 1-64 chars, starting alphanumeric. The same pattern both bins validated against by hand. */
39+
export const PROFILE_NAME_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/;
40+
41+
export type LoopoverConfigProfile = {
42+
apiUrl?: unknown;
43+
session?: { token?: unknown } | null | undefined;
44+
};
45+
46+
export type LoopoverConfig = {
47+
activeProfile?: unknown;
48+
profiles?: Record<string, LoopoverConfigProfile | undefined>;
49+
apiUrl?: unknown;
50+
};
51+
52+
/**
53+
* The environment reads that steer config location. A plain object so no caller needs `process` --
54+
* and the index signature is what lets a caller pass `process.env` straight in (without it TS's
55+
* weak-type check rejects ProcessEnv, whose own properties are all index-signature entries).
56+
*/
57+
export type LoopoverConfigEnv = {
58+
readonly LOOPOVER_CONFIG_PATH?: string | undefined;
59+
readonly LOOPOVER_CONFIG_DIR?: string | undefined;
60+
readonly XDG_CONFIG_HOME?: string | undefined;
61+
readonly LOOPOVER_API_URL?: string | undefined;
62+
readonly [key: string]: string | undefined;
63+
};
64+
65+
/**
66+
* Where the config lives: LOOPOVER_CONFIG_PATH wins outright; else LOOPOVER_CONFIG_DIR/config.json;
67+
* else the XDG location under the home directory.
68+
*
69+
* `join` and `homeDir` are injected rather than imported so this stays free of node:path/node:os --
70+
* callers pass node's own, which keeps Windows separators correct.
71+
*/
72+
export function loopoverConfigPath(
73+
env: LoopoverConfigEnv,
74+
deps: { join: (...segments: string[]) => string; homeDir: () => string },
75+
): string {
76+
if (env.LOOPOVER_CONFIG_PATH) return env.LOOPOVER_CONFIG_PATH;
77+
if (env.LOOPOVER_CONFIG_DIR) return deps.join(env.LOOPOVER_CONFIG_DIR, "config.json");
78+
return deps.join(env.XDG_CONFIG_HOME || deps.join(deps.homeDir(), ".config"), "loopover", "config.json");
79+
}
80+
81+
/**
82+
* The config a raw file body describes, or `{}` for any reason it cannot be understood (absent file,
83+
* malformed JSON, or a non-object top level). Never throws and never reports WHY: a missing config is
84+
* the normal state for a fresh install, and the failure paths must not leak the path or its contents.
85+
*/
86+
export function parseLoopoverConfig(body: string | null | undefined): LoopoverConfig {
87+
if (!body) return {};
88+
try {
89+
const parsed: unknown = JSON.parse(body);
90+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as LoopoverConfig) : {};
91+
} catch {
92+
return {};
93+
}
94+
}
95+
96+
/** The canonical (trimmed, lowercased) spelling of a profile name, or null when it is not a legal one. */
97+
export function canonicalProfileName(value: unknown): string | null {
98+
const name = String(value ?? "").trim().toLowerCase();
99+
return PROFILE_NAME_PATTERN.test(name) ? name : null;
100+
}
101+
102+
/** The session token recorded for a profile, or null when that profile has never logged in. */
103+
export function profileSessionToken(profile: LoopoverConfigProfile | undefined): string | null {
104+
const token = profile?.session?.token;
105+
return typeof token === "string" && token ? token : null;
106+
}
107+
108+
/**
109+
* The API URL to call: LOOPOVER_API_URL, else the active profile's apiUrl, else the config's top-level
110+
* apiUrl, else the default -- skipping any LEGACY_LOOPOVER_API_URLS entry at every step.
111+
*
112+
* The fall-THROUGH matters and is the behavior the two copies had drifted on (#9521). @loopover/mcp
113+
* picked the profile apiUrl if present and fell straight to the default when it was legacy, so a stale
114+
* profile URL masked a perfectly good top-level override; @loopover/miner kept looking (#8854). The
115+
* miner's is correct -- a legacy value means "ignore this one," not "stop looking" -- so it is what
116+
* this shared resolver does for both.
117+
*/
118+
export function resolveLoopoverApiUrl(
119+
env: LoopoverConfigEnv,
120+
config: LoopoverConfig,
121+
profile: LoopoverConfigProfile | undefined,
122+
): string {
123+
if (env.LOOPOVER_API_URL) return env.LOOPOVER_API_URL.replace(/\/+$/, "");
124+
for (const candidate of [profile?.apiUrl, config.apiUrl]) {
125+
if (typeof candidate === "string" && candidate.trim()) {
126+
const normalized = candidate.replace(/\/+$/, "");
127+
if (!LEGACY_LOOPOVER_API_URLS.has(normalized)) return normalized;
128+
}
129+
}
130+
return DEFAULT_LOOPOVER_API_URL;
131+
}

packages/loopover-miner/lib/github-token-resolution.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
//
66
// The config/profile/apiUrl/session resolution this needs used to be hand-copied from loopover-mcp's own
77
// bin, because @loopover/miner and @loopover/mcp are separately-installable CLIs and neither publishes the
8-
// config format as a stable API. It now imports @loopover/contract/local-config (#9521), which both
8+
// config format as a stable API. It now imports @loopover/contract/cli-config (#9521), which both
99
// packages already depend on -- so the hand-sync, and the drift it invited, are gone.
1010
import { existsSync, readFileSync } from "node:fs";
1111
import { homedir } from "node:os";
@@ -19,7 +19,7 @@ import {
1919
resolveLoopoverApiUrl,
2020
type LoopoverConfig,
2121
type LoopoverConfigProfile,
22-
} from "@loopover/contract/local-config";
22+
} from "@loopover/contract/cli-config";
2323

2424
// A narrower shape than `typeof fetch` on purpose: this module only ever calls it with a string URL and a
2525
// plain init object, and the ambient `fetch` type in this repo's TS program is Cloudflare-Workers-flavored
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
parseLoopoverConfig,
88
profileSessionToken,
99
resolveLoopoverApiUrl,
10-
} from "@loopover/contract/local-config";
10+
} from "@loopover/contract/cli-config";
1111

1212
// #9521: @loopover/mcp writes this config and @loopover/miner reads it, and the miner used to
1313
// hand-copy the resolution ("kept in sync by hand -- there is no shared module to import"). These

vitest.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export default defineConfig({
2828
// "<index.ts path>/tools" that resolved nowhere. Confirmed by reproducing the failure with a
2929
// throwaway probe test before reordering, not assumed from reading Vite's docs alone.
3030
"@loopover/contract/tools": new URL("./packages/loopover-contract/src/tools/index.ts", import.meta.url).pathname,
31-
"@loopover/contract/local-config": new URL("./packages/loopover-contract/src/local-config.ts", import.meta.url).pathname,
31+
"@loopover/contract/cli-config": new URL("./packages/loopover-contract/src/cli-config.ts", import.meta.url).pathname,
3232
"@loopover/contract/orb-broker": new URL("./packages/loopover-contract/src/orb-broker.ts", import.meta.url).pathname,
3333
"@loopover/contract/api-schemas": new URL("./packages/loopover-contract/src/api-schemas.ts", import.meta.url).pathname,
3434
"@loopover/contract/public-api": new URL("./packages/loopover-contract/src/public-api.ts", import.meta.url).pathname,

0 commit comments

Comments
 (0)