Adapt settings client for DSH 0.2.0 configForms without breaking boot.

Soft-inject configForms/settingsScope and mark Config fields volatile so desktop 0.2.0 no longer waits forever on the removed settingsScope service.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-10-09 15:31:31 +08:00
parent 30d4df23fa
commit 9b8e873c18
8 changed files with 358 additions and 166 deletions

View file

@ -2629,9 +2629,7 @@ var zh = {
var LOCALE_NS = "settings.netxops";
var inject = [
"slots",
"locale",
"remote",
"settingsScope"
"locale"
];
function apply(ctx) {
ctx.effect(() => {
@ -2646,53 +2644,79 @@ function apply(ctx) {
};
}
}, "netxops: locales");
const card = new NetxopsCardController(ctx.settingsScope.bind({ namespace: NETXOPS_NS }), ctx);
const t = ctx.locale.bind(LOCALE_NS);
ctx.inject(["remote.credentials"], (credCtx) => {
card.setCredentialsAvailable(true);
credCtx.effect(() => {
const off = credCtx.remote.$on("credentials/reference-updated", (ref) => {
card.refreshCredential(String(ref));
});
return () => {
off();
card.setCredentialsAvailable(false);
};
}, "netxops: credential invalidations");
});
ctx.inject(["connection"], (connCtx) => {
const call = connCtx.connection?.rpc?.call?.bind(connCtx.connection.rpc);
if (typeof call !== "function") {
connCtx.logger.warn("netxops: connection.rpc.call unavailable — alarm status UI disabled");
let mounted = false;
const mountCard = (scope) => {
if (mounted)
return;
mounted = true;
const card = new NetxopsCardController(scope, ctx);
ctx.inject(["remote.credentials"], (credCtx) => {
card.setCredentialsAvailable(true);
credCtx.effect(() => {
const off = credCtx.remote.$on("credentials/reference-updated", (ref) => {
card.refreshCredential(String(ref));
});
return () => {
off();
card.setCredentialsAvailable(false);
};
}, "netxops: credential invalidations");
});
ctx.inject(["connection"], (connCtx) => {
const call = connCtx.connection?.rpc?.call?.bind(connCtx.connection.rpc);
if (typeof call !== "function") {
connCtx.logger.warn("netxops: connection.rpc.call unavailable — alarm status UI disabled");
return;
}
card.setAlarmPushRpc(call);
connCtx.effect(() => () => {
card.setAlarmPushRpc(undefined);
}, "netxops: clear alarm-push rpc");
});
const bindDirectoryPicker = (picker) => {
if (!picker || typeof picker.pick !== "function")
return;
card.setDirectoryPicker(picker);
};
bindDirectoryPicker(ctx.remote?.directoryPicker);
ctx.inject(["remote.directoryPicker"], (dpCtx) => {
const viaGet = typeof dpCtx.get === "function" ? dpCtx.get("remote.directoryPicker") : undefined;
const viaNested = dpCtx.remote?.directoryPicker;
bindDirectoryPicker(viaGet ?? viaNested);
dpCtx.effect(() => () => {
card.setDirectoryPicker(undefined);
}, "netxops: clear directory picker");
});
ctx.slots.inject("settings.section", () => ctx.slots.register({
name: "settings.section",
id: NETXOPS_NS,
order: 24,
label: () => t("title"),
locale: LOCALE_NS,
inject: () => card.inject()
}, NetxopsCard));
};
ctx.inject(["configForms"], (formsCtx) => {
const forms = formsCtx.configForms;
if (!forms || typeof forms.get !== "function") {
formsCtx.logger?.warn?.("netxops: configForms present but .get missing");
return;
}
card.setAlarmPushRpc(call);
connCtx.effect(() => () => {
card.setAlarmPushRpc(undefined);
}, "netxops: clear alarm-push rpc");
formsCtx.logger?.info?.("netxops: settings card via configForms");
mountCard(forms.get(NETXOPS_NS));
});
const bindDirectoryPicker = (picker) => {
if (!picker || typeof picker.pick !== "function")
ctx.inject(["settingsScope"], (scopeCtx) => {
const binder = scopeCtx.settingsScope;
if (!binder || typeof binder.bind !== "function") {
scopeCtx.logger?.warn?.("netxops: settingsScope present but .bind missing");
return;
card.setDirectoryPicker(picker);
};
bindDirectoryPicker(ctx.remote?.directoryPicker);
ctx.inject(["remote.directoryPicker"], (dpCtx) => {
const viaGet = typeof dpCtx.get === "function" ? dpCtx.get("remote.directoryPicker") : undefined;
const viaNested = dpCtx.remote?.directoryPicker;
bindDirectoryPicker(viaGet ?? viaNested);
dpCtx.effect(() => () => {
card.setDirectoryPicker(undefined);
}, "netxops: clear directory picker");
}
if (mounted)
return;
scopeCtx.logger?.info?.("netxops: settings card via settingsScope");
mountCard(binder.bind({ namespace: NETXOPS_NS }));
});
ctx.slots.inject("settings.section", () => ctx.slots.register({
name: "settings.section",
id: NETXOPS_NS,
order: 24,
label: () => t("title"),
locale: LOCALE_NS,
inject: () => card.inject()
}, NetxopsCard));
}
return module.exports;

View file

@ -5236,30 +5236,34 @@ var NETXOPS_RPC_CHANNEL = "/netxops";
var NETXOPS_SETTINGS_NAMESPACE = "netxops";
var NETXOPS_PRESET_ID = "netxops";
var DEFAULT_TOKEN_REF = "NETX_API_TOKEN";
function vol(schema) {
const candidate = schema;
return typeof candidate.volatile === "function" ? candidate.volatile() : schema;
}
var Config = z.object({
apiUrl: z.string().default("http://127.0.0.1:8890"),
lang: z.string().default("zh"),
thinkingLanguage: z.string().default("auto"),
replyLanguage: z.string().default("follow-user"),
tokenCredentialRef: z.string().role("credential-ref").default(DEFAULT_TOKEN_REF),
toolCallTimeoutMs: z.number().step(1).min(1000).default(120000),
installAgentPreset: z.boolean().default(true),
alarmPushEnabled: z.boolean().default(false),
alarmDeliverDsh: z.boolean().default(true),
alarmDeliverIm: z.boolean().default(false),
imTargets: z.string().default(""),
imBotId: z.string().default(""),
imTargetId: z.string().default(""),
nmsProvider: z.string().default("zte-ume"),
groupOpsInPreset: z.boolean().default(true),
groupOpsPublic: z.boolean().default(false),
groupTopologyInPreset: z.boolean().default(false),
groupTopologyPublic: z.boolean().default(false),
groupBizMonitorInPreset: z.boolean().default(true),
groupBizMonitorPublic: z.boolean().default(false),
kbRoot: z.string().default(""),
groupKbInPreset: z.boolean().default(true),
groupKbPublic: z.boolean().default(false)
apiUrl: vol(z.string().default("http://127.0.0.1:8890")),
lang: vol(z.string().default("zh")),
thinkingLanguage: vol(z.string().default("auto")),
replyLanguage: vol(z.string().default("follow-user")),
tokenCredentialRef: vol(z.string().role("credential-ref").default(DEFAULT_TOKEN_REF)),
toolCallTimeoutMs: vol(z.number().step(1).min(1000).default(120000)),
installAgentPreset: vol(z.boolean().default(true)),
alarmPushEnabled: vol(z.boolean().default(false)),
alarmDeliverDsh: vol(z.boolean().default(true)),
alarmDeliverIm: vol(z.boolean().default(false)),
imTargets: vol(z.string().default("")),
imBotId: vol(z.string().default("")),
imTargetId: vol(z.string().default("")),
nmsProvider: vol(z.string().default("zte-ume")),
groupOpsInPreset: vol(z.boolean().default(true)),
groupOpsPublic: vol(z.boolean().default(false)),
groupTopologyInPreset: vol(z.boolean().default(false)),
groupTopologyPublic: vol(z.boolean().default(false)),
groupBizMonitorInPreset: vol(z.boolean().default(true)),
groupBizMonitorPublic: vol(z.boolean().default(false)),
kbRoot: vol(z.string().default("")),
groupKbInPreset: vol(z.boolean().default(true)),
groupKbPublic: vol(z.boolean().default(false))
});
function packageRoot() {
return join7(dirname4(fileURLToPath2(import.meta.url)), "..");
@ -5296,6 +5300,19 @@ async function resolveToken(ctx, refName) {
const hit = await ctx.credentials.resolve(credentialRef(refName));
return hit?.value ?? "";
}
function describeRows(describe) {
if (typeof describe !== "function")
return [];
try {
const raw = describe();
if (Array.isArray(raw))
return raw;
if (raw && typeof raw === "object" && Array.isArray(raw.namespaces)) {
return raw.namespaces;
}
} catch {}
return [];
}
function installNetxopsSettings(ctx, entry, hooks) {
const legacy = DshSettings.installSettingsSection;
if (typeof legacy === "function") {
@ -5303,7 +5320,33 @@ function installNetxopsSettings(ctx, entry, hooks) {
return;
}
ctx.inject(["settings"], (settingsCtx) => {
settingsCtx.settings.installSection(ctx, NETXOPS_SETTINGS_NAMESPACE, Config, entry, hooks);
const settings = settingsCtx.settings;
if (typeof settings.installSection === "function") {
settings.installSection(ctx, NETXOPS_SETTINGS_NAMESPACE, Config, entry, hooks);
return;
}
const readLive = () => {
const row = describeRows(settings.describe).find((item) => item.ns === NETXOPS_SETTINGS_NAMESPACE);
if (row?.value !== null && typeof row?.value === "object" && !Array.isArray(row.value)) {
return { ...entry, ...row.value };
}
return entry;
};
hooks.setSource(readLive);
hooks.onChange();
let lastFingerprint = JSON.stringify(readLive());
const timer = setInterval(() => {
const next = readLive();
const fingerprint = JSON.stringify(next);
if (fingerprint === lastFingerprint)
return;
lastFingerprint = fingerprint;
hooks.onChange();
}, 2000);
settingsCtx.effect(() => () => {
clearInterval(timer);
}, "netxops: settings describe poll");
settingsCtx.logger.info("netxops: following settings via describe() (no installSection — DSH ≥0.1.7 path)");
});
}
function apply(ctx, config = Config({})) {

View file

@ -1,6 +1,6 @@
{
"name": "dsh-netxops",
"version": "0.1.41",
"version": "0.1.42",
"description": "DeepSeek Harness Netx Ops: ops/topology + IM delivery + session export + knowledge-base MANIFEST/_skills + localSkills",
"license": "MIT",
"type": "module",
@ -63,10 +63,10 @@
},
"client": {
"platform": "web",
"immediately": true,
"inject": [
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-settings",
"@deepseek-ai/dsh-api-remotes"
"@deepseek-ai/dsh-client-ui-slots",
"@deepseek-ai/dsh-client-locale"
]
}
},

View file

@ -3,7 +3,7 @@
* Self-contained (cannot value-import ui-settings-plugins — client bundle purity).
*/
import type { SettingsScope, SettingsScopeSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client'
import type { SettingsFormScope, SettingsFormSnapshot } from './settings-form.ts'
import { createSnapshotStore, type SnapshotStore } from './snapshot-store.ts'
export type FieldWrite = { kind: 'set'; value: unknown } | { kind: 'clear' }
@ -110,7 +110,7 @@ export class CardForm<T> {
private failed = false
constructor(
private readonly scope: SettingsScope<T>,
private readonly scope: SettingsFormScope<T>,
specs: CardFieldSpec[],
secrets: CardSecretSpec[] = [],
) {
@ -235,7 +235,7 @@ export class CardForm<T> {
return spec
}
private snapshotOf(): SettingsScopeSnapshot<T> {
private snapshotOf(): SettingsFormSnapshot<T> {
return this.scope.getSnapshot()
}

View file

@ -4,7 +4,7 @@
import type { Context as ClientContext } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-api-remotes/client'
import type { SettingsScope, SettingsScopeSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client'
import type { SettingsFormScope, SettingsFormSnapshot } from './settings-form.ts'
import type { SnapshotStore } from './snapshot-store.ts'
import {
CardForm, textField, booleanField, booleanFieldPersistFalse,
@ -175,7 +175,7 @@ export class NetxopsCardController {
private exportInFlight: Promise<void> | undefined
constructor(
private readonly scope: SettingsScope<NetxopsSettings>,
private readonly scope: SettingsFormScope<NetxopsSettings>,
private readonly ctx: ClientContext,
) {
this.form = new CardForm(
@ -708,7 +708,7 @@ export class NetxopsCardController {
}
}
function refOf(snapshot: SettingsScopeSnapshot<NetxopsSettings>): string {
function refOf(snapshot: SettingsFormSnapshot<NetxopsSettings>): string {
const declared = snapshot.value?.tokenCredentialRef
return declared !== undefined && declared.length > 0 ? declared : DEFAULT_TOKEN_REF
}

View file

@ -1,19 +1,23 @@
/**
* Browser half — Settings → Netx Ops section (uds-auth-style page).
*
* Do not hard-inject `remote.credentials`: shipped `@deepseek-ai/dsh` 0.1.1-rc.2
* remotes assembly does not mount that namespace (Models/Plugins cards only
* inject `remote`). Soft-inject when a newer Host provides it.
* Hard-inject only services present on both DSH ≤0.1.5 and ≥0.1.7.
* Do NOT hard-inject `settingsScope`: DSH ≥0.1.7 / 0.2.0 removed it
* (`configForms` replaced it) and a hard wait kills web boot.
*
* Soft-inject `configForms` first (0.2.0 desktop), then `settingsScope`
* (≤0.1.5). Soft-inject credentials / connection / directoryPicker when present.
*/
import type { Context as ClientContext } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-client-locale/client'
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
import type {} from '@deepseek-ai/dsh-api-remotes/client'
import { NetxopsCard } from './NetxopsCard.tsx'
import { NETXOPS_NS, NetxopsCardController } from './controller.ts'
import { en, zh, type NetxopsLocaleKey } from './locales.ts'
import type { SettingsFormScope } from './settings-form.ts'
import type { NetxopsSettings } from './controller.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
@ -23,12 +27,13 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
const LOCALE_NS = 'settings.netxops'
/** Match shipped settings shell inject (no remote.credentials). */
/**
* Services that exist on every supported DSH client roster.
* Settings transport (`configForms` / `settingsScope`) is soft-injected below.
*/
export const inject = [
'slots',
'locale',
'remote',
'settingsScope',
]
export function apply(ctx: ClientContext): void {
@ -45,69 +50,98 @@ export function apply(ctx: ClientContext): void {
}
}, 'netxops: locales')
const card = new NetxopsCardController(
ctx.settingsScope.bind({ namespace: NETXOPS_NS }),
ctx,
)
const t = ctx.locale.bind(LOCALE_NS) as (key: NetxopsLocaleKey) => string
let mounted = false
// Optional: newer remotes that mount credentials unlock the token field.
ctx.inject(['remote.credentials'], (credCtx) => {
card.setCredentialsAvailable(true)
credCtx.effect(() => {
const off = credCtx.remote.$on('credentials/reference-updated', (ref) => {
card.refreshCredential(String(ref))
})
return () => {
off()
card.setCredentialsAvailable(false)
const mountCard = (scope: SettingsFormScope<NetxopsSettings>): void => {
if (mounted) return
mounted = true
const card = new NetxopsCardController(scope, ctx)
// Optional: newer remotes that mount credentials unlock the token field.
ctx.inject(['remote.credentials'], (credCtx) => {
card.setCredentialsAvailable(true)
credCtx.effect(() => {
const off = credCtx.remote.$on('credentials/reference-updated', (ref) => {
card.refreshCredential(String(ref))
})
return () => {
off()
card.setCredentialsAvailable(false)
}
}, 'netxops: credential invalidations')
})
// Soft-inject Connection so the card can poll host WSS status + KB status.
ctx.inject(['connection'], (connCtx) => {
const call = connCtx.connection?.rpc?.call?.bind(connCtx.connection.rpc)
if (typeof call !== 'function') {
connCtx.logger.warn('netxops: connection.rpc.call unavailable — alarm status UI disabled')
return
}
}, 'netxops: credential invalidations')
})
card.setAlarmPushRpc(call)
connCtx.effect(() => () => {
card.setAlarmPushRpc(undefined)
}, 'netxops: clear alarm-push rpc')
})
// Soft-inject Connection so the card can poll host WSS status + KB status.
ctx.inject(['connection'], (connCtx) => {
const call = connCtx.connection?.rpc?.call?.bind(connCtx.connection.rpc)
if (typeof call !== 'function') {
connCtx.logger.warn('netxops: connection.rpc.call unavailable — alarm status UI disabled')
// Soft-inject Host directory picker for knowledge-base browse.
const bindDirectoryPicker = (picker: { pick?: (signal?: AbortSignal) => Promise<string | null> } | undefined): void => {
if (!picker || typeof picker.pick !== 'function') return
card.setDirectoryPicker(picker as { pick: (signal?: AbortSignal) => Promise<string | null> })
}
bindDirectoryPicker(
(ctx as { remote?: { directoryPicker?: { pick?: (signal?: AbortSignal) => Promise<string | null> } } })
.remote?.directoryPicker,
)
ctx.inject(['remote.directoryPicker'], (dpCtx) => {
const viaGet = typeof (dpCtx as { get?: (name: string) => unknown }).get === 'function'
? (dpCtx as { get: (name: string) => unknown }).get('remote.directoryPicker') as
| { pick?: (signal?: AbortSignal) => Promise<string | null> }
| undefined
: undefined
const viaNested = (dpCtx as { remote?: { directoryPicker?: { pick?: (signal?: AbortSignal) => Promise<string | null> } } })
.remote?.directoryPicker
bindDirectoryPicker(viaGet ?? viaNested)
dpCtx.effect(() => () => {
card.setDirectoryPicker(undefined)
}, 'netxops: clear directory picker')
})
ctx.slots.inject('settings.section', () => ctx.slots.register({
name: 'settings.section',
id: NETXOPS_NS,
order: 24,
label: () => t('title'),
locale: LOCALE_NS,
inject: () => card.inject(),
}, NetxopsCard))
}
// DSH ≥0.1.7 / 0.2.0 — profile entry id matches cordis.patch.yml `id: netxops`.
ctx.inject(['configForms'], (formsCtx) => {
const forms = (formsCtx as { configForms?: { get: (id: string) => SettingsFormScope<NetxopsSettings> } })
.configForms
if (!forms || typeof forms.get !== 'function') {
formsCtx.logger?.warn?.('netxops: configForms present but .get missing')
return
}
card.setAlarmPushRpc(call)
connCtx.effect(() => () => {
card.setAlarmPushRpc(undefined)
}, 'netxops: clear alarm-push rpc')
formsCtx.logger?.info?.('netxops: settings card via configForms')
mountCard(forms.get(NETXOPS_NS))
})
// Soft-inject Host directory picker for knowledge-base browse.
// Prefer the dedicated remote namespace; also accept ctx.remote.directoryPicker.
const bindDirectoryPicker = (picker: { pick?: (signal?: AbortSignal) => Promise<string | null> } | undefined): void => {
if (!picker || typeof picker.pick !== 'function') return
card.setDirectoryPicker(picker as { pick: (signal?: AbortSignal) => Promise<string | null> })
}
bindDirectoryPicker(
(ctx as { remote?: { directoryPicker?: { pick?: (signal?: AbortSignal) => Promise<string | null> } } })
.remote?.directoryPicker,
)
ctx.inject(['remote.directoryPicker'], (dpCtx) => {
const viaGet = typeof (dpCtx as { get?: (name: string) => unknown }).get === 'function'
? (dpCtx as { get: (name: string) => unknown }).get('remote.directoryPicker') as
| { pick?: (signal?: AbortSignal) => Promise<string | null> }
| undefined
: undefined
const viaNested = (dpCtx as { remote?: { directoryPicker?: { pick?: (signal?: AbortSignal) => Promise<string | null> } } })
.remote?.directoryPicker
bindDirectoryPicker(viaGet ?? viaNested)
dpCtx.effect(() => () => {
card.setDirectoryPicker(undefined)
}, 'netxops: clear directory picker')
// DSH ≤0.1.5 — settingsScope binder.
ctx.inject(['settingsScope'], (scopeCtx) => {
const binder = (scopeCtx as {
settingsScope?: { bind: (spec: { namespace: string }) => SettingsFormScope<NetxopsSettings> }
}).settingsScope
if (!binder || typeof binder.bind !== 'function') {
scopeCtx.logger?.warn?.('netxops: settingsScope present but .bind missing')
return
}
if (mounted) return
scopeCtx.logger?.info?.('netxops: settings card via settingsScope')
mountCard(binder.bind({ namespace: NETXOPS_NS }))
})
ctx.slots.inject('settings.section', () => ctx.slots.register({
name: 'settings.section',
id: NETXOPS_NS,
order: 24,
label: () => t('title'),
locale: LOCALE_NS,
inject: () => card.inject(),
}, NetxopsCard))
}

View file

@ -0,0 +1,24 @@
/**
* Shared settings-form face used by the Plugins card.
*
* DSH ≤0.1.5 exposes `ctx.settingsScope.bind({ namespace })`.
* DSH ≥0.1.7 / 0.2.0 exposes `ctx.configForms.get(entryId)`.
* Snapshot + set/unset/subscribe shapes match, so the card stays agnostic.
*/
export interface SettingsFormSnapshot<T> {
status: string
value?: T
base?: unknown
user?: unknown
revision?: number
writable: boolean
mode?: string
}
export interface SettingsFormScope<T> {
getSnapshot(): SettingsFormSnapshot<T>
subscribe(listener: () => void): () => void
set(field: string, value: unknown): Promise<boolean | void>
unset(field: string): Promise<boolean | void>
}

View file

@ -168,30 +168,40 @@ export interface Config {
groupKbPublic: boolean
}
/**
* DSH ≥0.1.7 / 0.2.0 only projects Config fields marked `.volatile()` into the
* settings UI. Zero volatile fields → the whole entry is silently filtered.
* Older schemastery without `volatile` keeps the bare schema (≤0.1.5 path).
*/
function vol<S>(schema: S): S {
const candidate = schema as { volatile?: () => S }
return typeof candidate.volatile === 'function' ? candidate.volatile() : schema
}
export const Config: z<Config> = z.object({
apiUrl: z.string().default('http://127.0.0.1:8890'),
lang: z.string().default('zh'),
thinkingLanguage: z.string().default('auto'),
replyLanguage: z.string().default('follow-user'),
tokenCredentialRef: z.string().role('credential-ref').default(DEFAULT_TOKEN_REF),
toolCallTimeoutMs: z.number().step(1).min(1000).default(120_000),
installAgentPreset: z.boolean().default(true),
alarmPushEnabled: z.boolean().default(false),
alarmDeliverDsh: z.boolean().default(true),
alarmDeliverIm: z.boolean().default(false),
imTargets: z.string().default(''),
imBotId: z.string().default(''),
imTargetId: z.string().default(''),
nmsProvider: z.string().default('zte-ume'),
groupOpsInPreset: z.boolean().default(true),
groupOpsPublic: z.boolean().default(false),
groupTopologyInPreset: z.boolean().default(false),
groupTopologyPublic: z.boolean().default(false),
groupBizMonitorInPreset: z.boolean().default(true),
groupBizMonitorPublic: z.boolean().default(false),
kbRoot: z.string().default(''),
groupKbInPreset: z.boolean().default(true),
groupKbPublic: z.boolean().default(false),
apiUrl: vol(z.string().default('http://127.0.0.1:8890')),
lang: vol(z.string().default('zh')),
thinkingLanguage: vol(z.string().default('auto')),
replyLanguage: vol(z.string().default('follow-user')),
tokenCredentialRef: vol(z.string().role('credential-ref').default(DEFAULT_TOKEN_REF)),
toolCallTimeoutMs: vol(z.number().step(1).min(1000).default(120_000)),
installAgentPreset: vol(z.boolean().default(true)),
alarmPushEnabled: vol(z.boolean().default(false)),
alarmDeliverDsh: vol(z.boolean().default(true)),
alarmDeliverIm: vol(z.boolean().default(false)),
imTargets: vol(z.string().default('')),
imBotId: vol(z.string().default('')),
imTargetId: vol(z.string().default('')),
nmsProvider: vol(z.string().default('zte-ume')),
groupOpsInPreset: vol(z.boolean().default(true)),
groupOpsPublic: vol(z.boolean().default(false)),
groupTopologyInPreset: vol(z.boolean().default(false)),
groupTopologyPublic: vol(z.boolean().default(false)),
groupBizMonitorInPreset: vol(z.boolean().default(true)),
groupBizMonitorPublic: vol(z.boolean().default(false)),
kbRoot: vol(z.string().default('')),
groupKbInPreset: vol(z.boolean().default(true)),
groupKbPublic: vol(z.boolean().default(false)),
})
/** Package root (parent of `lib/` or `src/` depending on launch). */
@ -243,10 +253,27 @@ async function resolveToken(ctx: Context, refName: string): Promise<string> {
return hit?.value ?? ''
}
/** Normalize settings.describe() across DSH generations (array or { namespaces }). */
function describeRows(describe: unknown): Array<{ ns?: string, value?: unknown }> {
if (typeof describe !== 'function') return []
try {
const raw = (describe as () => unknown)()
if (Array.isArray(raw)) return raw as Array<{ ns?: string, value?: unknown }>
if (raw && typeof raw === 'object' && Array.isArray((raw as { namespaces?: unknown }).namespaces)) {
return (raw as { namespaces: Array<{ ns?: string, value?: unknown }> }).namespaces
}
} catch {
// describe can throw while the provider is settling
}
return []
}
/**
* Register the settings namespace on both dsh generations:
* Register / follow the settings namespace across DSH generations:
* - 0.1.1-rc.2: standalone `installSettingsSection`
* - ≥0.1.2-rc.1: `settings.installSection` on the provider
* - ≥0.1.7 / 0.2.0: Config is projected from the Loader entry; no owner scope —
* re-read via `settings.describe()` and poll for UI writes (no scope.watch).
*/
function installNetxopsSettings(
ctx: Context,
@ -271,7 +298,47 @@ function installNetxopsSettings(
}
ctx.inject(['settings'], (settingsCtx) => {
settingsCtx.settings.installSection(ctx, NETXOPS_SETTINGS_NAMESPACE, Config, entry, hooks)
const settings = settingsCtx.settings as {
installSection?: (
context: Context,
ns: string,
schema: z<Config>,
base: Config,
sectionHooks: typeof hooks,
) => void
describe?: () => unknown
}
if (typeof settings.installSection === 'function') {
settings.installSection(ctx, NETXOPS_SETTINGS_NAMESPACE, Config, entry, hooks)
return
}
// DSH ≥0.1.7: profile Config projection — follow describe() for live values.
const readLive = (): Config => {
const row = describeRows(settings.describe).find(item => item.ns === NETXOPS_SETTINGS_NAMESPACE)
if (row?.value !== null && typeof row?.value === 'object' && !Array.isArray(row.value)) {
return { ...entry, ...(row.value as Partial<Config>) }
}
return entry
}
hooks.setSource(readLive)
hooks.onChange()
let lastFingerprint = JSON.stringify(readLive())
const timer = setInterval(() => {
const next = readLive()
const fingerprint = JSON.stringify(next)
if (fingerprint === lastFingerprint) return
lastFingerprint = fingerprint
hooks.onChange()
}, 2_000)
settingsCtx.effect(() => () => {
clearInterval(timer)
}, 'netxops: settings describe poll')
settingsCtx.logger.info(
'netxops: following settings via describe() (no installSection — DSH ≥0.1.7 path)',
)
})
}