feat(core): add connector authentication (#31837)
This commit is contained in:
parent
bf05e8a122
commit
dac0dd5309
47 changed files with 5433 additions and 862 deletions
14
packages/core/test/plugin/fixtures/models-dev.json
Normal file
14
packages/core/test/plugin/fixtures/models-dev.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"acme": {
|
||||
"id": "acme",
|
||||
"name": "Acme",
|
||||
"env": ["ACME_API_KEY"],
|
||||
"models": {}
|
||||
},
|
||||
"local": {
|
||||
"id": "local",
|
||||
"name": "Local",
|
||||
"env": [],
|
||||
"models": {}
|
||||
}
|
||||
}
|
||||
70
packages/core/test/plugin/models-dev.test.ts
Normal file
70
packages/core/test/plugin/models-dev.test.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Connector } from "@opencode-ai/core/connector"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { ModelsDev } from "@opencode-ai/core/models-dev"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { ModelsDevPlugin } from "@opencode-ai/core/plugin/models-dev"
|
||||
import { Policy } from "@opencode-ai/core/policy"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { location } from "../fixture/location"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const events = EventV2.defaultLayer
|
||||
const locationLayer = Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(import.meta.dir) })),
|
||||
)
|
||||
const plugins = PluginV2.layer.pipe(Layer.provide(events))
|
||||
const policy = Policy.layer.pipe(Layer.provide(locationLayer))
|
||||
const credentials = Credential.layer.pipe(
|
||||
Layer.provide(Database.layerFromPath(":memory:")),
|
||||
Layer.provide(events),
|
||||
)
|
||||
const catalog = Catalog.layer.pipe(
|
||||
Layer.provide(Layer.mergeAll(events, locationLayer, plugins, policy, credentials)),
|
||||
)
|
||||
const connectors = Connector.locationLayer.pipe(Layer.provide(credentials), Layer.provide(events))
|
||||
const layer = Layer.mergeAll(catalog, connectors, credentials, events, locationLayer, plugins)
|
||||
const it = testEffect(layer)
|
||||
|
||||
describe("ModelsDevPlugin", () => {
|
||||
it.effect("registers key connectors for providers with environment variables", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const previous = {
|
||||
path: Flag.OPENCODE_MODELS_PATH,
|
||||
disabled: Flag.OPENCODE_DISABLE_MODELS_FETCH,
|
||||
}
|
||||
Flag.OPENCODE_MODELS_PATH = path.join(import.meta.dir, "fixtures", "models-dev.json")
|
||||
Flag.OPENCODE_DISABLE_MODELS_FETCH = true
|
||||
return previous
|
||||
}),
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* ModelsDevPlugin.effect
|
||||
const connectors = yield* Connector.Service
|
||||
expect(yield* connectors.list()).toEqual([
|
||||
new Connector.Info({
|
||||
id: Connector.ID.make("acme"),
|
||||
name: "Acme",
|
||||
methods: [
|
||||
new Connector.KeyMethod({ id: Connector.MethodID.make("api-key"), type: "key", label: "API Key" }),
|
||||
],
|
||||
}),
|
||||
])
|
||||
}).pipe(Effect.provide(ModelsDev.defaultLayer)),
|
||||
(previous) =>
|
||||
Effect.sync(() => {
|
||||
Flag.OPENCODE_MODELS_PATH = previous.path
|
||||
Flag.OPENCODE_DISABLE_MODELS_FETCH = previous.disabled
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
|
@ -1,11 +1,12 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Auth } from "@opencode-ai/core/auth"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { Connector } from "@opencode-ai/core/connector"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { AccountPlugin } from "@opencode-ai/core/plugin/account"
|
||||
import { AzurePlugin } from "@opencode-ai/core/plugin/provider/azure"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
|
|
@ -15,7 +16,12 @@ import { fakeSelectorSdk, it, model, npmLayer, provider, withEnv } from "./provi
|
|||
|
||||
const itWithAccount = testEffect(
|
||||
Catalog.locationLayer.pipe(
|
||||
Layer.provideMerge(Auth.defaultLayer),
|
||||
Layer.provideMerge(
|
||||
Credential.layer.pipe(
|
||||
Layer.provide(Database.layerFromPath(":memory:").pipe(Layer.fresh)),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
),
|
||||
),
|
||||
Layer.provideMerge(EventV2.defaultLayer),
|
||||
Layer.provideMerge(
|
||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))),
|
||||
|
|
@ -74,26 +80,17 @@ describe("AzurePlugin", () => {
|
|||
() =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const accounts = yield* Auth.Service
|
||||
const credentials = yield* Credential.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
const events = yield* EventV2.Service
|
||||
yield* accounts.create({
|
||||
serviceID: Auth.ServiceID.make("azure"),
|
||||
credential: new Auth.ApiKeyCredential({
|
||||
type: "api",
|
||||
yield* credentials.create({
|
||||
connectorID: Connector.ID.make("azure"),
|
||||
methodID: Connector.MethodID.make("api-key"),
|
||||
value: new Credential.Key({
|
||||
type: "key",
|
||||
key: "key",
|
||||
metadata: { resourceName: "from-account" },
|
||||
}),
|
||||
})
|
||||
yield* plugin.add({
|
||||
...AccountPlugin,
|
||||
effect: AccountPlugin.effect.pipe(
|
||||
Effect.provideService(Auth.Service, accounts),
|
||||
Effect.provideService(Catalog.Service, catalog),
|
||||
Effect.provideService(EventV2.Service, events),
|
||||
Effect.provideService(PluginV2.Service, plugin),
|
||||
),
|
||||
})
|
||||
yield* plugin.add(AzurePlugin)
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Auth } from "@opencode-ai/core/auth"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { Connector } from "@opencode-ai/core/connector"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { AccountPlugin } from "@opencode-ai/core/plugin/account"
|
||||
import { CloudflareWorkersAIPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-workers-ai"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
|
|
@ -16,7 +17,12 @@ import { fakeSelectorSdk, it, model, npmLayer, withEnv } from "./provider-helper
|
|||
|
||||
const itWithAccount = testEffect(
|
||||
Catalog.locationLayer.pipe(
|
||||
Layer.provideMerge(Auth.defaultLayer),
|
||||
Layer.provideMerge(
|
||||
Credential.layer.pipe(
|
||||
Layer.provide(Database.layerFromPath(":memory:").pipe(Layer.fresh)),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
),
|
||||
),
|
||||
Layer.provideMerge(EventV2.defaultLayer),
|
||||
Layer.provideMerge(
|
||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))),
|
||||
|
|
@ -128,26 +134,17 @@ describe("CloudflareWorkersAIPlugin", () => {
|
|||
() =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const accounts = yield* Auth.Service
|
||||
const credentials = yield* Credential.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
const events = yield* EventV2.Service
|
||||
yield* accounts.create({
|
||||
serviceID: Auth.ServiceID.make("cloudflare-workers-ai"),
|
||||
credential: new Auth.ApiKeyCredential({
|
||||
type: "api",
|
||||
yield* credentials.create({
|
||||
connectorID: Connector.ID.make("cloudflare-workers-ai"),
|
||||
methodID: Connector.MethodID.make("api-key"),
|
||||
value: new Credential.Key({
|
||||
type: "key",
|
||||
key: "account-key",
|
||||
metadata: { accountId: "account-acct" },
|
||||
}),
|
||||
})
|
||||
yield* plugin.add({
|
||||
...AccountPlugin,
|
||||
effect: AccountPlugin.effect.pipe(
|
||||
Effect.provideService(Auth.Service, accounts),
|
||||
Effect.provideService(Catalog.Service, catalog),
|
||||
Effect.provideService(EventV2.Service, events),
|
||||
Effect.provideService(PluginV2.Service, plugin),
|
||||
),
|
||||
})
|
||||
yield* plugin.add(CloudflareWorkersAIPlugin)
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) =>
|
||||
|
|
@ -155,10 +152,9 @@ describe("CloudflareWorkersAIPlugin", () => {
|
|||
provider.api = { type: "aisdk", package: "test-provider" }
|
||||
}),
|
||||
)
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).api).toEqual({
|
||||
type: "aisdk",
|
||||
package: "test-provider",
|
||||
url: "https://api.cloudflare.com/client/v4/accounts/account-acct/ai/v1",
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).request.body).toMatchObject({
|
||||
apiKey: "account-key",
|
||||
accountId: "account-acct",
|
||||
})
|
||||
}),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import { describe, expect, mock } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Auth } from "@opencode-ai/core/auth"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { Connector } from "@opencode-ai/core/connector"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { AccountPlugin } from "@opencode-ai/core/plugin/account"
|
||||
import { GitLabPlugin } from "@opencode-ai/core/plugin/provider/gitlab"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
|
|
@ -30,7 +31,12 @@ void mock.module("gitlab-ai-provider", () => ({
|
|||
|
||||
const itWithAccount = testEffect(
|
||||
Catalog.locationLayer.pipe(
|
||||
Layer.provideMerge(Auth.defaultLayer),
|
||||
Layer.provideMerge(
|
||||
Credential.layer.pipe(
|
||||
Layer.provide(Database.layerFromPath(":memory:").pipe(Layer.fresh)),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
),
|
||||
),
|
||||
Layer.provideMerge(EventV2.defaultLayer),
|
||||
Layer.provideMerge(
|
||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("/") }))),
|
||||
|
|
@ -165,21 +171,12 @@ describe("GitLabPlugin", () => {
|
|||
Effect.gen(function* () {
|
||||
gitlabSDKOptions.length = 0
|
||||
const plugin = yield* PluginV2.Service
|
||||
const accounts = yield* Auth.Service
|
||||
const credentials = yield* Credential.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
const events = yield* EventV2.Service
|
||||
yield* accounts.create({
|
||||
serviceID: Auth.ServiceID.make("gitlab"),
|
||||
credential: new Auth.ApiKeyCredential({ type: "api", key: "account-token" }),
|
||||
})
|
||||
yield* plugin.add({
|
||||
...AccountPlugin,
|
||||
effect: AccountPlugin.effect.pipe(
|
||||
Effect.provideService(Auth.Service, accounts),
|
||||
Effect.provideService(Catalog.Service, catalog),
|
||||
Effect.provideService(EventV2.Service, events),
|
||||
Effect.provideService(PluginV2.Service, plugin),
|
||||
),
|
||||
yield* credentials.create({
|
||||
connectorID: Connector.ID.make("gitlab"),
|
||||
methodID: Connector.MethodID.make("api-key"),
|
||||
value: new Credential.Key({ type: "key", key: "account-token" }),
|
||||
})
|
||||
yield* plugin.add(GitLabPlugin)
|
||||
const transform = yield* catalog.transform()
|
||||
|
|
@ -208,27 +205,18 @@ describe("GitLabPlugin", () => {
|
|||
Effect.gen(function* () {
|
||||
gitlabSDKOptions.length = 0
|
||||
const plugin = yield* PluginV2.Service
|
||||
const accounts = yield* Auth.Service
|
||||
const credentials = yield* Credential.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
const events = yield* EventV2.Service
|
||||
yield* accounts.create({
|
||||
serviceID: Auth.ServiceID.make("gitlab"),
|
||||
credential: new Auth.OAuthCredential({
|
||||
yield* credentials.create({
|
||||
connectorID: Connector.ID.make("gitlab"),
|
||||
methodID: Connector.MethodID.make("oauth"),
|
||||
value: new Credential.OAuth({
|
||||
type: "oauth",
|
||||
refresh: "refresh-token",
|
||||
access: "account-oauth-token",
|
||||
expires: 9999999999999,
|
||||
}),
|
||||
})
|
||||
yield* plugin.add({
|
||||
...AccountPlugin,
|
||||
effect: AccountPlugin.effect.pipe(
|
||||
Effect.provideService(Auth.Service, accounts),
|
||||
Effect.provideService(Catalog.Service, catalog),
|
||||
Effect.provideService(EventV2.Service, events),
|
||||
Effect.provideService(PluginV2.Service, plugin),
|
||||
),
|
||||
})
|
||||
yield* plugin.add(GitLabPlugin)
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("gitlab"), () => {}))
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import type { LanguageModelV3 } from "@ai-sdk/provider"
|
|||
import { expect } from "bun:test"
|
||||
import { Effect, Layer, Option } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Connector } from "@opencode-ai/core/connector"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
|
|
@ -46,8 +48,15 @@ export const catalogLayer = Layer.succeed(
|
|||
}),
|
||||
)
|
||||
|
||||
const connectors = Connector.locationLayer.pipe(
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
Layer.provide(Layer.mock(Credential.Service)({ create: () => Effect.die("unexpected credential creation") })),
|
||||
)
|
||||
|
||||
export const it = testEffect(
|
||||
Catalog.locationLayer.pipe(
|
||||
Layer.provideMerge(connectors),
|
||||
Layer.provideMerge(Layer.mock(Credential.Service)({ activeAll: () => Effect.succeed(new Map()) })),
|
||||
Layer.provideMerge(EventV2.defaultLayer),
|
||||
Layer.provideMerge(locationLayer),
|
||||
Layer.provideMerge(npmLayer),
|
||||
|
|
|
|||
|
|
@ -1,17 +1,44 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Connector } from "@opencode-ai/core/connector"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { OpenAIPlugin } from "@opencode-ai/core/plugin/provider/openai"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { fakeSelectorSdk, it, model, provider } from "./provider-helper"
|
||||
|
||||
function add(plugin: PluginV2.Interface, connectors: Connector.Interface) {
|
||||
return plugin.add({
|
||||
...OpenAIPlugin,
|
||||
effect: OpenAIPlugin.effect.pipe(Effect.provideService(Connector.Service, connectors)),
|
||||
})
|
||||
}
|
||||
|
||||
describe("OpenAIPlugin", () => {
|
||||
it.effect("registers browser and headless ChatGPT OAuth methods", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* add(plugin, yield* Connector.Service)
|
||||
expect((yield* (yield* Connector.Service).get(Connector.ID.make("openai")))?.methods).toEqual([
|
||||
new Connector.OAuthMethod({
|
||||
id: Connector.MethodID.make("chatgpt-browser"),
|
||||
type: "oauth",
|
||||
label: "ChatGPT Pro/Plus (browser)",
|
||||
}),
|
||||
new Connector.OAuthMethod({
|
||||
id: Connector.MethodID.make("chatgpt-headless"),
|
||||
type: "oauth",
|
||||
label: "ChatGPT Pro/Plus (headless)",
|
||||
}),
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("creates an OpenAI SDK for @ai-sdk/openai using the provider ID as SDK name", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* plugin.add(OpenAIPlugin)
|
||||
yield* add(plugin, yield* Connector.Service)
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
|
|
@ -28,7 +55,7 @@ describe("OpenAIPlugin", () => {
|
|||
it.effect("ignores non-OpenAI SDK packages", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* plugin.add(OpenAIPlugin)
|
||||
yield* add(plugin, yield* Connector.Service)
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{ model: model("openai", "gpt-5"), package: "@ai-sdk/openai-compatible", options: { name: "openai" } },
|
||||
|
|
@ -42,7 +69,7 @@ describe("OpenAIPlugin", () => {
|
|||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const calls: string[] = []
|
||||
yield* plugin.add(OpenAIPlugin)
|
||||
yield* add(plugin, yield* Connector.Service)
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.language",
|
||||
{
|
||||
|
|
@ -63,7 +90,7 @@ describe("OpenAIPlugin", () => {
|
|||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const calls: string[] = []
|
||||
yield* plugin.add(OpenAIPlugin)
|
||||
yield* add(plugin, yield* Connector.Service)
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.language",
|
||||
{ model: model("anthropic", "gpt-5"), sdk: fakeSelectorSdk(calls), options: {} },
|
||||
|
|
@ -78,7 +105,7 @@ describe("OpenAIPlugin", () => {
|
|||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(OpenAIPlugin)
|
||||
yield* add(plugin, yield* Connector.Service)
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const item = provider("openai", { api: { type: "aisdk", package: "@ai-sdk/openai" } })
|
||||
|
|
@ -97,7 +124,7 @@ describe("OpenAIPlugin", () => {
|
|||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(OpenAIPlugin)
|
||||
yield* add(plugin, yield* Connector.Service)
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const item = provider("custom-openai")
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { DateTime, Effect, Layer, Option } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
|
|
@ -161,7 +162,9 @@ describe("OpencodePlugin", () => {
|
|||
yield* plugin.add(OpencodePlugin)
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const item = provider("opencode", { enabled: { via: "account", service: "opencode" } })
|
||||
const item = provider("opencode", {
|
||||
enabled: { via: "credential", credentialID: Credential.ID.make("credential") },
|
||||
})
|
||||
catalog.provider.update(item.id, (draft) => {
|
||||
draft.enabled = item.enabled
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue