@aion0/forge 0.10.39 → 0.10.41
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/CLAUDE.md +1 -1
- package/RELEASE_NOTES.md +83 -6
- package/app/api/bridge-info/route.ts +34 -0
- package/app/api/connectors/[id]/test/route.ts +14 -0
- package/app/api/connectors/import-config-template/route.ts +103 -13
- package/app/api/enterprise-keys/route.ts +204 -0
- package/app/api/marketplace/sync-all/route.ts +28 -0
- package/app/api/monitor/route.ts +29 -6
- package/app/api/onboarding/route.ts +897 -23
- package/app/api/projects/clone/route.ts +51 -0
- package/app/api/settings/route.ts +11 -2
- package/bin/forge-server.mjs +189 -30
- package/cli/mw.mjs +16 -6
- package/cli/mw.ts +19 -6
- package/components/ConnectorsPanel.tsx +85 -13
- package/components/CraftTerminal.tsx +12 -3
- package/components/Dashboard.tsx +55 -17
- package/components/DocTerminal.tsx +12 -6
- package/components/EnterpriseBadge.tsx +420 -0
- package/components/LoginStatusPanel.tsx +15 -1
- package/components/OnboardingWizard.tsx +418 -31
- package/components/SettingsModal.tsx +382 -63
- package/components/SkillsPanel.tsx +116 -91
- package/components/WebTerminal.tsx +36 -13
- package/dev-test.sh +34 -1
- package/install.sh +29 -2
- package/lib/agents/claude-adapter.ts +18 -4
- package/lib/agents/index.ts +33 -4
- package/lib/auth/login-status.ts +14 -0
- package/lib/chat/agent-loop.ts +23 -1
- package/lib/chat/protocols/http.ts +15 -2
- package/lib/chat/tool-dispatcher.ts +163 -1
- package/lib/connectors/registry.ts +69 -4
- package/lib/connectors/sync.ts +536 -138
- package/lib/connectors/test-runner.ts +21 -3
- package/lib/connectors/types.ts +36 -4
- package/lib/connectors/wizard-template.ts +161 -0
- package/lib/dirs.ts +5 -0
- package/lib/enterprise-known.ts +34 -0
- package/lib/enterprise-secret.ts +87 -0
- package/lib/enterprise.ts +208 -0
- package/lib/help-docs/00-overview.md +12 -0
- package/lib/help-docs/01-settings.md +47 -1
- package/lib/help-docs/17-connectors.md +25 -22
- package/lib/help-docs/CLAUDE.md +1 -0
- package/lib/init.ts +13 -6
- package/lib/marketplace-sync.ts +70 -0
- package/lib/memory/temper-provision.ts +92 -0
- package/lib/pipeline-gc.ts +5 -2
- package/lib/pipeline.ts +26 -21
- package/lib/plugins/templates.ts +76 -3
- package/lib/projects.ts +85 -0
- package/lib/settings.ts +10 -0
- package/lib/telegram-bot.ts +14 -2
- package/lib/workflow-marketplace.ts +174 -108
- package/package.json +1 -1
- package/{middleware.ts → proxy.ts} +2 -1
- package/src/core/db/database.ts +8 -2
- package/templates/connector-config-template.json +0 -7
|
@@ -396,6 +396,16 @@ function buildFormBody(spec: string | Record<string, unknown> | undefined, injec
|
|
|
396
396
|
// the actual key+value choices to per-user instance config without
|
|
397
397
|
// hardcoding them in the manifest. Rows with empty name or value
|
|
398
398
|
// are dropped.
|
|
399
|
+
//
|
|
400
|
+
// Values pass through expandAllTokens — Jenkins' inject_params
|
|
401
|
+
// typically reads cross-connector refs like
|
|
402
|
+
// { name: TOKEN_USER, value: "{connector:gitlab.token_name}" }
|
|
403
|
+
// { name: TOKEN_PASSWORD, value: "{connector:gitlab.token}" }
|
|
404
|
+
// and we want the runtime gitlab value, not the literal string.
|
|
405
|
+
// Key gets the same treatment so connectors can template that too.
|
|
406
|
+
// Rows where either side resolves to empty (or stays an unresolved
|
|
407
|
+
// {settings|args}.X) get dropped — same guard as the static inject
|
|
408
|
+
// branch above.
|
|
399
409
|
if (injectFrom) {
|
|
400
410
|
let rows: any = (settings as any)[injectFrom];
|
|
401
411
|
if (typeof rows === 'string') {
|
|
@@ -404,9 +414,12 @@ function buildFormBody(spec: string | Record<string, unknown> | undefined, injec
|
|
|
404
414
|
if (Array.isArray(rows)) {
|
|
405
415
|
for (const row of rows) {
|
|
406
416
|
if (!row || typeof row !== 'object') continue;
|
|
407
|
-
const
|
|
408
|
-
const
|
|
417
|
+
const rawK = typeof row.name === 'string' ? row.name : '';
|
|
418
|
+
const rawV = typeof row.value === 'string' ? row.value : (row.value == null ? '' : String(row.value));
|
|
419
|
+
const k = expandAllTokens(rawK, settings, args).trim();
|
|
420
|
+
const v = expandAllTokens(rawV, settings, args);
|
|
409
421
|
if (!k || !v) continue;
|
|
422
|
+
if (/\{(settings|args|connector:)/.test(k) || /\{(settings|args|connector:)/.test(v)) continue;
|
|
410
423
|
obj[k] = v;
|
|
411
424
|
}
|
|
412
425
|
}
|
|
@@ -19,7 +19,8 @@ import {
|
|
|
19
19
|
getConnectorEntries,
|
|
20
20
|
} from '../connectors/registry';
|
|
21
21
|
import { expandSettingsTokens } from '../plugins/templates';
|
|
22
|
-
import
|
|
22
|
+
import { resolveWizardTemplate } from '../connectors/wizard-template';
|
|
23
|
+
import type { ConnectorEntry, ConnectorTool, ConnectorDefinition } from '../connectors/types';
|
|
23
24
|
|
|
24
25
|
export interface ToolCall {
|
|
25
26
|
id: string;
|
|
@@ -39,6 +40,44 @@ export type BuiltinHandler = (input: unknown) => Promise<string>;
|
|
|
39
40
|
const BUILTINS: Record<string, BuiltinHandler> = {
|
|
40
41
|
get_current_time: async () => new Date().toISOString(),
|
|
41
42
|
|
|
43
|
+
// Register an enterprise marketplace key so subsequent connector /
|
|
44
|
+
// workflow lookups overlay the named tenant's private repo on top of
|
|
45
|
+
// the public marketplace. The user typically pastes a key string like
|
|
46
|
+
// `fortinet:github_pat_…` directly into chat — see lib/enterprise.ts for
|
|
47
|
+
// the two accepted formats. Triggers an immediate sync so the new
|
|
48
|
+
// source is visible without manual Refresh.
|
|
49
|
+
add_enterprise_key: async (input) => {
|
|
50
|
+
const { key } = (input as { key?: string } | undefined) || {};
|
|
51
|
+
if (!key || typeof key !== 'string' || !key.trim()) {
|
|
52
|
+
return 'Error: key is required (paste the full enterprise key string).';
|
|
53
|
+
}
|
|
54
|
+
const { addEnterpriseKey } = await import('../enterprise');
|
|
55
|
+
const r = addEnterpriseKey(key.trim());
|
|
56
|
+
if (!r.ok) return `Could not add key: ${r.reason}`;
|
|
57
|
+
|
|
58
|
+
// Best-effort sync — failures here don't undo the key add; user can
|
|
59
|
+
// hit Refresh in Settings later. Connector first (since enterprise
|
|
60
|
+
// registry.json lives in the connector cache), then workflow
|
|
61
|
+
// (re-reads that same cache for recipes/pipelines).
|
|
62
|
+
const syncDetail: string[] = [];
|
|
63
|
+
try {
|
|
64
|
+
const { syncRegistry } = await import('../connectors/sync');
|
|
65
|
+
const s = await syncRegistry({ refreshInstalled: false });
|
|
66
|
+
syncDetail.push(`connectors: ${s.ok ? `${s.registry_count} entries` : `failed (${s.error})`}`);
|
|
67
|
+
} catch (e) {
|
|
68
|
+
syncDetail.push(`connectors: failed (${(e as Error).message})`);
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
const { syncMarketplace } = await import('../workflow-marketplace');
|
|
72
|
+
const w = await syncMarketplace();
|
|
73
|
+
syncDetail.push(`workflows: ${w.ok ? `${w.recipes} recipes + ${w.pipelines} pipelines` : `failed (${w.error})`}`);
|
|
74
|
+
} catch (e) {
|
|
75
|
+
syncDetail.push(`workflows: failed (${(e as Error).message})`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return `✓ Added enterprise key for tenant "${r.tenant_id}". Sync: ${syncDetail.join('; ')}. The new source will surface in Settings → Marketplace Providers and in any marketplace listing.`;
|
|
79
|
+
},
|
|
80
|
+
|
|
42
81
|
// Trigger a pipeline workflow defined under flows/<name>.yaml. Mirrors
|
|
43
82
|
// the same MCP tool used by Claude Code tasks (forge-mcp-server.ts), but
|
|
44
83
|
// available directly inside the chat agent so users can say "run the
|
|
@@ -504,6 +543,17 @@ export const BUILTIN_TOOL_DEFS: BuiltinToolDef[] = [
|
|
|
504
543
|
description: 'Get the current local time as an ISO 8601 string. Use whenever the user asks about "now" or "today".',
|
|
505
544
|
input_schema: { type: 'object', properties: {} },
|
|
506
545
|
},
|
|
546
|
+
{
|
|
547
|
+
name: 'add_enterprise_key',
|
|
548
|
+
description: 'Register an enterprise marketplace key the user has pasted (e.g. "fortinet:github_pat_…" short form, or "github_pat_…@github.com/owner/forge-enterprise-agent-name" long form). Adds the tenant\'s private repo as a higher-priority marketplace source over public forge-connectors / forge-workflow. Call this when the user says something like "here\'s my enterprise key", "我有 fortinet 的 key 给你", or pastes a string matching either format. Triggers an immediate marketplace sync; the new source becomes visible in Settings → Marketplace Providers and in any marketplace listing. DO NOT invent or hallucinate keys — only call with the exact string the user provided.',
|
|
549
|
+
input_schema: {
|
|
550
|
+
type: 'object',
|
|
551
|
+
properties: {
|
|
552
|
+
key: { type: 'string', description: 'The enterprise key string verbatim, as pasted by the user.' },
|
|
553
|
+
},
|
|
554
|
+
required: ['key'],
|
|
555
|
+
},
|
|
556
|
+
},
|
|
507
557
|
{
|
|
508
558
|
name: 'trigger_pipeline',
|
|
509
559
|
description: 'Trigger a Forge pipeline workflow (YAML under flows/). Two-step usage: (1) call with NO args first — returns every workflow + its input schema (which fields are required vs have defaults). (2) call again with workflow=<name> and input={...} passing ONLY required fields and any optional fields the user explicitly specified. NEVER pass invented placeholder values for optional fields with defaults — omit them and the default is used. On success returns JSON: {ok, pipeline_id, workflow, status, terminal, errors?, warning?, hint?}. If the run is still running, follow the hint — call start_watch on get_pipeline_status and STOP polling in this conversation.',
|
|
@@ -756,6 +806,80 @@ function buildConnectorPayload(
|
|
|
756
806
|
};
|
|
757
807
|
}
|
|
758
808
|
|
|
809
|
+
/**
|
|
810
|
+
* Walk the active wizard template's `_prompts` and find the one whose
|
|
811
|
+
* `${key}` is substituted into `<connectorId>.config.<field>`. Returns
|
|
812
|
+
* the prompt's url + label hint so the chat-side preflight can mirror
|
|
813
|
+
* the wizard's "click here to create your PAT" UX.
|
|
814
|
+
*
|
|
815
|
+
* Returns null when no template / no matching prompt. Best-effort —
|
|
816
|
+
* the chat error still gives the user enough to act on.
|
|
817
|
+
*/
|
|
818
|
+
function lookupWizardPromptHint(
|
|
819
|
+
connectorId: string,
|
|
820
|
+
field: string,
|
|
821
|
+
): { url?: string; url_label?: string; prompt_label?: string } | null {
|
|
822
|
+
const resolved = resolveWizardTemplate();
|
|
823
|
+
if (!resolved) return null;
|
|
824
|
+
const tmpl = resolved.template as any;
|
|
825
|
+
if (!tmpl || typeof tmpl !== 'object') return null;
|
|
826
|
+
const connectorBlock = tmpl[connectorId];
|
|
827
|
+
const rawVal = connectorBlock?.config?.[field];
|
|
828
|
+
if (typeof rawVal !== 'string') return null;
|
|
829
|
+
const m = rawVal.match(/^\$\{([a-zA-Z0-9_]+)\}$/);
|
|
830
|
+
if (!m) return null;
|
|
831
|
+
const promptKey = m[1];
|
|
832
|
+
const p = tmpl._prompts?.[promptKey];
|
|
833
|
+
if (!p) return null;
|
|
834
|
+
return {
|
|
835
|
+
url: typeof p.url === 'string' ? p.url : undefined,
|
|
836
|
+
url_label: typeof p.url_label === 'string' ? p.url_label : undefined,
|
|
837
|
+
prompt_label: typeof p.label === 'string' ? p.label : undefined,
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* Check that every `required: true` setting on the connector has a
|
|
843
|
+
* non-empty value in the user's installed config. Returns a chat-
|
|
844
|
+
* friendly error string (with PAT-creation URL when the wizard template
|
|
845
|
+
* declares one) when something's missing, else null.
|
|
846
|
+
*/
|
|
847
|
+
function preflightConnectorSettings(
|
|
848
|
+
def: ConnectorDefinition,
|
|
849
|
+
settings: Record<string, any>,
|
|
850
|
+
): string | null {
|
|
851
|
+
const schema = def.settings || {};
|
|
852
|
+
const missing: Array<{ key: string; label: string; hint: ReturnType<typeof lookupWizardPromptHint> }> = [];
|
|
853
|
+
for (const [key, fdef] of Object.entries(schema)) {
|
|
854
|
+
if (!fdef?.required) continue;
|
|
855
|
+
const v = settings?.[key];
|
|
856
|
+
const empty = v == null
|
|
857
|
+
|| (typeof v === 'string' && v.trim() === '')
|
|
858
|
+
|| (Array.isArray(v) && v.length === 0);
|
|
859
|
+
if (!empty) continue;
|
|
860
|
+
missing.push({
|
|
861
|
+
key,
|
|
862
|
+
label: fdef.label || key,
|
|
863
|
+
hint: lookupWizardPromptHint(def.id, key),
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
if (missing.length === 0) return null;
|
|
867
|
+
|
|
868
|
+
const lines = [
|
|
869
|
+
`${def.name} isn't configured yet — needs ${missing.length === 1 ? '1 field' : `${missing.length} fields`} before tools can run:`,
|
|
870
|
+
'',
|
|
871
|
+
];
|
|
872
|
+
for (const m of missing) {
|
|
873
|
+
lines.push(`• ${m.label} (${m.key})`);
|
|
874
|
+
if (m.hint?.url) {
|
|
875
|
+
lines.push(` ${m.hint.url_label || 'Get it here'}: ${m.hint.url}`);
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
lines.push('');
|
|
879
|
+
lines.push(`Then open Settings → Connectors → ${def.name} to paste the values in.`);
|
|
880
|
+
return lines.join('\n');
|
|
881
|
+
}
|
|
882
|
+
|
|
759
883
|
// ─── Public entry ─────────────────────────────────────────
|
|
760
884
|
|
|
761
885
|
export interface DispatchOptions {
|
|
@@ -856,6 +980,44 @@ export async function dispatchTool(
|
|
|
856
980
|
effectiveSettings = { ...located.settings, ...inst };
|
|
857
981
|
}
|
|
858
982
|
|
|
983
|
+
// Post-overlay arg defaults — for any tool parameter the LLM omitted,
|
|
984
|
+
// check effectiveSettings for a `default_<paramName>` field and use
|
|
985
|
+
// its value. Lets connectors declare instance-level defaults (e.g.
|
|
986
|
+
// Jenkins `default_job_path = "fortinac-build-7.6-token"`) so users
|
|
987
|
+
// can say "build fortinac" without spelling out the job path every
|
|
988
|
+
// time. Strict: only string args, only when missing or blank, only
|
|
989
|
+
// when the default is non-empty.
|
|
990
|
+
// Stem-strip param + setting suffixes so default_project_path can fill a
|
|
991
|
+
// project_id arg (GitLab accepts URL-encoded paths as :id, and the wizard
|
|
992
|
+
// baked the user's default as `_path`). Exact match wins; stem match is
|
|
993
|
+
// the safety net.
|
|
994
|
+
const stem = (n: string) => n.replace(/_(id|path|name|key|slug)$/, '');
|
|
995
|
+
for (const pname of Object.keys(located.tool.parameters || {})) {
|
|
996
|
+
const current = argInput[pname];
|
|
997
|
+
if (current != null && !(typeof current === 'string' && current.trim() === '')) continue;
|
|
998
|
+
let fallback = (effectiveSettings as any)?.[`default_${pname}`];
|
|
999
|
+
if (typeof fallback !== 'string' || !fallback.trim()) {
|
|
1000
|
+
const want = stem(pname);
|
|
1001
|
+
for (const k of Object.keys(effectiveSettings || {})) {
|
|
1002
|
+
if (!k.startsWith('default_')) continue;
|
|
1003
|
+
if (stem(k.slice('default_'.length)) !== want) continue;
|
|
1004
|
+
const v = (effectiveSettings as any)[k];
|
|
1005
|
+
if (typeof v === 'string' && v.trim()) { fallback = v; break; }
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
if (typeof fallback === 'string' && fallback.trim() !== '') {
|
|
1009
|
+
argInput[pname] = fallback;
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
// Preflight: any required setting blank? Bail with a wizard-style
|
|
1014
|
+
// error pointing at the credential-creation URL instead of letting
|
|
1015
|
+
// the call HTTP-401 (or browser-script silently misbehave) at the
|
|
1016
|
+
// user. Mirrors the wizard's required-field message verbatim when
|
|
1017
|
+
// a matching ${key} prompt exists.
|
|
1018
|
+
const cfgGap = preflightConnectorSettings(def, effectiveSettings);
|
|
1019
|
+
if (cfgGap) return { content: cfgGap, is_error: true };
|
|
1020
|
+
|
|
859
1021
|
try {
|
|
860
1022
|
let result: ToolResult;
|
|
861
1023
|
switch (protocol) {
|
|
@@ -107,12 +107,24 @@ export function getConnectorEntries(def: ConnectorDefinition): ConnectorEntry[]
|
|
|
107
107
|
|
|
108
108
|
/**
|
|
109
109
|
* Wire shape of `<dataDir>/connector-configs.json`:
|
|
110
|
-
* { "<id>": { config, installed_version, enabled } }
|
|
110
|
+
* { "<id>": { config, installed_version, enabled, installed_source_id } }
|
|
111
|
+
*
|
|
112
|
+
* `installed_source_id` records which marketplace source the on-disk
|
|
113
|
+
* manifest came from — `public`, `enterprise-<tenant_id>`, or undefined
|
|
114
|
+
* for install-local / pre-enterprise rows. UI uses it for the source
|
|
115
|
+
* badge; sync.ts updates it on every refresh so it stays in step with
|
|
116
|
+
* the priority-winning source.
|
|
111
117
|
*/
|
|
112
118
|
interface ConnectorConfigRow {
|
|
113
119
|
config: Record<string, unknown>;
|
|
114
120
|
installed_version: string;
|
|
115
121
|
enabled?: boolean;
|
|
122
|
+
installed_source_id?: string;
|
|
123
|
+
/** E3: which department template last wrote this row. Future
|
|
124
|
+
* wizard re-runs default the dept picker to this value so a user
|
|
125
|
+
* on FortiNAC doesn't get bumped to FortiWeb when they re-open
|
|
126
|
+
* the wizard. */
|
|
127
|
+
installed_dept?: string;
|
|
116
128
|
}
|
|
117
129
|
|
|
118
130
|
type ConfigStore = Record<string, ConnectorConfigRow>;
|
|
@@ -161,6 +173,18 @@ function transformConnectorSecrets(
|
|
|
161
173
|
|
|
162
174
|
if (t === 'secret' || t === 'password') {
|
|
163
175
|
if (typeof value !== 'string' || !value) return value;
|
|
176
|
+
// Template tokens (`{base_url}`, `{connector:gitlab.token}`,
|
|
177
|
+
// `{user.login}`, `{settings.x}`, `{secret:scope.path}`) are literal
|
|
178
|
+
// references resolved at request time by expandAllTokens. Encrypting
|
|
179
|
+
// them would (a) hide the indirection from users browsing their
|
|
180
|
+
// connector settings, and (b) treat the *resolved* value as
|
|
181
|
+
// plaintext on decrypt. Skip the encrypt step for any value that
|
|
182
|
+
// looks like a single token reference (`{[A-Za-z0-9_.:]+}`).
|
|
183
|
+
// Real PATs / api-tokens never contain `{` or `}`, so the false-
|
|
184
|
+
// positive risk on a legitimate secret value is zero.
|
|
185
|
+
if (op === 'encrypt' && /^\{[A-Za-z0-9_.:]+\}$/.test(value.trim())) {
|
|
186
|
+
return value;
|
|
187
|
+
}
|
|
164
188
|
if (op === 'encrypt') return isEncrypted(value) ? value : (() => {
|
|
165
189
|
try { return encryptSecret(value); } catch { return value; }
|
|
166
190
|
})();
|
|
@@ -241,6 +265,7 @@ function saveStore(store: ConfigStore): void {
|
|
|
241
265
|
config: encryptedConfig,
|
|
242
266
|
installed_version: row.installed_version,
|
|
243
267
|
enabled: row.enabled !== false,
|
|
268
|
+
installed_source_id: row.installed_source_id,
|
|
244
269
|
};
|
|
245
270
|
}
|
|
246
271
|
writeFileSync(configsFile(), JSON.stringify(out, null, 2), { mode: 0o600 });
|
|
@@ -253,6 +278,14 @@ export interface InstalledConnector {
|
|
|
253
278
|
config: Record<string, unknown>;
|
|
254
279
|
installed_version: string;
|
|
255
280
|
enabled: boolean;
|
|
281
|
+
/** Marketplace source that provided the on-disk manifest:
|
|
282
|
+
* `public` | `enterprise-<tenant_id>` | undefined for install-local
|
|
283
|
+
* or pre-enterprise rows. */
|
|
284
|
+
installed_source_id?: string;
|
|
285
|
+
/** E3: department name (matches template `_department_name`) the
|
|
286
|
+
* row was last applied under. Used by the wizard to default the
|
|
287
|
+
* dept picker to whatever the user last chose. */
|
|
288
|
+
installed_dept?: string;
|
|
256
289
|
}
|
|
257
290
|
|
|
258
291
|
export function isInstalled(id: string): boolean {
|
|
@@ -287,6 +320,8 @@ export function listInstalledConnectors(): InstalledConnector[] {
|
|
|
287
320
|
config: row.config || {},
|
|
288
321
|
installed_version: row.installed_version || def.version,
|
|
289
322
|
enabled: row.enabled !== false,
|
|
323
|
+
installed_source_id: row.installed_source_id,
|
|
324
|
+
installed_dept: row.installed_dept,
|
|
290
325
|
});
|
|
291
326
|
}
|
|
292
327
|
return out;
|
|
@@ -302,6 +337,8 @@ export function getInstalledConnector(id: string): InstalledConnector | null {
|
|
|
302
337
|
config: row.config || {},
|
|
303
338
|
installed_version: row.installed_version || def.version,
|
|
304
339
|
enabled: row.enabled !== false,
|
|
340
|
+
installed_source_id: row.installed_source_id,
|
|
341
|
+
installed_dept: row.installed_dept,
|
|
305
342
|
};
|
|
306
343
|
}
|
|
307
344
|
|
|
@@ -309,8 +346,18 @@ export function getInstalledConnector(id: string): InstalledConnector | null {
|
|
|
309
346
|
* Install (or upgrade) a connector by writing its manifest YAML and
|
|
310
347
|
* recording the version in the config store. Existing user config is
|
|
311
348
|
* preserved across upgrades.
|
|
349
|
+
*
|
|
350
|
+
* `opts.source_id` records which marketplace source provided this
|
|
351
|
+
* manifest. Sync passes the priority-winning source id; install-local
|
|
352
|
+
* leaves it undefined. Preserves the previous source_id when undefined
|
|
353
|
+
* is passed so a manual re-install doesn't accidentally orphan the
|
|
354
|
+
* source tracking.
|
|
312
355
|
*/
|
|
313
|
-
export function installConnector(
|
|
356
|
+
export function installConnector(
|
|
357
|
+
id: string,
|
|
358
|
+
manifestYaml: string,
|
|
359
|
+
opts: { source_id?: string } = {},
|
|
360
|
+
): boolean {
|
|
314
361
|
const def = parseManifest(manifestYaml);
|
|
315
362
|
if (!def || def.id !== id) return false;
|
|
316
363
|
const dir = join(connectorsDir(), id);
|
|
@@ -318,11 +365,12 @@ export function installConnector(id: string, manifestYaml: string): boolean {
|
|
|
318
365
|
writeFileSync(join(dir, 'manifest.yaml'), manifestYaml, { mode: 0o600 });
|
|
319
366
|
|
|
320
367
|
const store = loadStore();
|
|
321
|
-
const prev = store[id]
|
|
368
|
+
const prev = store[id];
|
|
322
369
|
store[id] = {
|
|
323
|
-
config: prev,
|
|
370
|
+
config: prev?.config || {},
|
|
324
371
|
installed_version: def.version,
|
|
325
372
|
enabled: true,
|
|
373
|
+
installed_source_id: opts.source_id !== undefined ? opts.source_id : prev?.installed_source_id,
|
|
326
374
|
};
|
|
327
375
|
saveStore(store);
|
|
328
376
|
return true;
|
|
@@ -371,6 +419,23 @@ export function setConnectorConfig(
|
|
|
371
419
|
store[id] = { ...row, config };
|
|
372
420
|
}
|
|
373
421
|
saveStore(store);
|
|
422
|
+
// Creds may have changed — drop any cached probe so the Login Status
|
|
423
|
+
// panel re-probes on next view. Dynamic import to avoid pulling the
|
|
424
|
+
// auth subsystem (and its disk I/O) into every registry consumer.
|
|
425
|
+
import('../auth/login-status').then(m => m.invalidateCachedResult(`connector:${id}`)).catch(() => {});
|
|
426
|
+
return true;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** E3: stamp the dept that owns this connector's last applied config.
|
|
430
|
+
* Called by applyConnectors when the template carries _department_name.
|
|
431
|
+
* Removing the dept = pass undefined; reading is via the underlying
|
|
432
|
+
* store row. */
|
|
433
|
+
export function setConnectorDept(id: string, dept: string | undefined): boolean {
|
|
434
|
+
const store = loadStore();
|
|
435
|
+
if (!store[id]) return false;
|
|
436
|
+
if (dept && dept.trim()) store[id].installed_dept = dept.trim();
|
|
437
|
+
else delete store[id].installed_dept;
|
|
438
|
+
saveStore(store);
|
|
374
439
|
return true;
|
|
375
440
|
}
|
|
376
441
|
|