@hystudio/dsh-quota-pill 0.2.6 → 0.2.11
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/AGENTS.md +188 -20
- package/README.md +85 -17
- package/bin/opendesign-login.js +80 -0
- package/client.js +148 -21
- package/docs/screenshot.png +0 -0
- package/index.js +12 -4
- package/package.json +5 -3
- package/src/adapters-apikey.js +45 -8
- package/src/adapters-cliproxy.js +13 -2
- package/src/adapters-opendesign.js +199 -57
- package/src/autostart.js +64 -0
- package/src/credentials.js +60 -9
- package/src/http.js +45 -0
- package/src/opendesign-auth.js +95 -0
- package/src/providers.js +112 -0
- package/src/server.js +71 -55
- package/src/user-adapters.js +172 -0
package/src/providers.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adapter keys ↔ DSH provider/model ids.
|
|
3
|
+
*
|
|
4
|
+
* Two different namespaces meet here:
|
|
5
|
+
* · DSH names providers after the upstream or the user's provider config
|
|
6
|
+
* (`grok`, `commandcode-goat`, `opendesign-go`, `deepseek-account`,
|
|
7
|
+
* `opencode-go`), and models carry that id as their prefix;
|
|
8
|
+
* · adapters in this package use short historical keys (`xai`, `commandcode`,
|
|
9
|
+
* `opendesign`, `deepseek`, `opencode`).
|
|
10
|
+
*
|
|
11
|
+
* A missing alias is invisible in the worst way: the provider is configured and
|
|
12
|
+
* readable, but the catalog filter drops it before any adapter runs. Keep these
|
|
13
|
+
* maps in one place and test them against a real catalog shape.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Which DSH model prefixes map to which adapter. */
|
|
17
|
+
export const PREFIX_ALIASES = {
|
|
18
|
+
'kimi-coding': 'kimi',
|
|
19
|
+
kimi: 'kimi',
|
|
20
|
+
'opencode-go': 'opencode',
|
|
21
|
+
opencode: 'opencode',
|
|
22
|
+
commandcode: 'commandcode',
|
|
23
|
+
'command-code': 'commandcode',
|
|
24
|
+
'commandcode-goat': 'commandcode',
|
|
25
|
+
xai: 'xai',
|
|
26
|
+
grok: 'xai',
|
|
27
|
+
codex: 'codex',
|
|
28
|
+
antigravity: 'agy',
|
|
29
|
+
agy: 'agy',
|
|
30
|
+
deepseek: 'deepseek',
|
|
31
|
+
'deepseek-account': 'deepseek',
|
|
32
|
+
opendesign: 'opendesign',
|
|
33
|
+
'opendesign-go': 'opendesign',
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Which DSH catalog ids count as "this adapter".
|
|
38
|
+
*
|
|
39
|
+
* DSH names providers after the upstream (`antigravity`, `deepseek-account`,
|
|
40
|
+
* `opencode-go`); the adapters use the short keys the pill has always used.
|
|
41
|
+
*/
|
|
42
|
+
export const CATALOG_ALIASES = {
|
|
43
|
+
agy: ['antigravity', 'agy'],
|
|
44
|
+
deepseek: ['deepseek-account', 'deepseek'],
|
|
45
|
+
opencode: ['opencode-go', 'opencode'],
|
|
46
|
+
kimi: ['kimi-coding', 'kimi'],
|
|
47
|
+
// DSH ids follow the upstream/provider config name, not the short adapter
|
|
48
|
+
// key. Missing one of these hides a provider the user actually configured:
|
|
49
|
+
// grok (xAI), commandcode-goat (Command Code), opendesign-go (Open Design).
|
|
50
|
+
xai: ['xai', 'grok'],
|
|
51
|
+
codex: ['codex'],
|
|
52
|
+
commandcode: ['commandcode', 'commandcode-goat', 'command-code'],
|
|
53
|
+
opendesign: ['opendesign', 'opendesign-go'],
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Providers contributed by the user's own adapters (~/.dsh/quota-pill/adapters).
|
|
58
|
+
* Kept separate from the built-in maps so a package update can re-register them
|
|
59
|
+
* from disk without ever rewriting the user's files.
|
|
60
|
+
*/
|
|
61
|
+
export const USER_PREFIX_ALIASES = {};
|
|
62
|
+
export const USER_CATALOG_ALIASES = {};
|
|
63
|
+
export const USER_DISPLAY_NAMES = {};
|
|
64
|
+
|
|
65
|
+
export function clearUserProviders() {
|
|
66
|
+
for (const key of Object.keys(USER_PREFIX_ALIASES)) delete USER_PREFIX_ALIASES[key];
|
|
67
|
+
for (const key of Object.keys(USER_CATALOG_ALIASES)) delete USER_CATALOG_ALIASES[key];
|
|
68
|
+
for (const key of Object.keys(USER_DISPLAY_NAMES)) delete USER_DISPLAY_NAMES[key];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Register one user adapter's ids/name. Called on every adapter-dir reload. */
|
|
72
|
+
export function registerUserProvider({ provider, catalogIds = [], prefixes = [], displayName } = {}) {
|
|
73
|
+
if (!provider) return;
|
|
74
|
+
const key = String(provider);
|
|
75
|
+
const ids = new Set([key, ...(Array.isArray(catalogIds) ? catalogIds : []), ...(Array.isArray(prefixes) ? prefixes : [])]);
|
|
76
|
+
for (const id of ids) {
|
|
77
|
+
if (!id) continue;
|
|
78
|
+
USER_PREFIX_ALIASES[String(id).toLowerCase()] = key;
|
|
79
|
+
}
|
|
80
|
+
USER_CATALOG_ALIASES[key] = Array.from(new Set([key, ...(USER_CATALOG_ALIASES[key] || []), ...(Array.isArray(catalogIds) ? catalogIds : [])]));
|
|
81
|
+
if (displayName) USER_DISPLAY_NAMES[key] = String(displayName);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function providerForModel(model) {
|
|
85
|
+
const m = String(model || '').toLowerCase();
|
|
86
|
+
const prefix = m.split('/')[0];
|
|
87
|
+
// User adapters win: their catalog id is exactly what DSH uses for the model
|
|
88
|
+
// prefix, and the user added the adapter specifically for it.
|
|
89
|
+
if (USER_PREFIX_ALIASES[prefix]) return USER_PREFIX_ALIASES[prefix];
|
|
90
|
+
if (PREFIX_ALIASES[prefix]) return PREFIX_ALIASES[prefix];
|
|
91
|
+
// Fuzzy fallbacks for models whose prefix is not the provider id.
|
|
92
|
+
if (m.includes('grok')) return 'xai';
|
|
93
|
+
if (m.includes('codex') || m.startsWith('gpt-')) return 'codex';
|
|
94
|
+
if (m.includes('gemini')) return 'agy';
|
|
95
|
+
if (m.startsWith('k3') || m.startsWith('kimi')) return 'kimi';
|
|
96
|
+
return '';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function inCatalog(provider, ids) {
|
|
100
|
+
if (!ids) return true; // catalog unknown: do not hide anything
|
|
101
|
+
const names = [
|
|
102
|
+
...(CATALOG_ALIASES[provider] || []),
|
|
103
|
+
...(USER_CATALOG_ALIASES[provider] || []),
|
|
104
|
+
];
|
|
105
|
+
if (!names.length) names.push(provider);
|
|
106
|
+
return names.some((n) => ids.includes(n));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Display name for user adapters (built-ins keep their server-side table). */
|
|
110
|
+
export function userDisplayName(provider) {
|
|
111
|
+
return USER_DISPLAY_NAMES[provider] || null;
|
|
112
|
+
}
|
package/src/server.js
CHANGED
|
@@ -19,14 +19,19 @@
|
|
|
19
19
|
* Every response includes `cached` and `at` so the client can tell a live
|
|
20
20
|
* reading from a cached one.
|
|
21
21
|
*/
|
|
22
|
+
import fs from 'node:fs';
|
|
22
23
|
import http from 'node:http';
|
|
23
24
|
import { kimi, opencode, commandcode } from './adapters-apikey.js';
|
|
24
25
|
import { xai, codex, agy } from './adapters-cliproxy.js';
|
|
25
26
|
import { deepseek } from './adapters-deepseek.js';
|
|
26
27
|
import { credentialSource, hasCliProxy, CLI_PROXY_DIR, CREDENTIALS_FILE } from './credentials.js';
|
|
27
|
-
import {
|
|
28
|
+
import { loadUserAdapters, adapterContext, userAdaptersInfo } from './user-adapters.js';
|
|
29
|
+
import { catalogProviderIds, forgetCatalog } from './catalog.js';
|
|
28
30
|
import { opendesign } from './adapters-opendesign.js';
|
|
31
|
+
import { providerForModel, inCatalog } from './providers.js';
|
|
29
32
|
|
|
33
|
+
const PKG = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
34
|
+
const VERSION = PKG.version;
|
|
30
35
|
const PORT = Number(process.env.QUOTA_PILL_PORT || 8319);
|
|
31
36
|
const HOST = process.env.QUOTA_PILL_HOST || '127.0.0.1';
|
|
32
37
|
const CACHE_TTL_MS = Number(process.env.QUOTA_PILL_CACHE_TTL || 60) * 1000;
|
|
@@ -47,37 +52,33 @@ const DISPLAY_NAMES = {
|
|
|
47
52
|
opendesign: 'Open Design',
|
|
48
53
|
};
|
|
49
54
|
|
|
50
|
-
/**
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
}
|
|
55
|
+
/** Provider ids contributed by ~/.dsh/quota-pill/adapters (tracked to purge stale ones). */
|
|
56
|
+
let userProviderNames = new Set();
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Reload user adapters from disk and sync them into the request-time registry.
|
|
60
|
+
* Cheap when the directory has not changed; force on ?fresh=1.
|
|
61
|
+
*/
|
|
62
|
+
async function refreshUserAdapters({ force = false } = {}) {
|
|
63
|
+
const info = await loadUserAdapters({ force });
|
|
64
|
+
const next = new Set(info.adapters.map((a) => a.provider));
|
|
65
|
+
for (const provider of userProviderNames) {
|
|
66
|
+
if (!next.has(provider)) {
|
|
67
|
+
delete ADAPTERS[provider];
|
|
68
|
+
delete DISPLAY_NAMES[provider];
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
for (const adapter of info.adapters) {
|
|
72
|
+
ADAPTERS[adapter.provider] = (model) => adapter.read(adapterContext(model));
|
|
73
|
+
DISPLAY_NAMES[adapter.provider] = adapter.displayName;
|
|
74
|
+
}
|
|
75
|
+
userProviderNames = next;
|
|
76
|
+
return info;
|
|
77
|
+
}
|
|
65
78
|
|
|
66
79
|
/** provider|model → { at, payload }. Keyed by model because readings differ per model. */
|
|
67
80
|
const cache = new Map();
|
|
68
81
|
|
|
69
|
-
function providerForModel(model) {
|
|
70
|
-
const m = String(model || '').toLowerCase();
|
|
71
|
-
const prefix = m.split('/')[0];
|
|
72
|
-
if (PREFIX_ALIASES[prefix]) return PREFIX_ALIASES[prefix];
|
|
73
|
-
// Fuzzy fallbacks for models whose prefix is not the provider id.
|
|
74
|
-
if (m.includes('grok')) return 'xai';
|
|
75
|
-
if (m.includes('codex') || m.startsWith('gpt-')) return 'codex';
|
|
76
|
-
if (m.includes('gemini')) return 'agy';
|
|
77
|
-
if (m.startsWith('k3') || m.startsWith('kimi')) return 'kimi';
|
|
78
|
-
return '';
|
|
79
|
-
}
|
|
80
|
-
|
|
81
82
|
async function readOne(provider, model, { fresh = false } = {}) {
|
|
82
83
|
const fn = ADAPTERS[provider];
|
|
83
84
|
if (!fn) return { kind: 'none', provider, error: `no adapter for '${provider}'` };
|
|
@@ -105,29 +106,6 @@ async function readOne(provider, model, { fresh = false } = {}) {
|
|
|
105
106
|
return { ...payload, cached: false, at: now };
|
|
106
107
|
}
|
|
107
108
|
|
|
108
|
-
/**
|
|
109
|
-
* Which DSH catalog ids count as "this adapter".
|
|
110
|
-
*
|
|
111
|
-
* DSH names providers after the upstream (`antigravity`, `deepseek-account`,
|
|
112
|
-
* `opencode-go`); the adapters use the short keys the pill has always used.
|
|
113
|
-
*/
|
|
114
|
-
const CATALOG_ALIASES = {
|
|
115
|
-
agy: ['antigravity', 'agy'],
|
|
116
|
-
deepseek: ['deepseek-account', 'deepseek'],
|
|
117
|
-
opencode: ['opencode-go', 'opencode'],
|
|
118
|
-
kimi: ['kimi-coding', 'kimi'],
|
|
119
|
-
xai: ['xai'],
|
|
120
|
-
codex: ['codex'],
|
|
121
|
-
commandcode: ['commandcode'],
|
|
122
|
-
opendesign: ['opendesign'],
|
|
123
|
-
};
|
|
124
|
-
|
|
125
|
-
function inCatalog(provider, ids) {
|
|
126
|
-
if (!ids) return true; // catalog unknown: do not hide anything
|
|
127
|
-
const names = CATALOG_ALIASES[provider] || [provider];
|
|
128
|
-
return names.some((n) => ids.includes(n));
|
|
129
|
-
}
|
|
130
|
-
|
|
131
109
|
/**
|
|
132
110
|
* Every provider worth showing: present in DSH's catalog AND actually readable.
|
|
133
111
|
*
|
|
@@ -141,6 +119,7 @@ function inCatalog(provider, ids) {
|
|
|
141
119
|
* failure is information the user needs.
|
|
142
120
|
*/
|
|
143
121
|
async function readAll({ fresh = false } = {}) {
|
|
122
|
+
await refreshUserAdapters({ force: fresh });
|
|
144
123
|
const ids = await catalogProviderIds();
|
|
145
124
|
const wanted = Object.keys(ADAPTERS).filter((p) => inCatalog(p, ids));
|
|
146
125
|
|
|
@@ -153,20 +132,48 @@ async function readAll({ fresh = false } = {}) {
|
|
|
153
132
|
}
|
|
154
133
|
|
|
155
134
|
/** What is configured and what is missing — powers the agent-driven setup. */
|
|
156
|
-
|
|
135
|
+
/** Human-readable "what this provider still needs" for /setup and AGENTS.md. */
|
|
136
|
+
function requiresText(src) {
|
|
137
|
+
if (src.requires) return String(src.requires);
|
|
138
|
+
if (src.kind === 'cli-proxy') return 'CLIProxyAPI + OAuth login';
|
|
139
|
+
if (src.kind === 'dsh-account') return 'DSH Platform sign-in (nothing to add)';
|
|
140
|
+
const refs = (src.refs && src.refs.length) ? src.refs : (src.ref ? [src.ref] : []);
|
|
141
|
+
if (!refs.length) return 'no credential source';
|
|
142
|
+
const named = refs.map((r) => `refs.${r}`).join(' or ');
|
|
143
|
+
if (src.kind === 'control-key') {
|
|
144
|
+
const ws = (src.workspaceRefs || []).map((r) => `refs.${r}`).join(' or ')
|
|
145
|
+
|| 'an explicit workspace ref (optional: resolved from /workspaces/current)';
|
|
146
|
+
const cookie = src.cookieRef ? ` or console cookie refs.${src.cookieRef}` : '';
|
|
147
|
+
return `~/.dsh/.credentials.yaml → ${named} + ${ws}${cookie}`;
|
|
148
|
+
}
|
|
149
|
+
const suffix = src.kind === 'session-cookie' ? ' (console session cookie)' : '';
|
|
150
|
+
return `~/.dsh/.credentials.yaml → ${named}${suffix}`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function describeSetup(info) {
|
|
154
|
+
const catalog = await catalogProviderIds();
|
|
157
155
|
const providers = Object.keys(ADAPTERS).map((p) => {
|
|
158
156
|
const src = credentialSource(p);
|
|
159
157
|
return {
|
|
160
158
|
provider: p,
|
|
161
159
|
name: DISPLAY_NAMES[p] || p,
|
|
162
|
-
requires: src
|
|
160
|
+
requires: requiresText(src),
|
|
161
|
+
inCatalog: catalog ? inCatalog(p, catalog) : null,
|
|
163
162
|
credential: src,
|
|
164
163
|
};
|
|
165
164
|
});
|
|
165
|
+
const adapted = new Set(
|
|
166
|
+
catalog ? catalog.filter((id) => Object.keys(ADAPTERS).some((p) => inCatalog(p, [id]))) : [],
|
|
167
|
+
);
|
|
166
168
|
return {
|
|
167
169
|
ok: true,
|
|
170
|
+
version: VERSION,
|
|
171
|
+
catalog,
|
|
172
|
+
catalogSource: 'DSH session/modelCatalog',
|
|
173
|
+
unadaptedCatalog: catalog ? catalog.filter((id) => !adapted.has(id)) : [],
|
|
168
174
|
credentialsFile: CREDENTIALS_FILE,
|
|
169
175
|
cliProxy: { dir: CLI_PROXY_DIR, present: hasCliProxy() },
|
|
176
|
+
userAdapters: info ? { ...userAdaptersInfo(), errors: info.errors } : userAdaptersInfo(),
|
|
170
177
|
providers,
|
|
171
178
|
};
|
|
172
179
|
}
|
|
@@ -187,22 +194,31 @@ const server = http.createServer(async (req, res) => {
|
|
|
187
194
|
|
|
188
195
|
try {
|
|
189
196
|
if (url.pathname === '/health') {
|
|
190
|
-
|
|
197
|
+
await refreshUserAdapters({ force: fresh });
|
|
198
|
+
return json(res, 200, { ok: true, port: PORT, version: VERSION, providers: Object.keys(ADAPTERS) });
|
|
191
199
|
}
|
|
192
200
|
if (url.pathname === '/setup') {
|
|
193
|
-
|
|
201
|
+
const userInfo = await refreshUserAdapters({ force: fresh });
|
|
202
|
+
if (fresh) forgetCatalog();
|
|
203
|
+
return json(res, 200, await describeSetup(userInfo));
|
|
194
204
|
}
|
|
195
205
|
if (url.pathname === '/quota/all') {
|
|
206
|
+
// The catalog is DSH's live provider list. A forced read is how a newly
|
|
207
|
+
// added provider appears without restarting the sidecar or waiting for
|
|
208
|
+
// the 120s TTL.
|
|
209
|
+
if (fresh) forgetCatalog();
|
|
196
210
|
const all = await readAll({ fresh });
|
|
197
211
|
return json(res, 200, all);
|
|
198
212
|
}
|
|
199
213
|
if (url.pathname === '/quota') {
|
|
200
214
|
const model = url.searchParams.get('model') || '';
|
|
215
|
+
await refreshUserAdapters({ force: fresh });
|
|
201
216
|
const provider = providerForModel(model);
|
|
202
217
|
if (!provider) {
|
|
203
218
|
return json(res, 200, {
|
|
204
219
|
kind: 'none',
|
|
205
220
|
provider: 'unknown',
|
|
221
|
+
displayName: 'Unknown provider',
|
|
206
222
|
model,
|
|
207
223
|
error: `no adapter matches model '${model}'`,
|
|
208
224
|
});
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User-contributed provider adapters.
|
|
3
|
+
*
|
|
4
|
+
* The package only ships eight built-ins. A DSH user can add a provider the
|
|
5
|
+
* built-ins do not know (and an agent can do it for them) by dropping a module
|
|
6
|
+
* in:
|
|
7
|
+
*
|
|
8
|
+
* ~/.dsh/quota-pill/adapters/<provider>.mjs
|
|
9
|
+
*
|
|
10
|
+
* That directory is OUTSIDE the npm package, so updating/reinstalling the plugin
|
|
11
|
+
* never overwrites it. The sidecar discovers it on every request (mtime check),
|
|
12
|
+
* so adding a file and pressing Refresh is enough — no restart.
|
|
13
|
+
*
|
|
14
|
+
* Contract (all exports optional except provider/read):
|
|
15
|
+
*
|
|
16
|
+
* export const provider = 'myprovider';
|
|
17
|
+
* export const catalogIds = ['myprovider-go']; // DSH catalog/model ids
|
|
18
|
+
* export const displayName = 'My Provider';
|
|
19
|
+
* export const credential = { kind: 'api-key', refs: ['MYPROVIDER_API_KEY'] };
|
|
20
|
+
* export async function read(ctx) {
|
|
21
|
+
* // ctx.model, ctx.readRef, ctx.readFirstRef, ctx.getJson,
|
|
22
|
+
* // ctx.getJsonViaCurl, ctx.postJsonViaCurl, ctx.env, ctx.dshHome
|
|
23
|
+
* return { kind: 'subscription-quota', remainingPct: 42, window: '5h', ... };
|
|
24
|
+
* }
|
|
25
|
+
*
|
|
26
|
+
* The result must use the same kinds as the built-in adapters
|
|
27
|
+
* (subscription-quota / payg / usage-only / none / probe-failed). Never invent
|
|
28
|
+
* a number: if the provider has no quota API, say so in `error`.
|
|
29
|
+
*/
|
|
30
|
+
import fs from 'node:fs';
|
|
31
|
+
import path from 'node:path';
|
|
32
|
+
import { pathToFileURL } from 'node:url';
|
|
33
|
+
import {
|
|
34
|
+
DSH_DIR,
|
|
35
|
+
readRef,
|
|
36
|
+
readFirstRef,
|
|
37
|
+
registerCredentialSource,
|
|
38
|
+
clearUserCredentialSources,
|
|
39
|
+
} from './credentials.js';
|
|
40
|
+
import { getJson, getJsonViaCurl, postJsonViaCurl } from './http.js';
|
|
41
|
+
import { clearUserProviders, registerUserProvider } from './providers.js';
|
|
42
|
+
|
|
43
|
+
export const USER_ADAPTER_DIR = process.env.QUOTA_PILL_USER_ADAPTER_DIR
|
|
44
|
+
|| path.join(DSH_DIR, 'quota-pill', 'adapters');
|
|
45
|
+
|
|
46
|
+
let current = { dir: USER_ADAPTER_DIR, adapters: [], errors: [], loadedAt: 0, signature: '' };
|
|
47
|
+
|
|
48
|
+
function signatureOf(dir) {
|
|
49
|
+
let files = [];
|
|
50
|
+
try {
|
|
51
|
+
files = fs.readdirSync(dir)
|
|
52
|
+
.filter((f) => /\.(mjs|js)$/i.test(f) && !f.endsWith('.test.mjs'))
|
|
53
|
+
.sort();
|
|
54
|
+
} catch {
|
|
55
|
+
return { files: [], sig: dir + '|missing' };
|
|
56
|
+
}
|
|
57
|
+
const parts = [];
|
|
58
|
+
for (const f of files) {
|
|
59
|
+
try {
|
|
60
|
+
const s = fs.statSync(path.join(dir, f));
|
|
61
|
+
parts.push(f + ':' + s.mtimeMs + ':' + s.size);
|
|
62
|
+
} catch {
|
|
63
|
+
parts.push(f + ':missing');
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return { files, sig: dir + '|' + parts.join('|') };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function normalizeCredential(raw, provider) {
|
|
70
|
+
if (raw && typeof raw === 'object') {
|
|
71
|
+
return {
|
|
72
|
+
kind: raw.kind || 'user-adapter',
|
|
73
|
+
refs: Array.isArray(raw.refs) ? raw.refs : (raw.ref ? [raw.ref] : []),
|
|
74
|
+
ref: raw.ref || (Array.isArray(raw.refs) ? raw.refs[0] : null) || null,
|
|
75
|
+
requires: raw.requires || null,
|
|
76
|
+
...raw,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
kind: 'user-adapter',
|
|
81
|
+
refs: [],
|
|
82
|
+
ref: null,
|
|
83
|
+
requires: 'credentials defined by the user adapter ' + provider,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function importAdapter(full, stat) {
|
|
88
|
+
// The mtime/size query busts Node's ESM cache, so editing an adapter and
|
|
89
|
+
// refreshing really reloads it instead of serving the first version.
|
|
90
|
+
const href = pathToFileURL(full).href + '?v=' + encodeURIComponent(stat.mtimeMs + '-' + stat.size);
|
|
91
|
+
const mod = await import(href);
|
|
92
|
+
const spec = (mod && mod.default && typeof mod.default === 'object') ? { ...mod.default, ...mod } : mod;
|
|
93
|
+
const fallback = path.basename(full, path.extname(full));
|
|
94
|
+
const provider = String(spec.provider || fallback).trim();
|
|
95
|
+
if (!/^[a-z0-9][a-z0-9._-]*$/i.test(provider)) {
|
|
96
|
+
throw new Error('invalid provider id: ' + JSON.stringify(provider));
|
|
97
|
+
}
|
|
98
|
+
if (typeof spec.read !== 'function') throw new Error('adapter must export an async read(ctx) function');
|
|
99
|
+
return {
|
|
100
|
+
provider,
|
|
101
|
+
read: spec.read,
|
|
102
|
+
displayName: spec.displayName ? String(spec.displayName) : provider,
|
|
103
|
+
catalogIds: Array.isArray(spec.catalogIds) ? spec.catalogIds.map(String).filter(Boolean) : [provider],
|
|
104
|
+
prefixes: Array.isArray(spec.prefixes) ? spec.prefixes.map(String).filter(Boolean) : [],
|
|
105
|
+
credential: normalizeCredential(spec.credential, provider),
|
|
106
|
+
file: full,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Load/reload user adapters. Cheap when the directory has not changed. */
|
|
111
|
+
export async function loadUserAdapters({ force = false, dir = USER_ADAPTER_DIR } = {}) {
|
|
112
|
+
try {
|
|
113
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
114
|
+
} catch {
|
|
115
|
+
// read-only home: scan whatever exists, /setup will show the error
|
|
116
|
+
}
|
|
117
|
+
const { files, sig } = signatureOf(dir);
|
|
118
|
+
if (!force && sig === current.signature) return current;
|
|
119
|
+
|
|
120
|
+
// Rebuild registrations from scratch so a deleted file also unregisters its
|
|
121
|
+
// provider instead of leaving a stale adapter/mapping behind.
|
|
122
|
+
clearUserProviders();
|
|
123
|
+
clearUserCredentialSources();
|
|
124
|
+
|
|
125
|
+
const adapters = [];
|
|
126
|
+
const errors = [];
|
|
127
|
+
for (const file of files) {
|
|
128
|
+
const full = path.join(dir, file);
|
|
129
|
+
try {
|
|
130
|
+
const stat = fs.statSync(full);
|
|
131
|
+
const adapter = await importAdapter(full, stat);
|
|
132
|
+
adapters.push(adapter);
|
|
133
|
+
registerUserProvider(adapter);
|
|
134
|
+
registerCredentialSource(adapter.provider, adapter.credential);
|
|
135
|
+
} catch (e) {
|
|
136
|
+
errors.push({ file, error: String((e && e.message) || e) });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
current = { dir, adapters, errors, loadedAt: Date.now(), signature: sig };
|
|
141
|
+
return current;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function userAdapterProviders() {
|
|
145
|
+
return current.adapters.map((a) => a.provider);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function userAdapterFor(provider) {
|
|
149
|
+
return current.adapters.find((a) => a.provider === provider) || null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function userAdaptersInfo() {
|
|
153
|
+
return {
|
|
154
|
+
dir: current.dir,
|
|
155
|
+
providers: current.adapters.map((a) => ({ provider: a.provider, displayName: a.displayName, file: a.file })),
|
|
156
|
+
errors: current.errors,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Context passed to a user adapter's read(ctx). */
|
|
161
|
+
export function adapterContext(model) {
|
|
162
|
+
return {
|
|
163
|
+
model,
|
|
164
|
+
readRef,
|
|
165
|
+
readFirstRef,
|
|
166
|
+
getJson,
|
|
167
|
+
getJsonViaCurl,
|
|
168
|
+
postJsonViaCurl,
|
|
169
|
+
env: process.env,
|
|
170
|
+
dshHome: DSH_DIR,
|
|
171
|
+
};
|
|
172
|
+
}
|