@adhdev/daemon-core 0.9.82-rc.165 → 0.9.82-rc.166

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.
@@ -0,0 +1,218 @@
1
+ /**
2
+ * External provider sources — registry + active-selection state.
3
+ *
4
+ * A user can register one or more 3rd-party git URLs as provider sources.
5
+ * Each source clones into ~/.adhdev/external/<source-name>/, namespaced so
6
+ * that two sources can disk-coexist providing the same provider type.
7
+ *
8
+ * When two sources expose the same type, only one is "active" at a time.
9
+ * The active selection is persisted so a user's choice survives daemon
10
+ * restarts. When only one source provides a type, no explicit selection
11
+ * is needed and the loader falls through to that source.
12
+ */
13
+ 'use strict';
14
+
15
+ import * as fs from 'node:fs';
16
+ import * as os from 'node:os';
17
+ import * as path from 'node:path';
18
+
19
+ export interface ExternalSource {
20
+ /** Unique short identifier, e.g. "@vendor-x". User-supplied or derived from the URL. */
21
+ name: string;
22
+ /** Full git URL (https://, git@, …). */
23
+ url: string;
24
+ /** Branch / tag / commit-ish to track. Defaults to `main`. */
25
+ ref: string;
26
+ /** ISO timestamp when first registered. */
27
+ addedAt: string;
28
+ }
29
+
30
+ export interface ExternalSourcesFile {
31
+ /** Schema version — bump if shape changes. */
32
+ schema: 1;
33
+ sources: ExternalSource[];
34
+ }
35
+
36
+ /**
37
+ * Per-type active source selection. Only types reachable from more than
38
+ * one source need an entry — single-source types have no ambiguity.
39
+ *
40
+ * Shape: { active: { "<type>": "<source-name>" } }
41
+ */
42
+ export interface ProvidersActiveFile {
43
+ schema: 1;
44
+ active: Record<string, string>;
45
+ }
46
+
47
+ const SOURCES_FILENAME = 'providers-sources.json';
48
+ const ACTIVE_FILENAME = 'providers-active.json';
49
+
50
+ function adhdevDir(): string {
51
+ return path.join(os.homedir(), '.adhdev');
52
+ }
53
+
54
+ export function externalRoot(): string {
55
+ return path.join(adhdevDir(), 'external');
56
+ }
57
+
58
+ export function sourcesFilePath(): string {
59
+ return path.join(adhdevDir(), SOURCES_FILENAME);
60
+ }
61
+
62
+ export function activeFilePath(): string {
63
+ return path.join(adhdevDir(), ACTIVE_FILENAME);
64
+ }
65
+
66
+ function ensureAdhdevDir(): void {
67
+ const d = adhdevDir();
68
+ if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true });
69
+ }
70
+
71
+ export function loadExternalSources(): ExternalSourcesFile {
72
+ const p = sourcesFilePath();
73
+ if (!fs.existsSync(p)) return { schema: 1, sources: [] };
74
+ try {
75
+ const raw = JSON.parse(fs.readFileSync(p, 'utf-8'));
76
+ if (!raw || typeof raw !== 'object') return { schema: 1, sources: [] };
77
+ const sources = Array.isArray(raw.sources) ? raw.sources.filter(isValidSource) : [];
78
+ return { schema: 1, sources };
79
+ } catch {
80
+ return { schema: 1, sources: [] };
81
+ }
82
+ }
83
+
84
+ export function saveExternalSources(file: ExternalSourcesFile): void {
85
+ ensureAdhdevDir();
86
+ const tmp = sourcesFilePath() + '.tmp';
87
+ fs.writeFileSync(tmp, JSON.stringify(file, null, 2) + '\n', 'utf-8');
88
+ fs.renameSync(tmp, sourcesFilePath());
89
+ }
90
+
91
+ export function loadProvidersActive(): ProvidersActiveFile {
92
+ const p = activeFilePath();
93
+ if (!fs.existsSync(p)) return { schema: 1, active: {} };
94
+ try {
95
+ const raw = JSON.parse(fs.readFileSync(p, 'utf-8'));
96
+ if (!raw || typeof raw !== 'object') return { schema: 1, active: {} };
97
+ const active = raw.active && typeof raw.active === 'object' ? raw.active : {};
98
+ return { schema: 1, active };
99
+ } catch {
100
+ return { schema: 1, active: {} };
101
+ }
102
+ }
103
+
104
+ export function saveProvidersActive(file: ProvidersActiveFile): void {
105
+ ensureAdhdevDir();
106
+ const tmp = activeFilePath() + '.tmp';
107
+ fs.writeFileSync(tmp, JSON.stringify(file, null, 2) + '\n', 'utf-8');
108
+ fs.renameSync(tmp, activeFilePath());
109
+ }
110
+
111
+ function isValidSource(x: unknown): x is ExternalSource {
112
+ if (!x || typeof x !== 'object') return false;
113
+ const s = x as Record<string, unknown>;
114
+ return typeof s.name === 'string' && s.name.length > 0
115
+ && typeof s.url === 'string' && s.url.length > 0
116
+ && typeof s.ref === 'string' && s.ref.length > 0
117
+ && typeof s.addedAt === 'string';
118
+ }
119
+
120
+ /**
121
+ * Derive a short identifier from a git URL when the user didn't supply one.
122
+ * https://github.com/vendor/extra-providers.git → "@vendor-extra-providers"
123
+ * git@github.com:vendor/extra-providers → "@vendor-extra-providers"
124
+ *
125
+ * Idempotent; safe to call before validation since it produces a string
126
+ * regardless of input shape.
127
+ */
128
+ export function deriveSourceName(url: string): string {
129
+ const m = url.match(/[/:]([^/:]+)\/([^/]+?)(?:\.git)?$/);
130
+ if (!m) return '@source';
131
+ const owner = m[1].toLowerCase().replace(/[^a-z0-9_-]/g, '-');
132
+ const repo = m[2].toLowerCase().replace(/[^a-z0-9_-]/g, '-');
133
+ return `@${owner}-${repo}`;
134
+ }
135
+
136
+ /**
137
+ * Return the per-source category/type tree currently on disk under
138
+ * ~/.adhdev/external/<source>/. Used by the conflict detector and by
139
+ * list_provider_sources.
140
+ */
141
+ export interface SourceInventoryEntry {
142
+ sourceName: string;
143
+ /** Map: category → list of provider types. */
144
+ providers: Record<string, string[]>;
145
+ }
146
+
147
+ export function inventoryExternalSources(): SourceInventoryEntry[] {
148
+ const root = externalRoot();
149
+ if (!fs.existsSync(root)) return [];
150
+ const out: SourceInventoryEntry[] = [];
151
+ let entries: fs.Dirent[];
152
+ try { entries = fs.readdirSync(root, { withFileTypes: true }); }
153
+ catch { return []; }
154
+ for (const sourceEntry of entries) {
155
+ if (!sourceEntry.isDirectory()) continue;
156
+ const sourceName = sourceEntry.name;
157
+ const sourceDir = path.join(root, sourceName);
158
+ const providers: Record<string, string[]> = {};
159
+ let categoryEntries: fs.Dirent[];
160
+ try { categoryEntries = fs.readdirSync(sourceDir, { withFileTypes: true }); }
161
+ catch { continue; }
162
+ for (const categoryEntry of categoryEntries) {
163
+ if (!categoryEntry.isDirectory()) continue;
164
+ const category = categoryEntry.name;
165
+ const categoryDir = path.join(sourceDir, category);
166
+ let typeEntries: fs.Dirent[];
167
+ try { typeEntries = fs.readdirSync(categoryDir, { withFileTypes: true }); }
168
+ catch { continue; }
169
+ const types: string[] = [];
170
+ for (const typeEntry of typeEntries) {
171
+ if (!typeEntry.isDirectory()) continue;
172
+ // Only count it as a provider if a manifest file exists
173
+ const typeDir = path.join(categoryDir, typeEntry.name);
174
+ const hasV1 = fs.existsSync(path.join(typeDir, 'provider.v1.json'));
175
+ const hasV0 = fs.existsSync(path.join(typeDir, 'provider.json'));
176
+ if (hasV1 || hasV0) types.push(typeEntry.name);
177
+ }
178
+ if (types.length > 0) providers[category] = types;
179
+ }
180
+ out.push({ sourceName, providers });
181
+ }
182
+ return out;
183
+ }
184
+
185
+ /**
186
+ * For a given category+type, list every source that currently exposes it.
187
+ * Returns source names in disk-walk order; callers can use the first one
188
+ * when no explicit active selection exists.
189
+ */
190
+ export function sourcesProviding(category: string, type: string): string[] {
191
+ const inventory = inventoryExternalSources();
192
+ return inventory
193
+ .filter(s => (s.providers[category] || []).includes(type))
194
+ .map(s => s.sourceName);
195
+ }
196
+
197
+ /**
198
+ * Resolve which source should be active for a given category+type.
199
+ * - If exactly one source provides it → that source.
200
+ * - If multiple sources provide it → the one named in providers-active.json
201
+ * (when present) or null (ambiguous; loader should warn and pick
202
+ * the first deterministically so daemon doesn't refuse to boot).
203
+ * - If none → null.
204
+ */
205
+ export function resolveActiveSource(
206
+ category: string,
207
+ type: string,
208
+ activeFile?: ProvidersActiveFile,
209
+ ): { source: string | null; ambiguous: boolean; candidates: string[] } {
210
+ const candidates = sourcesProviding(category, type);
211
+ if (candidates.length === 0) return { source: null, ambiguous: false, candidates };
212
+ if (candidates.length === 1) return { source: candidates[0], ambiguous: false, candidates };
213
+ const explicit = (activeFile ?? loadProvidersActive()).active[type];
214
+ if (explicit && candidates.includes(explicit)) {
215
+ return { source: explicit, ambiguous: false, candidates };
216
+ }
217
+ return { source: candidates[0], ambiguous: true, candidates };
218
+ }
@@ -215,6 +215,33 @@ export class ProviderLoader {
215
215
  sourceMode: options?.sourceMode,
216
216
  disableUpstream: options?.disableUpstream,
217
217
  });
218
+
219
+ // One-time migration: ~/.adhdev/marketplace → ~/.adhdev/external.
220
+ // The directory was renamed when the "marketplace" install model was
221
+ // dropped in favour of explicit external git sources. Best-effort:
222
+ // if the rename fails we leave both dirs in place and log so the user
223
+ // can investigate.
224
+ this.migrateMarketplaceDirToExternal();
225
+ }
226
+
227
+ private migrateMarketplaceDirToExternal(): void {
228
+ try {
229
+ const home = os.homedir();
230
+ const oldDir = path.join(home, '.adhdev', 'marketplace');
231
+ const newDir = path.join(home, '.adhdev', 'external');
232
+ if (!fs.existsSync(oldDir)) return;
233
+ if (fs.existsSync(newDir)) {
234
+ // Both exist — don't merge. Leave old in place; surface in logs so
235
+ // the user can decide what to keep. Loader still loads from
236
+ // external/ only, so old marketplace/ becomes inert.
237
+ this.log(`Migration skipped: both ~/.adhdev/marketplace and ~/.adhdev/external exist (marketplace dir is now inert and can be removed manually).`);
238
+ return;
239
+ }
240
+ fs.renameSync(oldDir, newDir);
241
+ this.log(`Migrated ~/.adhdev/marketplace → ~/.adhdev/external (one-time rename after provider source-layer cleanup).`);
242
+ } catch (e: any) {
243
+ this.log(`Marketplace→external migration failed: ${e?.message || e}`);
244
+ }
218
245
  }
219
246
 
220
247
  private log(msg: string): void {
@@ -246,12 +273,12 @@ export class ProviderLoader {
246
273
  * Highest-priority editable overrides come first.
247
274
  */
248
275
  getProviderRoots(): string[] {
249
- // Order matters: user customs > marketplace installs > upstream auto-sync.
250
- // findProviderDirInternal walks this list in order to locate the provider
251
- // dir containing the scripts/, so marketplace must be included here even
252
- // though loadAll() also reads it directly.
253
- const marketplaceDir = path.join(os.homedir(), '.adhdev', 'marketplace');
254
- return [this.userDir, marketplaceDir, this.upstreamDir];
276
+ // Order matters: user customs > external (3rd-party sources) > upstream
277
+ // (official auto-sync). findProviderDirInternal walks this list in order
278
+ // to locate the provider dir containing the scripts/, so external must
279
+ // be included here even though loadAll() also reads it directly.
280
+ const externalDir = path.join(os.homedir(), '.adhdev', 'external');
281
+ return [this.userDir, externalDir, this.upstreamDir];
255
282
  }
256
283
 
257
284
  getSourceConfig(): ProviderSourceConfigSnapshot {
@@ -343,16 +370,19 @@ export class ProviderLoader {
343
370
 
344
371
  /**
345
372
  * Load all providers (3-tier priority)
346
- * 1. .upstream/ (GitHub auto-download — primary source)
347
- * 2. User custom (~/.adhdev/providers/ excluding .upstream)
348
- * User custom always wins (highest priority).
373
+ * 1. ~/.adhdev/providers/.upstream/ official git, auto-synced
374
+ * 2. ~/.adhdev/external/ 3rd-party git sources, user-added,
375
+ * bundled providers may include arbitrary JS (untrusted by default)
376
+ * 3. ~/.adhdev/providers/ (excluding .upstream) — user-authored customs,
377
+ * always wins
378
+ * Highest priority listed last (overwrites earlier loads).
349
379
  * If .upstream/ is empty, call fetchLatest() before loadAll().
350
380
  */
351
381
  loadAll(): void {
352
382
  this.providers.clear();
353
383
  this.providerAvailability.clear();
354
384
 
355
- // 1. Load upstream (GitHub auto-download — primary source)
385
+ // 1. Load upstream (GitHub auto-download — primary official source)
356
386
  let upstreamCount = 0;
357
387
  if (!this.disableUpstream && fs.existsSync(this.upstreamDir)) {
358
388
  upstreamCount = this.loadDir(this.upstreamDir);
@@ -363,15 +393,90 @@ export class ProviderLoader {
363
393
  this.log('Upstream loading disabled (sourceMode=no-upstream)');
364
394
  }
365
395
 
366
- // 2. Load marketplace installs from ~/.adhdev/marketplace/ (overrides upstream,
367
- // but is itself overridden by user customs in step 3). These are providers the
368
- // user explicitly installed via the Marketplace UI. They are NOT touched by
369
- // upstream sync.
370
- const marketplaceDir = path.join(os.homedir(), '.adhdev', 'marketplace');
371
- if (fs.existsSync(marketplaceDir)) {
372
- const marketplaceCount = this.loadDir(marketplaceDir);
373
- if (marketplaceCount > 0) {
374
- this.log(`Loaded ${marketplaceCount} marketplace-installed providers`);
396
+ // 2. Load external providers from ~/.adhdev/external/<source-name>/
397
+ // (3rd-party git sources). Overrides upstream but is itself overridden
398
+ // by user customs in step 3.
399
+ //
400
+ // Each registered source is a separate subdirectory so two sources can
401
+ // both expose the same provider type without overwriting each other.
402
+ // When more than one source provides the same type, providers-active.json
403
+ // chooses the active one; without an explicit choice we deterministically
404
+ // pick the first in disk-walk order and log the ambiguity so the user
405
+ // can resolve it from the dashboard.
406
+ //
407
+ // Any non-spec manifest (tui block / overrides / scriptDir) coming from
408
+ // an external source runs JavaScript the daemon hasn't audited, so
409
+ // dashboards must surface an "untrusted source" badge before letting
410
+ // the user enable them.
411
+ const externalDir = path.join(os.homedir(), '.adhdev', 'external');
412
+ if (fs.existsSync(externalDir)) {
413
+ // Legacy layout (pre-source-namespace): manifests sit directly at
414
+ // external/<category>/<type>/. Detect by presence of category dirs at
415
+ // the root and migrate inline by treating the whole tree as a single
416
+ // implicit source. Loader behavior unchanged for legacy callers.
417
+ const rootEntries = (() => {
418
+ try { return fs.readdirSync(externalDir, { withFileTypes: true }); }
419
+ catch { return [] as fs.Dirent[]; }
420
+ })();
421
+ const KNOWN_CATEGORIES = new Set(['cli', 'ide', 'extension', 'acp']);
422
+ const looksLegacy = rootEntries.some(e => e.isDirectory() && KNOWN_CATEGORIES.has(e.name));
423
+ if (looksLegacy) {
424
+ // Tree shape predates per-source dirs — treat the whole thing as a
425
+ // single anonymous source so existing installs keep working until
426
+ // they're migrated to a real source registration.
427
+ const externalCount = this.loadDir(externalDir);
428
+ if (externalCount > 0) {
429
+ this.log(`Loaded ${externalCount} external providers (legacy unnamed source)`);
430
+ }
431
+ } else {
432
+ // New layout: external/<source-name>/<category>/<type>/…
433
+ const {
434
+ loadProvidersActive,
435
+ resolveActiveSource,
436
+ } = require('./external-sources.js') as typeof import('./external-sources.js');
437
+ const activeFile = loadProvidersActive();
438
+ let totalLoaded = 0;
439
+ const ambiguousTypes: { type: string; chosen: string; candidates: string[] }[] = [];
440
+ // Per-source load, then filter by active-selection: for each type
441
+ // present in more than one source, only the active source's copy
442
+ // is left in this.providers.
443
+ for (const sourceEntry of rootEntries) {
444
+ if (!sourceEntry.isDirectory()) continue;
445
+ const sourceDir = path.join(externalDir, sourceEntry.name);
446
+ const sourceLoaded = this.loadDir(sourceDir);
447
+ if (sourceLoaded > 0) {
448
+ totalLoaded += sourceLoaded;
449
+ this.log(`Loaded ${sourceLoaded} providers from external source "${sourceEntry.name}"`);
450
+ }
451
+ }
452
+ // Resolve ambiguities — when the same type came from multiple
453
+ // sources, the last load wins by default. Replay with the active
454
+ // selection so the user-chosen source ends up winning.
455
+ for (const [type] of this.providers) {
456
+ const prov = this.providers.get(type);
457
+ if (!prov) continue;
458
+ const resolved = resolveActiveSource(prov.category, type, activeFile);
459
+ if (resolved.candidates.length <= 1) continue;
460
+ if (resolved.ambiguous) {
461
+ ambiguousTypes.push({ type, chosen: resolved.source ?? '?', candidates: resolved.candidates });
462
+ }
463
+ if (resolved.source && resolved.source !== '?') {
464
+ const sourceDir = path.join(externalDir, resolved.source);
465
+ // Reload only this source's copy of the conflicting type so it
466
+ // overwrites whatever else won the initial pass.
467
+ const reloadCount = this.loadDir(sourceDir);
468
+ // reloadCount is a sanity check — we expect ≥1
469
+ if (reloadCount === 0) {
470
+ this.log(`Active source "${resolved.source}" no longer provides ${type}`);
471
+ }
472
+ }
473
+ }
474
+ if (totalLoaded > 0) {
475
+ this.log(`Loaded ${totalLoaded} external providers (3rd-party sources)`);
476
+ }
477
+ for (const a of ambiguousTypes) {
478
+ this.log(`Ambiguous provider "${a.type}" — provided by [${a.candidates.join(', ')}], defaulted to "${a.chosen}". Set the active source from the dashboard to silence this warning.`);
479
+ }
375
480
  }
376
481
  }
377
482
 
@@ -2132,14 +2237,40 @@ export class ProviderLoader {
2132
2237
  }
2133
2238
  }
2134
2239
 
2240
+ // Classify trust based on which on-disk layer this manifest
2241
+ // came from + whether it ships JavaScript hooks. The dashboard
2242
+ // uses this to render trust badges; non-spec external manifests
2243
+ // need an explicit user confirm before activation.
2244
+ const externalDirAbs = path.join(os.homedir(), '.adhdev', 'external');
2245
+ const layer: 'user' | 'upstream' | 'external' = d.startsWith(externalDirAbs)
2246
+ ? 'external'
2247
+ : (d.startsWith(this.userDir) && !d.includes('.upstream') ? 'user' : 'upstream');
2248
+ try {
2249
+ const { inspectManifestShape, classifyTrust } =
2250
+ require('./provider-trust.js') as typeof import('./provider-trust.js');
2251
+ const shape = inspectManifestShape(mod as Record<string, unknown>);
2252
+ const trust = classifyTrust(layer, shape);
2253
+ (normalizedProvider as any)._sourceLayer = layer;
2254
+ (normalizedProvider as any)._sourceTrust = trust;
2255
+ (normalizedProvider as any)._manifestShape = shape;
2256
+ // For external-namespaced layouts (external/<source>/…) record
2257
+ // which source the manifest came from so dashboards can name
2258
+ // it in the trust badge.
2259
+ if (layer === 'external') {
2260
+ const rel = path.relative(externalDirAbs, d);
2261
+ const firstSeg = rel.split(path.sep)[0];
2262
+ if (firstSeg && firstSeg !== '..') (normalizedProvider as any)._sourceName = firstSeg;
2263
+ }
2264
+ } catch { /* best-effort — trust is enrichment, not gating */ }
2265
+
2135
2266
  const existed = this.providers.has(normalizedProvider.type);
2136
2267
  this.providers.set(normalizedProvider.type, normalizedProvider);
2137
2268
  count++;
2138
- // Identify source tier for debugging
2139
- const source = d.startsWith(this.userDir) && !d.includes('.upstream')
2140
- ? 'user' : 'upstream';
2269
+ const source = (normalizedProvider as any)._sourceLayer ?? 'upstream';
2141
2270
  const overrideWarning = existed && source === 'user' ? ' ⚠ OVERRIDES upstream' : '';
2142
- this.log(` ${existed ? '🔄' : '✅'} ${normalizedProvider.type} (${normalizedProvider.category}) ${normalizedProvider.name} [${source}]${overrideWarning}`);
2271
+ const sourceName = (normalizedProvider as any)._sourceName;
2272
+ const sourceLabel = sourceName ? `${source}/${sourceName}` : source;
2273
+ this.log(` ${existed ? '🔄' : '✅'} ${normalizedProvider.type} (${normalizedProvider.category}) — ${normalizedProvider.name} [${sourceLabel}]${overrideWarning}`);
2143
2274
  }
2144
2275
  } catch (e) {
2145
2276
  this.log(`⚠ Failed to load ${jsonPath}: ${(e as Error).message}`);
@@ -2151,6 +2282,11 @@ export class ProviderLoader {
2151
2282
  for (const entry of entries) {
2152
2283
  if (!entry.isDirectory()) continue;
2153
2284
  if (entry.name.startsWith('_') || entry.name.startsWith('.')) continue;
2285
+ // `examples/` is a documentation / scaffold tree (e.g. stub-cli),
2286
+ // not a real provider source. SDK authors copy from here when
2287
+ // writing a new provider; daemon-core tests reference the
2288
+ // manifest by path. Keep it off the dashboard's provider list.
2289
+ if (d === dir && entry.name === 'examples') continue;
2154
2290
  if (excludeDirs && d === dir && excludeDirs.includes(entry.name)) continue;
2155
2291
  scan(path.join(d, entry.name));
2156
2292
  }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Provider trust classification.
3
+ *
4
+ * Each loaded provider gets a `trust` tag that the daemon and dashboard
5
+ * use to decide what to surface and what to gate behind a confirmation
6
+ * prompt. Trust is a function of *where* the provider came from
7
+ * (upstream / external / userDir) and the *shape of the manifest*
8
+ * (spec-only vs. carries JavaScript hooks).
9
+ *
10
+ * Rationale:
11
+ * - Spec-only manifests are static declarative configuration; the
12
+ * daemon's spec adapter walks the spec but never executes code
13
+ * authored by the provider.
14
+ * - Non-spec manifests ship JavaScript (tui-block builders consumed
15
+ * by the SDK, scriptDir overrides, or explicit `overrides.* .path`
16
+ * bindings). That code runs inside the daemon process with the
17
+ * same privileges as adhdev itself.
18
+ *
19
+ * Source × shape decides the trust label:
20
+ *
21
+ * layer | spec-only | non-spec
22
+ * ---------|---------------------|-----------------------
23
+ * user | user-custom | user-custom
24
+ * upstream | trusted | trusted-with-scripts
25
+ * external | external-safe | external-untrusted
26
+ *
27
+ * Dashboards should always show a badge for `trusted-with-scripts`
28
+ * (info — runs official JS), `external-safe` (info — declarative only,
29
+ * but 3rd-party), and `external-untrusted` (warning — runs JS from a
30
+ * 3rd-party). Activation of `external-untrusted` should require an
31
+ * explicit confirmation step that names the source URL.
32
+ */
33
+ 'use strict';
34
+
35
+ export type ProviderTrust =
36
+ | 'user-custom'
37
+ | 'trusted'
38
+ | 'trusted-with-scripts'
39
+ | 'external-safe'
40
+ | 'external-untrusted';
41
+
42
+ export type ProviderLayer = 'user' | 'upstream' | 'external';
43
+
44
+ export interface ProviderManifestShape {
45
+ /** Has a `tui` block — SDK builders consume it as code paths. */
46
+ hasTui: boolean;
47
+ /** Has a non-empty `overrides` object — JS override paths. */
48
+ hasOverrides: boolean;
49
+ /** compatibility[].scriptDir or defaultScriptDir is set — JS scripts dir. */
50
+ hasScriptDir: boolean;
51
+ }
52
+
53
+ /**
54
+ * Inspect a manifest to decide whether it ships JavaScript hooks.
55
+ * Cheap; pure; safe to call on every provider load.
56
+ */
57
+ export function inspectManifestShape(manifest: Record<string, unknown>): ProviderManifestShape {
58
+ const hasTui = !!manifest.tui && typeof manifest.tui === 'object'
59
+ && Object.keys(manifest.tui as Record<string, unknown>).length > 0;
60
+ const hasOverrides = !!manifest.overrides && typeof manifest.overrides === 'object'
61
+ && !Array.isArray(manifest.overrides)
62
+ && Object.keys(manifest.overrides as Record<string, unknown>).length > 0;
63
+ const compat = Array.isArray(manifest.compatibility) ? manifest.compatibility : [];
64
+ const compatHasScriptDir = compat.some((entry: any) => typeof entry?.scriptDir === 'string');
65
+ const hasScriptDir = compatHasScriptDir || typeof manifest.defaultScriptDir === 'string';
66
+ return { hasTui, hasOverrides, hasScriptDir };
67
+ }
68
+
69
+ /**
70
+ * Classify trust given the layer the provider was loaded from + the
71
+ * manifest's JS-hook footprint.
72
+ */
73
+ export function classifyTrust(
74
+ layer: ProviderLayer,
75
+ shape: ProviderManifestShape,
76
+ ): ProviderTrust {
77
+ const isSpecOnly = !shape.hasTui && !shape.hasOverrides && !shape.hasScriptDir;
78
+ switch (layer) {
79
+ case 'user':
80
+ return 'user-custom';
81
+ case 'upstream':
82
+ return isSpecOnly ? 'trusted' : 'trusted-with-scripts';
83
+ case 'external':
84
+ return isSpecOnly ? 'external-safe' : 'external-untrusted';
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Returns true when activation should require an explicit user confirm.
90
+ * Today only `external-untrusted` qualifies; future trust levels may
91
+ * fold in here.
92
+ */
93
+ export function requiresConfirmation(trust: ProviderTrust): boolean {
94
+ return trust === 'external-untrusted';
95
+ }
96
+
97
+ /**
98
+ * Render a short human-readable rationale for the trust tag. Used by
99
+ * the dashboard's confirmation modal and the provider catalog tooltip.
100
+ */
101
+ export function describeTrust(trust: ProviderTrust): string {
102
+ switch (trust) {
103
+ case 'user-custom':
104
+ return 'Hand-authored in ~/.adhdev/providers/. Runs your own code.';
105
+ case 'trusted':
106
+ return 'Official, declarative-only manifest from the ADHDev registry.';
107
+ case 'trusted-with-scripts':
108
+ return 'Official manifest from the ADHDev registry. Ships JavaScript hooks executed by the daemon.';
109
+ case 'external-safe':
110
+ return 'Manifest from a 3rd-party git source you added. Declarative-only — the daemon never runs JS from this source.';
111
+ case 'external-untrusted':
112
+ return 'Manifest from a 3rd-party git source you added. Ships JavaScript that the daemon will execute. Treat as untrusted code — review the source before enabling.';
113
+ }
114
+ }
@@ -143,7 +143,7 @@ export const V1_PRIMITIVE_CATALOG = Object.freeze({
143
143
  ],
144
144
  } as const);
145
145
 
146
- /** Aggregate flat list — for marketplace catalog endpoint. */
146
+ /** Aggregate flat list — for provider catalog endpoints. */
147
147
  export const V1_ALL_PRIMITIVES: ReadonlyArray<string> = Object.freeze(
148
148
  Object.values(V1_PRIMITIVE_CATALOG).flat(),
149
149
  );
@@ -344,7 +344,7 @@ function canonicalize(p: string): string | null {
344
344
  ? nodeFs.realpathSync.native(resolved)
345
345
  : nodeFs.realpathSync(resolved);
346
346
  } catch {
347
- // Root may not exist yet (e.g. marketplace dir created lazily);
347
+ // Root may not exist yet (e.g. external dir created lazily);
348
348
  // we still register the resolved path so future requires from
349
349
  // inside are gated once the directory shows up.
350
350
  return resolved;
@@ -9,7 +9,7 @@
9
9
  * offending field without guessing.
10
10
  *
11
11
  * Validation lives in the SDK layer (not in provider-loader) so dashboards,
12
- * registry workers, and the marketplace publish flow can all reuse the
12
+ * registry workers, and the provider publish flow can all reuse the
13
13
  * same code path and produce identical error messages.
14
14
  */
15
15
 
@@ -2,7 +2,7 @@
2
2
  * Static taint analyzer for extended-tier override JS.
3
3
  *
4
4
  * Goal: classify the override JS shipped with an extended-tier provider into
5
- * one of three risk tiers, so the marketplace + daemon trust prompt can
5
+ * one of three risk tiers, so the dashboard + daemon trust prompt can
6
6
  * surface accurate language to the operator instead of a single generic
7
7
  * "this provider contains JS" warning.
8
8
  *
@@ -525,7 +525,39 @@ export interface AvailableProviderInfo {
525
525
  lastVerification?: MachineProviderCheckResult;
526
526
  /** Provider-declared Repo Mesh coordinator/MCP behavior. */
527
527
  meshCoordinator?: ProviderMeshCoordinatorConfig;
528
- }
528
+ /**
529
+ * Provider trust classification — derived from the on-disk layer the
530
+ * manifest came from and the shape of the manifest. Dashboards use
531
+ * this to render a trust badge and gate activation of
532
+ * `external-untrusted` providers behind a confirm modal.
533
+ */
534
+ trust?: ProviderTrust;
535
+ /** Daemon-side human-readable description of the trust value. */
536
+ trustDescription?: string;
537
+ /** True when activation needs a user-side confirmation step. */
538
+ requiresConfirmation?: boolean;
539
+ /** Which on-disk layer the manifest lives in. */
540
+ sourceLayer?: 'user' | 'upstream' | 'external';
541
+ /** For external providers, the source-name namespace it came from. */
542
+ sourceName?: string | null;
543
+ /** Manifest-declared version, e.g. "1.2.1". */
544
+ providerVersion?: string;
545
+ /** Underlying executable name (CLI/binary providers). */
546
+ binary?: string;
547
+ /** Lifecycle label from the manifest: "Stable", "Beta", … */
548
+ status?: string;
549
+ /** One-line provider description from the manifest. */
550
+ details?: string;
551
+ /** Manifest-declared links: homepage, docs, repo, … */
552
+ links?: Record<string, string>;
553
+ }
554
+
555
+ export type ProviderTrust =
556
+ | 'user-custom'
557
+ | 'trusted'
558
+ | 'trusted-with-scripts'
559
+ | 'external-safe'
560
+ | 'external-untrusted';
529
561
 
530
562
  export interface MachineProviderCheckResult {
531
563
  ok: boolean;