@adhdev/daemon-core 0.9.82-rc.164 → 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,71 @@
1
+ export interface ExternalSource {
2
+ /** Unique short identifier, e.g. "@vendor-x". User-supplied or derived from the URL. */
3
+ name: string;
4
+ /** Full git URL (https://, git@, …). */
5
+ url: string;
6
+ /** Branch / tag / commit-ish to track. Defaults to `main`. */
7
+ ref: string;
8
+ /** ISO timestamp when first registered. */
9
+ addedAt: string;
10
+ }
11
+ export interface ExternalSourcesFile {
12
+ /** Schema version — bump if shape changes. */
13
+ schema: 1;
14
+ sources: ExternalSource[];
15
+ }
16
+ /**
17
+ * Per-type active source selection. Only types reachable from more than
18
+ * one source need an entry — single-source types have no ambiguity.
19
+ *
20
+ * Shape: { active: { "<type>": "<source-name>" } }
21
+ */
22
+ export interface ProvidersActiveFile {
23
+ schema: 1;
24
+ active: Record<string, string>;
25
+ }
26
+ export declare function externalRoot(): string;
27
+ export declare function sourcesFilePath(): string;
28
+ export declare function activeFilePath(): string;
29
+ export declare function loadExternalSources(): ExternalSourcesFile;
30
+ export declare function saveExternalSources(file: ExternalSourcesFile): void;
31
+ export declare function loadProvidersActive(): ProvidersActiveFile;
32
+ export declare function saveProvidersActive(file: ProvidersActiveFile): void;
33
+ /**
34
+ * Derive a short identifier from a git URL when the user didn't supply one.
35
+ * https://github.com/vendor/extra-providers.git → "@vendor-extra-providers"
36
+ * git@github.com:vendor/extra-providers → "@vendor-extra-providers"
37
+ *
38
+ * Idempotent; safe to call before validation since it produces a string
39
+ * regardless of input shape.
40
+ */
41
+ export declare function deriveSourceName(url: string): string;
42
+ /**
43
+ * Return the per-source category/type tree currently on disk under
44
+ * ~/.adhdev/external/<source>/. Used by the conflict detector and by
45
+ * list_provider_sources.
46
+ */
47
+ export interface SourceInventoryEntry {
48
+ sourceName: string;
49
+ /** Map: category → list of provider types. */
50
+ providers: Record<string, string[]>;
51
+ }
52
+ export declare function inventoryExternalSources(): SourceInventoryEntry[];
53
+ /**
54
+ * For a given category+type, list every source that currently exposes it.
55
+ * Returns source names in disk-walk order; callers can use the first one
56
+ * when no explicit active selection exists.
57
+ */
58
+ export declare function sourcesProviding(category: string, type: string): string[];
59
+ /**
60
+ * Resolve which source should be active for a given category+type.
61
+ * - If exactly one source provides it → that source.
62
+ * - If multiple sources provide it → the one named in providers-active.json
63
+ * (when present) or null (ambiguous; loader should warn and pick
64
+ * the first deterministically so daemon doesn't refuse to boot).
65
+ * - If none → null.
66
+ */
67
+ export declare function resolveActiveSource(category: string, type: string, activeFile?: ProvidersActiveFile): {
68
+ source: string | null;
69
+ ambiguous: boolean;
70
+ candidates: string[];
71
+ };
@@ -86,6 +86,7 @@ export declare class ProviderLoader {
86
86
  */
87
87
  probeStarts?: string[];
88
88
  });
89
+ private migrateMarketplaceDirToExternal;
89
90
  private log;
90
91
  private debugLog;
91
92
  /**
@@ -130,9 +131,12 @@ export declare class ProviderLoader {
130
131
  resolveProviderFile(type: string, ...segments: string[]): string | null;
131
132
  /**
132
133
  * Load all providers (3-tier priority)
133
- * 1. .upstream/ (GitHub auto-download — primary source)
134
- * 2. User custom (~/.adhdev/providers/ excluding .upstream)
135
- * User custom always wins (highest priority).
134
+ * 1. ~/.adhdev/providers/.upstream/ official git, auto-synced
135
+ * 2. ~/.adhdev/external/ 3rd-party git sources, user-added,
136
+ * bundled providers may include arbitrary JS (untrusted by default)
137
+ * 3. ~/.adhdev/providers/ (excluding .upstream) — user-authored customs,
138
+ * always wins
139
+ * Highest priority listed last (overwrites earlier loads).
136
140
  * If .upstream/ is empty, call fetchLatest() before loadAll().
137
141
  */
138
142
  loadAll(): void;
@@ -0,0 +1,31 @@
1
+ export type ProviderTrust = 'user-custom' | 'trusted' | 'trusted-with-scripts' | 'external-safe' | 'external-untrusted';
2
+ export type ProviderLayer = 'user' | 'upstream' | 'external';
3
+ export interface ProviderManifestShape {
4
+ /** Has a `tui` block — SDK builders consume it as code paths. */
5
+ hasTui: boolean;
6
+ /** Has a non-empty `overrides` object — JS override paths. */
7
+ hasOverrides: boolean;
8
+ /** compatibility[].scriptDir or defaultScriptDir is set — JS scripts dir. */
9
+ hasScriptDir: boolean;
10
+ }
11
+ /**
12
+ * Inspect a manifest to decide whether it ships JavaScript hooks.
13
+ * Cheap; pure; safe to call on every provider load.
14
+ */
15
+ export declare function inspectManifestShape(manifest: Record<string, unknown>): ProviderManifestShape;
16
+ /**
17
+ * Classify trust given the layer the provider was loaded from + the
18
+ * manifest's JS-hook footprint.
19
+ */
20
+ export declare function classifyTrust(layer: ProviderLayer, shape: ProviderManifestShape): ProviderTrust;
21
+ /**
22
+ * Returns true when activation should require an explicit user confirm.
23
+ * Today only `external-untrusted` qualifies; future trust levels may
24
+ * fold in here.
25
+ */
26
+ export declare function requiresConfirmation(trust: ProviderTrust): boolean;
27
+ /**
28
+ * Render a short human-readable rationale for the trust tag. Used by
29
+ * the dashboard's confirmation modal and the provider catalog tooltip.
30
+ */
31
+ export declare function describeTrust(trust: ProviderTrust): string;
@@ -24,7 +24,7 @@ export declare const V1_PRIMITIVE_CATALOG: Readonly<{
24
24
  readonly common: readonly ["adhdev:common/setting-boolean@1", "adhdev:common/setting-number@1", "adhdev:common/setting-string@1", "adhdev:common/setting-select@1", "adhdev:common/capability-input@1", "adhdev:common/capability-output@1", "adhdev:common/capability-controls@1", "adhdev:common/auth-env-var@1", "adhdev:common/auth-cli-command@1", "adhdev:common/spawn@1", "adhdev:common/timeouts@1", "adhdev:common/resume@1", "adhdev:common/mesh-coordinator@1"];
25
25
  readonly override: readonly ["adhdev:override/cli-parse-session@1", "adhdev:override/cli-detect-status@1", "adhdev:override/cli-parse-approval@1", "adhdev:override/cli-parse-output@1", "adhdev:override/cli-read-native-history@1", "adhdev:override/cli-list-native-history@1", "adhdev:override/cli-capability-handler@1"];
26
26
  }>;
27
- /** Aggregate flat list — for marketplace catalog endpoint. */
27
+ /** Aggregate flat list — for provider catalog endpoints. */
28
28
  export declare const V1_ALL_PRIMITIVES: ReadonlyArray<string>;
29
29
  /** Catalog version label exposed at `registry.adhf.dev/primitives`. */
30
30
  export declare const V1_CONTRACT_VERSION: "1.0.0";
@@ -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
  export interface ManifestValidationIssue {
@@ -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
  *
@@ -4,6 +4,15 @@ export declare class SpecCliAdapter implements CliAdapter {
4
4
  readonly cliType: string;
5
5
  readonly cliName: string;
6
6
  readonly workingDir: string;
7
+ /**
8
+ * Marker the daemon's finalization gate checks: `getStatus()` returns
9
+ * `messages: []` by design here (chat history lives in the daemon's
10
+ * native-history pipeline, not the adapter). Without this flag,
11
+ * cli-provider-instance's `missing_final_assistant` gate would stall
12
+ * every turn until the 30s safety timeout because it expects the
13
+ * adapter to surface the final assistant message.
14
+ */
15
+ readonly chatMessagesOwnedExternally: true;
7
16
  private driver;
8
17
  private spec;
9
18
  private lastEvent;
@@ -422,7 +422,33 @@ export interface AvailableProviderInfo {
422
422
  lastVerification?: MachineProviderCheckResult;
423
423
  /** Provider-declared Repo Mesh coordinator/MCP behavior. */
424
424
  meshCoordinator?: ProviderMeshCoordinatorConfig;
425
- }
425
+ /**
426
+ * Provider trust classification — derived from the on-disk layer the
427
+ * manifest came from and the shape of the manifest. Dashboards use
428
+ * this to render a trust badge and gate activation of
429
+ * `external-untrusted` providers behind a confirm modal.
430
+ */
431
+ trust?: ProviderTrust;
432
+ /** Daemon-side human-readable description of the trust value. */
433
+ trustDescription?: string;
434
+ /** True when activation needs a user-side confirmation step. */
435
+ requiresConfirmation?: boolean;
436
+ /** Which on-disk layer the manifest lives in. */
437
+ sourceLayer?: 'user' | 'upstream' | 'external';
438
+ /** For external providers, the source-name namespace it came from. */
439
+ sourceName?: string | null;
440
+ /** Manifest-declared version, e.g. "1.2.1". */
441
+ providerVersion?: string;
442
+ /** Underlying executable name (CLI/binary providers). */
443
+ binary?: string;
444
+ /** Lifecycle label from the manifest: "Stable", "Beta", … */
445
+ status?: string;
446
+ /** One-line provider description from the manifest. */
447
+ details?: string;
448
+ /** Manifest-declared links: homepage, docs, repo, … */
449
+ links?: Record<string, string>;
450
+ }
451
+ export type ProviderTrust = 'user-custom' | 'trusted' | 'trusted-with-scripts' | 'external-safe' | 'external-untrusted';
426
452
  export interface MachineProviderCheckResult {
427
453
  ok: boolean;
428
454
  stage?: 'detection' | 'runnable' | 'verification';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.164",
3
+ "version": "0.9.82-rc.166",
4
4
  "description": "ADHDev daemon core — CDP, IDE detection, providers, command execution",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -1099,6 +1099,25 @@ export class DaemonCliManager {
1099
1099
  env: args?.env,
1100
1100
  })
1101
1101
  : null;
1102
+ // Untrusted-provider gate: an external source that ships JS
1103
+ // hooks needs explicit user confirmation before its first
1104
+ // launch. Dashboards add `confirmExternalUntrusted: true` to
1105
+ // the launch args after showing the trust modal. Without
1106
+ // that ack we refuse to spawn and tell the caller why.
1107
+ const provLookup = this.providerLoader.getMeta(this.providerLoader.resolveAlias(cliType)) as any;
1108
+ const provTrust = provLookup?._sourceTrust;
1109
+ if (provTrust === 'external-untrusted' && args?.confirmExternalUntrusted !== true) {
1110
+ return {
1111
+ success: false,
1112
+ error: 'untrusted_external_provider',
1113
+ provider: {
1114
+ type: provLookup?.type ?? cliType,
1115
+ sourceName: provLookup?._sourceName ?? null,
1116
+ trust: provTrust,
1117
+ },
1118
+ hint: 'Resend launch_cli with confirmExternalUntrusted=true after the user explicitly approves running JavaScript from this 3rd-party source.',
1119
+ };
1120
+ }
1102
1121
  const started = await this.startSession(
1103
1122
  cliType,
1104
1123
  dir,
@@ -515,6 +515,10 @@ export class DaemonCommandHandler implements CommandHelpers {
515
515
  case 'uninstall_provider_manifest': return this.handleUninstallProviderManifest(args);
516
516
  case 'check_provider_updates': return this.handleCheckProviderUpdates(args);
517
517
  case 'list_installed_providers': return this.handleListInstalledProviders(args);
518
+ case 'add_provider_source': return this.handleAddProviderSource(args);
519
+ case 'remove_provider_source': return this.handleRemoveProviderSource(args);
520
+ case 'list_provider_sources': return this.handleListProviderSources(args);
521
+ case 'set_active_provider_source': return this.handleSetActiveProviderSource(args);
518
522
 
519
523
  // ─── Stream commands (stream-commands.ts) ───────────
520
524
  case 'select_session': return Stream.handleSelectSession(this, args);
@@ -575,18 +579,23 @@ export class DaemonCommandHandler implements CommandHelpers {
575
579
  }
576
580
 
577
581
  /**
578
- * Return per-provider availability so a Marketplace UI can show
579
- * "Installed" badges. Reuses the existing detection state from
582
+ * Return per-provider availability so the dashboard's provider catalog
583
+ * can show "Installed" badges. Reuses the existing detection state from
580
584
  * ProviderLoader.getMachineProviderStatus() — no probing is triggered.
581
585
  */
582
586
  private handleListProviderAvailability(_args: any): CommandResult {
583
587
  if (!this._ctx.providerLoader) {
584
588
  return { success: false, error: 'ProviderLoader not initialized' };
585
589
  }
590
+ const { describeTrust, requiresConfirmation } =
591
+ require('../providers/provider-trust.js') as typeof import('../providers/provider-trust.js');
586
592
  const loader = this._ctx.providerLoader;
587
593
  const items = loader.getAll().map((provider) => {
588
594
  const machineConfig = loader.getMachineProviderConfig(provider.type);
589
595
  const lastDetection = machineConfig.lastDetection;
596
+ const trust = (provider as any)._sourceTrust ?? 'trusted';
597
+ const layer = (provider as any)._sourceLayer ?? 'upstream';
598
+ const sourceName = (provider as any)._sourceName ?? null;
590
599
  return {
591
600
  type: provider.type,
592
601
  category: provider.category,
@@ -594,32 +603,41 @@ export class DaemonCommandHandler implements CommandHelpers {
594
603
  installed: lastDetection?.ok === true,
595
604
  detectedPath: lastDetection?.path ?? null,
596
605
  checkedAt: lastDetection?.checkedAt ?? null,
606
+ trust,
607
+ trustDescription: describeTrust(trust),
608
+ requiresConfirmation: requiresConfirmation(trust),
609
+ sourceLayer: layer,
610
+ sourceName,
597
611
  };
598
612
  });
599
613
  return { success: true, providers: items };
600
614
  }
601
615
 
602
616
  /**
603
- * Compute the *Marketplace install root*. This is always
604
- * `~/.adhdev/marketplace/` regardless of how ProviderLoader resolved its
605
- * userDir (which can point to a sibling adhdev-providers git checkout in
606
- * dev). Marketplace-installed manifests must never overwrite files in a
607
- * developer checkout, and they should be isolated from upstream/registry
608
- * sync so they survive `refresh_scripts`.
617
+ * Compute the *upstream cache root*. install_provider_manifest writes
618
+ * official-registry manifests here so the daemon's standard upstream
619
+ * layer picks them up no special handling needed at load time, and
620
+ * the manifests inherit the official-trust badge instead of the
621
+ * untrusted-external one.
622
+ *
623
+ * Path matches ProviderLoader.upstreamDir but we recompute it from
624
+ * homedir() so this method stays usable in dev where userDir can
625
+ * point at a sibling git checkout.
609
626
  */
610
- private getMarketplaceInstallRoot(): string {
627
+ private getUpstreamInstallRoot(): string {
611
628
  const os = require('os') as typeof import('os');
612
629
  const path = require('path') as typeof import('path');
613
- return path.join(os.homedir(), '.adhdev', 'marketplace');
630
+ return path.join(os.homedir(), '.adhdev', 'providers', '.upstream');
614
631
  }
615
632
 
616
633
  /**
617
634
  * Download a single provider manifest from the registry and write it to
618
- * ~/.adhdev/marketplace/{category}/{type}/provider.json.
635
+ * ~/.adhdev/providers/.upstream/{category}/{type}/provider.json.
619
636
  *
620
- * Used by the Marketplace UI's Install button. Verifies SHA-256 checksum
621
- * against the registry meta before persisting. Refuses to write outside
622
- * the marketplace root.
637
+ * Used by standalone onboarding to seed the upstream cache with the
638
+ * default provider set on first launch. Verifies SHA-256 checksum
639
+ * against the registry meta before persisting. Refuses to write
640
+ * outside the upstream root.
623
641
  *
624
642
  * Args: { type: string, category?: string, version?: string }
625
643
  * If category/version are omitted, looks up the latest from the registry.
@@ -678,13 +696,13 @@ export class DaemonCommandHandler implements CommandHelpers {
678
696
  return { success: false, error: `checksum mismatch: expected ${meta.checksum}, got ${actualChecksum}` };
679
697
  }
680
698
 
681
- // 4. Write to the marketplace install root, NOT to ProviderLoader.getUserDir():
699
+ // 4. Write to the upstream cache root, NOT to ProviderLoader.getUserDir():
682
700
  // in dev, userDir points at the sibling adhdev-providers git checkout.
683
- const installRoot = this.getMarketplaceInstallRoot();
701
+ const installRoot = this.getUpstreamInstallRoot();
684
702
  const installRootResolved = path.resolve(installRoot);
685
703
  const targetDir = path.resolve(path.join(installRoot, category, type));
686
704
  if (!targetDir.startsWith(installRootResolved + path.sep)) {
687
- return { success: false, error: 'install path escaped marketplace root' };
705
+ return { success: false, error: 'install path escaped upstream root' };
688
706
  }
689
707
  fs.mkdirSync(targetDir, { recursive: true });
690
708
  // v1 vs v0 manifest selection — v1 manifests carry an SDK
@@ -792,6 +810,17 @@ export class DaemonCommandHandler implements CommandHelpers {
792
810
  if (Array.isArray(manifest.compatibility)) {
793
811
  for (const c of manifest.compatibility) {
794
812
  if (typeof c?.scriptDir === 'string') scriptDirs.add(c.scriptDir);
813
+ // Spec-driven providers (claude/codex/agy/…) point at a
814
+ // single specs/<version>.json instead of a scriptDir.
815
+ // The whole specs/ directory needs to come down so the
816
+ // spec adapter can resolve the file at runtime — without
817
+ // this, install_provider_manifest leaves the marketplace
818
+ // copy spec-less and provider-loader falls back to the
819
+ // legacy tui-based ProviderCliAdapter.
820
+ if (typeof c?.spec === 'string' && c.spec.includes('/')) {
821
+ const dir = c.spec.substring(0, c.spec.lastIndexOf('/'));
822
+ if (dir) scriptDirs.add(dir);
823
+ }
795
824
  }
796
825
  }
797
826
  if (manifest.overrides && typeof manifest.overrides === 'object' && !Array.isArray(manifest.overrides)) {
@@ -956,9 +985,12 @@ export class DaemonCommandHandler implements CommandHelpers {
956
985
  }
957
986
 
958
987
  /**
959
- * Remove a provider manifest from the marketplace install root
960
- * (~/.adhdev/marketplace/{category}/{type}/). Refuses to touch anything
961
- * outside that root.
988
+ * Remove a provider manifest from the upstream cache root
989
+ * (~/.adhdev/providers/.upstream/{category}/{type}/). Refuses to touch
990
+ * anything outside that root. Used by onboarding to opt out of a
991
+ * provider the user doesn't want; the dashboard no longer exposes a
992
+ * per-provider uninstall button (external sources are removed as a
993
+ * whole via remove_provider_source).
962
994
  */
963
995
  private async handleUninstallProviderManifest(args: any): Promise<CommandResult> {
964
996
  const type = typeof args?.type === 'string' ? args.type : '';
@@ -975,12 +1007,12 @@ export class DaemonCommandHandler implements CommandHelpers {
975
1007
  const path = require('path') as typeof import('path');
976
1008
 
977
1009
  try {
978
- const installRoot = this.getMarketplaceInstallRoot();
1010
+ const installRoot = this.getUpstreamInstallRoot();
979
1011
  const installRootResolved = path.resolve(installRoot);
980
1012
  const targetDir = path.resolve(path.join(installRoot, category, type));
981
1013
 
982
1014
  if (!targetDir.startsWith(installRootResolved + path.sep)) {
983
- return { success: false, error: 'refusing to delete outside marketplace root' };
1015
+ return { success: false, error: 'refusing to delete outside upstream root' };
984
1016
  }
985
1017
  if (!fs.existsSync(targetDir)) {
986
1018
  return { success: false, error: 'not installed' };
@@ -1000,7 +1032,7 @@ export class DaemonCommandHandler implements CommandHelpers {
1000
1032
  }
1001
1033
 
1002
1034
  /**
1003
- * Return everything currently installed in ~/.adhdev/marketplace/ with its
1035
+ * Return everything currently installed in the upstream cache with its
1004
1036
  * version. This is the "what does this daemon have" answer used both by
1005
1037
  * the UI and by the update checker.
1006
1038
  */
@@ -1008,7 +1040,7 @@ export class DaemonCommandHandler implements CommandHelpers {
1008
1040
  const fs = require('fs') as typeof import('fs');
1009
1041
  const path = require('path') as typeof import('path');
1010
1042
 
1011
- const installRoot = this.getMarketplaceInstallRoot();
1043
+ const installRoot = this.getUpstreamInstallRoot();
1012
1044
  if (!fs.existsSync(installRoot)) return { success: true, providers: [] };
1013
1045
 
1014
1046
  const CATEGORIES = ['cli', 'ide', 'extension', 'acp'] as const;
@@ -1101,6 +1133,236 @@ export class DaemonCommandHandler implements CommandHelpers {
1101
1133
  return { success: true, providers: checks };
1102
1134
  }
1103
1135
 
1136
+ // ─── External provider sources (3rd-party git URLs) ──────────────
1137
+
1138
+ /**
1139
+ * Register a new external provider source. The daemon clones the repo
1140
+ * to ~/.adhdev/external/<name>/, walks it once to detect provided
1141
+ * types, and surfaces any conflicts with already-installed types so
1142
+ * the dashboard can ask the user how to resolve them.
1143
+ *
1144
+ * Args: { url: string, ref?: string, name?: string }
1145
+ * - url: https://, git@, or any git-cloneable URL
1146
+ * - ref: branch/tag/commit (default "main")
1147
+ * - name: short identifier (default derived from URL)
1148
+ *
1149
+ * Returns: { source, providers, conflicts }
1150
+ * - conflicts: list of types this new source provides that another
1151
+ * source already exposes. UI uses this to prompt for active-source
1152
+ * selection before the load takes effect.
1153
+ */
1154
+ private async handleAddProviderSource(args: any): Promise<CommandResult> {
1155
+ const url = typeof args?.url === 'string' ? args.url.trim() : '';
1156
+ if (!url) return { success: false, error: 'url is required' };
1157
+ const ref = typeof args?.ref === 'string' && args.ref.trim() ? args.ref.trim() : 'main';
1158
+
1159
+ // Defense in depth against argv flag-smuggling: reject anything that
1160
+ // looks like a git option in either positional. The `--` end-of-options
1161
+ // sentinel below catches accidental cases, but rejecting early gives
1162
+ // a clear error message and stops obviously malicious inputs from
1163
+ // even touching git.
1164
+ if (url.startsWith('-')) return { success: false, error: 'url must not start with "-"' };
1165
+ if (ref.startsWith('-')) return { success: false, error: 'ref must not start with "-"' };
1166
+ // Whitelist the protocols we'll forward to git. Anything else
1167
+ // (file://, ext-protocol-handlers, …) is refused outright.
1168
+ if (!/^(https?:\/\/|git@[a-z0-9._-]+:)[a-z0-9._@:/~\-]+$/i.test(url)) {
1169
+ return { success: false, error: 'url must be https://… or git@host:… and contain only URL-safe characters' };
1170
+ }
1171
+ // Refs are git refnames — letters, digits, slashes, dots, underscores,
1172
+ // dashes. Rejects e.g. spaces, semicolons, backticks, shell metas.
1173
+ if (!/^[A-Za-z0-9._/-]+$/.test(ref)) {
1174
+ return { success: false, error: 'ref must contain only [A-Za-z0-9._/-]' };
1175
+ }
1176
+
1177
+ const ext = require('../providers/external-sources.js') as typeof import('../providers/external-sources.js');
1178
+ const requestedName = typeof args?.name === 'string' && args.name.trim() ? args.name.trim() : ext.deriveSourceName(url);
1179
+ if (!/^@[a-z0-9_-]+$/i.test(requestedName)) {
1180
+ return { success: false, error: 'name must match @[a-z0-9_-]+' };
1181
+ }
1182
+
1183
+ const fs = require('node:fs') as typeof import('node:fs');
1184
+ const path = require('node:path') as typeof import('node:path');
1185
+ const { spawnSync } = require('node:child_process') as typeof import('node:child_process');
1186
+
1187
+ const file = ext.loadExternalSources();
1188
+ if (file.sources.some(s => s.name === requestedName)) {
1189
+ return { success: false, error: `source name "${requestedName}" is already registered` };
1190
+ }
1191
+ if (file.sources.some(s => s.url === url && s.ref === ref)) {
1192
+ return { success: false, error: `source url+ref already registered (use a different name to track another ref)` };
1193
+ }
1194
+
1195
+ const sourceDir = path.join(ext.externalRoot(), requestedName);
1196
+ if (!fs.existsSync(ext.externalRoot())) fs.mkdirSync(ext.externalRoot(), { recursive: true });
1197
+ if (fs.existsSync(sourceDir)) {
1198
+ return { success: false, error: `directory already exists: ${sourceDir} (rename or remove first)` };
1199
+ }
1200
+
1201
+ // `--` sentinel after the option list so any future regex-bypassing
1202
+ // url that *did* start with `-` would still be treated as a path
1203
+ // by git rather than an option.
1204
+ const clone = spawnSync('git', ['clone', '--depth=1', '--branch', ref, '--', url, sourceDir], {
1205
+ encoding: 'utf-8',
1206
+ stdio: ['ignore', 'pipe', 'pipe'],
1207
+ timeout: 60_000,
1208
+ });
1209
+ if (clone.status !== 0) {
1210
+ try { fs.rmSync(sourceDir, { recursive: true, force: true }); } catch { /* best-effort */ }
1211
+ return { success: false, error: `git clone failed: ${(clone.stderr || clone.stdout || '').trim() || 'unknown error'}` };
1212
+ }
1213
+
1214
+ const source: import('../providers/external-sources.js').ExternalSource = {
1215
+ name: requestedName,
1216
+ url,
1217
+ ref,
1218
+ addedAt: new Date().toISOString(),
1219
+ };
1220
+ ext.saveExternalSources({ schema: 1, sources: [...file.sources, source] });
1221
+
1222
+ // Detect type-level conflicts with what's already on disk after this clone.
1223
+ const inventory = ext.inventoryExternalSources();
1224
+ const conflicts: { category: string; type: string; sources: string[] }[] = [];
1225
+ const newEntry = inventory.find(e => e.sourceName === requestedName);
1226
+ if (newEntry) {
1227
+ for (const [category, types] of Object.entries(newEntry.providers)) {
1228
+ for (const type of types) {
1229
+ const sources = ext.sourcesProviding(category, type);
1230
+ if (sources.length > 1) conflicts.push({ category, type, sources });
1231
+ }
1232
+ }
1233
+ }
1234
+
1235
+ // Hot-reload so the daemon picks up the new providers immediately.
1236
+ if (this._ctx.providerLoader) {
1237
+ this._ctx.providerLoader.reload();
1238
+ this._ctx.providerLoader.registerToDetector();
1239
+ }
1240
+
1241
+ return {
1242
+ success: true,
1243
+ source,
1244
+ providers: newEntry?.providers ?? {},
1245
+ conflicts,
1246
+ };
1247
+ }
1248
+
1249
+ /**
1250
+ * Remove a registered external source. Deletes the clone directory and
1251
+ * any active-source entry pointing to it.
1252
+ *
1253
+ * Args: { name: string }
1254
+ */
1255
+ private async handleRemoveProviderSource(args: any): Promise<CommandResult> {
1256
+ const name = typeof args?.name === 'string' ? args.name.trim() : '';
1257
+ if (!name) return { success: false, error: 'name is required' };
1258
+ const ext = require('../providers/external-sources.js') as typeof import('../providers/external-sources.js');
1259
+
1260
+ const fs = require('node:fs') as typeof import('node:fs');
1261
+ const path = require('node:path') as typeof import('node:path');
1262
+ const file = ext.loadExternalSources();
1263
+ const match = file.sources.find(s => s.name === name);
1264
+ if (!match) return { success: false, error: `source "${name}" not registered` };
1265
+
1266
+ const sourceDir = path.join(ext.externalRoot(), name);
1267
+ if (fs.existsSync(sourceDir)) {
1268
+ try { fs.rmSync(sourceDir, { recursive: true, force: true }); }
1269
+ catch (e: any) { return { success: false, error: `failed to delete ${sourceDir}: ${e?.message || e}` }; }
1270
+ }
1271
+
1272
+ ext.saveExternalSources({
1273
+ schema: 1,
1274
+ sources: file.sources.filter(s => s.name !== name),
1275
+ });
1276
+
1277
+ // Drop any active-source entries that pointed at this source.
1278
+ const active = ext.loadProvidersActive();
1279
+ const filteredActive: Record<string, string> = {};
1280
+ for (const [type, src] of Object.entries(active.active)) {
1281
+ if (src !== name) filteredActive[type] = src;
1282
+ }
1283
+ ext.saveProvidersActive({ schema: 1, active: filteredActive });
1284
+
1285
+ if (this._ctx.providerLoader) {
1286
+ this._ctx.providerLoader.reload();
1287
+ this._ctx.providerLoader.registerToDetector();
1288
+ }
1289
+
1290
+ return { success: true, removed: { name } };
1291
+ }
1292
+
1293
+ /**
1294
+ * List registered external sources + each source's currently installed
1295
+ * providers + the active selection for any conflicting types. Used by
1296
+ * the dashboard's "Sources" tab.
1297
+ */
1298
+ private handleListProviderSources(_args: any): CommandResult {
1299
+ const ext = require('../providers/external-sources.js') as typeof import('../providers/external-sources.js');
1300
+ const file = ext.loadExternalSources();
1301
+ const inventory = ext.inventoryExternalSources();
1302
+ const active = ext.loadProvidersActive();
1303
+
1304
+ // Build a per-source view + flag types that have ambiguity.
1305
+ const sources = file.sources.map(s => {
1306
+ const inv = inventory.find(e => e.sourceName === s.name);
1307
+ return {
1308
+ ...s,
1309
+ providers: inv?.providers ?? {},
1310
+ };
1311
+ });
1312
+
1313
+ // Compute conflicts globally — any type provided by ≥ 2 sources.
1314
+ const conflictMap = new Map<string, { category: string; sources: string[] }>();
1315
+ for (const inv of inventory) {
1316
+ for (const [category, types] of Object.entries(inv.providers)) {
1317
+ for (const type of types) {
1318
+ const candidates = ext.sourcesProviding(category, type);
1319
+ if (candidates.length > 1 && !conflictMap.has(type)) {
1320
+ conflictMap.set(type, { category, sources: candidates });
1321
+ }
1322
+ }
1323
+ }
1324
+ }
1325
+ const conflicts = [...conflictMap.entries()].map(([type, info]) => ({
1326
+ type,
1327
+ category: info.category,
1328
+ candidates: info.sources,
1329
+ active: active.active[type] ?? null,
1330
+ }));
1331
+
1332
+ return { success: true, sources, conflicts };
1333
+ }
1334
+
1335
+ /**
1336
+ * Pick which source's copy of a conflicting provider type is active.
1337
+ * Other sources' copies stay on disk but the loader ignores them.
1338
+ *
1339
+ * Args: { type: string, sourceName: string }
1340
+ */
1341
+ private handleSetActiveProviderSource(args: any): CommandResult {
1342
+ const type = typeof args?.type === 'string' ? args.type.trim() : '';
1343
+ const sourceName = typeof args?.sourceName === 'string' ? args.sourceName.trim() : '';
1344
+ if (!type || !sourceName) return { success: false, error: 'type and sourceName are required' };
1345
+ const ext = require('../providers/external-sources.js') as typeof import('../providers/external-sources.js');
1346
+
1347
+ // Validate: the source must actually provide that type.
1348
+ const inventory = ext.inventoryExternalSources();
1349
+ const entry = inventory.find(e => e.sourceName === sourceName);
1350
+ if (!entry) return { success: false, error: `source "${sourceName}" not found` };
1351
+ const provided = Object.values(entry.providers).some(types => types.includes(type));
1352
+ if (!provided) return { success: false, error: `source "${sourceName}" does not provide type "${type}"` };
1353
+
1354
+ const active = ext.loadProvidersActive();
1355
+ active.active[type] = sourceName;
1356
+ ext.saveProvidersActive(active);
1357
+
1358
+ if (this._ctx.providerLoader) {
1359
+ this._ctx.providerLoader.reload();
1360
+ this._ctx.providerLoader.registerToDetector();
1361
+ }
1362
+
1363
+ return { success: true, type, sourceName };
1364
+ }
1365
+
1104
1366
  // ─── DevServer HTTP proxy helpers ─────────────────
1105
1367
  // These bridge WS commands to the DevServer REST API (localhost:19280)
1106
1368