@dxos/plugin-registry 0.9.0 → 0.9.1-main.c7dcc2e112
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/dist/lib/neutral/RegistryPlugin.node.mjs +709 -18
- package/dist/lib/neutral/RegistryPlugin.node.mjs.map +4 -4
- package/dist/lib/neutral/chunk-ZY37XLVF.mjs +46 -0
- package/dist/lib/neutral/chunk-ZY37XLVF.mjs.map +7 -0
- package/dist/lib/neutral/index.mjs +1 -1
- package/dist/lib/neutral/meta.json +1 -1
- package/dist/lib/neutral/{open-plugin-registry-II735N76.mjs → open-plugin-registry-ID7LV3SF.mjs} +3 -3
- package/dist/lib/neutral/open-plugin-registry-ID7LV3SF.mjs.map +7 -0
- package/dist/lib/neutral/plugin.mjs +2 -2
- package/dist/lib/neutral/translations.mjs +1 -1
- package/dist/lib/neutral/translations.mjs.map +3 -3
- package/dist/types/dx.config.d.ts +28 -0
- package/dist/types/dx.config.d.ts.map +1 -0
- package/dist/types/src/capabilities/app-graph-builder.d.ts.map +1 -1
- package/dist/types/src/capabilities/index.d.ts +4 -12
- package/dist/types/src/capabilities/index.d.ts.map +1 -1
- package/dist/types/src/capabilities/react-surface.d.ts +2 -2
- package/dist/types/src/capabilities/react-surface.d.ts.map +1 -1
- package/dist/types/src/categories.d.ts.map +1 -1
- package/dist/types/src/commands/index.d.ts +2 -2
- package/dist/types/src/commands/index.d.ts.map +1 -1
- package/dist/types/src/commands/plugin/disable.d.ts.map +1 -0
- package/dist/types/src/commands/plugin/enable.d.ts.map +1 -0
- package/dist/types/src/commands/plugin/index.d.ts +3 -0
- package/dist/types/src/commands/plugin/index.d.ts.map +1 -0
- package/dist/types/src/commands/plugin/list.d.ts.map +1 -0
- package/dist/types/src/commands/plugin/util.d.ts.map +1 -0
- package/dist/types/src/commands/registry/index.d.ts +16 -0
- package/dist/types/src/commands/registry/index.d.ts.map +1 -0
- package/dist/types/src/commands/registry/publish-package.d.ts +29 -0
- package/dist/types/src/commands/registry/publish-package.d.ts.map +1 -0
- package/dist/types/src/commands/registry/publish-publisher.d.ts +19 -0
- package/dist/types/src/commands/registry/publish-publisher.d.ts.map +1 -0
- package/dist/types/src/commands/registry/publish.d.ts +25 -0
- package/dist/types/src/commands/registry/publish.d.ts.map +1 -0
- package/dist/types/src/commands/registry/records.d.ts +14 -0
- package/dist/types/src/commands/registry/records.d.ts.map +1 -0
- package/dist/types/src/commands/registry/unpublish.d.ts +15 -0
- package/dist/types/src/commands/registry/unpublish.d.ts.map +1 -0
- package/dist/types/src/commands/registry/util.d.ts +113 -0
- package/dist/types/src/commands/registry/util.d.ts.map +1 -0
- package/dist/types/src/commands/registry/verify.d.ts +20 -0
- package/dist/types/src/commands/registry/verify.d.ts.map +1 -0
- package/dist/types/src/components/PluginDetail/PluginDetail.d.ts +2 -2
- package/dist/types/src/components/PluginDetail/PluginDetail.d.ts.map +1 -1
- package/dist/types/src/components/PluginDetail/PluginDetail.stories.d.ts.map +1 -1
- package/dist/types/src/components/PluginList/PluginItem.d.ts.map +1 -1
- package/dist/types/src/components/PluginList/PluginList.stories.d.ts.map +1 -1
- package/dist/types/src/containers/LoadPluginDialog/LoadPluginDialog.d.ts.map +1 -1
- package/dist/types/src/containers/PluginArticle/PluginArticle.d.ts.map +1 -1
- package/dist/types/src/containers/PublicRegistryArticle/PublicRegistryArticle.d.ts.map +1 -1
- package/dist/types/src/hooks/useAutoTags.d.ts +2 -2
- package/dist/types/src/hooks/useAutoTags.d.ts.map +1 -1
- package/dist/types/src/hooks/useCatalogEntry.d.ts +4 -5
- package/dist/types/src/hooks/useCatalogEntry.d.ts.map +1 -1
- package/dist/types/src/hooks/usePluginActions.d.ts +3 -3
- package/dist/types/src/hooks/usePluginActions.d.ts.map +1 -1
- package/dist/types/src/hooks/useUpdateAvailable.d.ts +2 -2
- package/dist/types/src/hooks/useUpdateAvailable.d.ts.map +1 -1
- package/dist/types/src/hooks/useVersionPicker.d.ts +7 -8
- package/dist/types/src/hooks/useVersionPicker.d.ts.map +1 -1
- package/dist/types/src/meta.d.ts +28 -2
- package/dist/types/src/meta.d.ts.map +1 -1
- package/dist/types/src/operations/open-plugin-registry.d.ts.map +1 -1
- package/dist/types/src/types.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/dx.config.ts +20 -0
- package/package.json +31 -24
- package/src/RegistryPlugin.node.ts +2 -2
- package/src/RegistryPlugin.test.ts +1 -1
- package/src/capabilities/app-graph-builder.ts +43 -39
- package/src/capabilities/dev-plugin-loader.ts +1 -1
- package/src/capabilities/react-surface.tsx +1 -4
- package/src/capabilities/settings.ts +2 -2
- package/src/categories.ts +5 -4
- package/src/commands/index.ts +2 -10
- package/src/commands/{disable.ts → plugin/disable.ts} +3 -3
- package/src/commands/{enable.ts → plugin/enable.ts} +3 -3
- package/src/commands/plugin/index.ts +14 -0
- package/src/commands/{list.ts → plugin/list.ts} +4 -4
- package/src/commands/registry/index.ts +30 -0
- package/src/commands/registry/publish-package.ts +119 -0
- package/src/commands/registry/publish-publisher.ts +73 -0
- package/src/commands/registry/publish.ts +278 -0
- package/src/commands/registry/records.ts +73 -0
- package/src/commands/registry/unpublish.ts +70 -0
- package/src/commands/registry/util.ts +395 -0
- package/src/commands/registry/verify.ts +76 -0
- package/src/components/DisableDependentsAlert/DisableDependentsAlert.tsx +1 -1
- package/src/components/PluginDetail/PluginDetail.stories.tsx +5 -3
- package/src/components/PluginDetail/PluginDetail.tsx +21 -23
- package/src/components/PluginFailureBadge/PluginFailureBadge.tsx +1 -1
- package/src/components/PluginList/PluginItem.tsx +5 -3
- package/src/components/PluginList/PluginList.stories.tsx +1 -2
- package/src/components/PluginList/PluginList.tsx +4 -4
- package/src/components/RegistrySettings/RegistrySettings.tsx +1 -1
- package/src/containers/BaseRegistryArticle/BaseRegistryArticle.tsx +2 -2
- package/src/containers/LoadPluginDialog/LoadPluginDialog.tsx +3 -3
- package/src/containers/PluginArticle/PluginArticle.tsx +8 -6
- package/src/containers/PublicRegistryArticle/PublicRegistryArticle.tsx +35 -28
- package/src/containers/RegistryArticle/RegistryArticle.tsx +1 -1
- package/src/containers/RegistrySettingsContainer/RegistrySettingsContainer.tsx +1 -1
- package/src/hooks/useAutoTags.ts +3 -3
- package/src/hooks/useCatalogEntry.ts +6 -7
- package/src/hooks/useDisableConfirmation.ts +1 -1
- package/src/hooks/usePluginActions.ts +13 -11
- package/src/hooks/useUpdateAvailable.ts +5 -5
- package/src/hooks/useVersionPicker.ts +22 -30
- package/src/meta.ts +6 -15
- package/src/operations/open-plugin-registry.ts +2 -2
- package/src/translations.ts +1 -1
- package/src/types.ts +3 -1
- package/dist/lib/neutral/chunk-GRQI6MLI.mjs +0 -36
- package/dist/lib/neutral/chunk-GRQI6MLI.mjs.map +0 -7
- package/dist/lib/neutral/open-plugin-registry-II735N76.mjs.map +0 -7
- package/dist/types/src/commands/disable.d.ts.map +0 -1
- package/dist/types/src/commands/enable.d.ts.map +0 -1
- package/dist/types/src/commands/list.d.ts.map +0 -1
- package/dist/types/src/commands/util.d.ts.map +0 -1
- /package/dist/types/src/commands/{disable.d.ts → plugin/disable.d.ts} +0 -0
- /package/dist/types/src/commands/{enable.d.ts → plugin/enable.d.ts} +0 -0
- /package/dist/types/src/commands/{list.d.ts → plugin/list.d.ts} +0 -0
- /package/dist/types/src/commands/{util.d.ts → plugin/util.d.ts} +0 -0
- /package/src/commands/{util.ts → plugin/util.ts} +0 -0
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import * as HttpClient from '@effect/platform/HttpClient';
|
|
6
|
+
import * as HttpClientRequest from '@effect/platform/HttpClientRequest';
|
|
7
|
+
import type * as HttpClientResponse from '@effect/platform/HttpClientResponse';
|
|
8
|
+
import * as Config from 'effect/Config';
|
|
9
|
+
import * as Effect from 'effect/Effect';
|
|
10
|
+
import * as Option from 'effect/Option';
|
|
11
|
+
import * as Schema from 'effect/Schema';
|
|
12
|
+
|
|
13
|
+
import { AppSpace } from '@dxos/app-toolkit';
|
|
14
|
+
import { type Client } from '@dxos/client';
|
|
15
|
+
import { Filter } from '@dxos/echo';
|
|
16
|
+
import { ALL_NSIDS, NSID } from '@dxos/protocols';
|
|
17
|
+
import { AccessToken } from '@dxos/types';
|
|
18
|
+
|
|
19
|
+
export { ALL_NSIDS, NSID };
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Shared helpers for the `dx registry *` commands.
|
|
23
|
+
*
|
|
24
|
+
* These commands write `org.dxos.experimental.*` records to a publisher's PDS via AT Protocol
|
|
25
|
+
* XRPC. Auth has two modes (see {@link resolveSession}):
|
|
26
|
+
*
|
|
27
|
+
* - **Personal-space (default):** when logged in (`dx account login`), the publisher's atproto
|
|
28
|
+
* session lives in their personal space as the "Atmosphere" `AccessToken`. Record writes are
|
|
29
|
+
* signed by Edge's DPoP proxy (`/atproto/proxy`) — no app password required.
|
|
30
|
+
* - **App password (fallback):** explicit `--handle` / `--app-password` (or `$ATPROTO_HANDLE` /
|
|
31
|
+
* `$ATPROTO_APP_PASSWORD`) authenticate directly against the PDS with a session token.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
// `AccessToken.source` of the default atproto / login integration ("Atmosphere"). Mirrors
|
|
35
|
+
// `ATMOSPHERE_SOURCE` in plugin-integration; inlined to avoid a plugin-registry -> plugin-integration
|
|
36
|
+
// dependency.
|
|
37
|
+
const ATMOSPHERE_SOURCE = 'atproto.local';
|
|
38
|
+
|
|
39
|
+
// Public read-only XRPC base used for identity resolution. Works for any
|
|
40
|
+
// AT Protocol identity regardless of which PDS hosts it.
|
|
41
|
+
const BSKY_PUBLIC_API = 'https://public.api.bsky.app/xrpc';
|
|
42
|
+
const PLC_DIRECTORY = 'https://plc.directory';
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Schemas (subset of XRPC response shapes we consume)
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
const CreateSessionResponseSchema = Schema.Struct({
|
|
49
|
+
did: Schema.String,
|
|
50
|
+
handle: Schema.String,
|
|
51
|
+
accessJwt: Schema.String,
|
|
52
|
+
refreshJwt: Schema.String,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const ResolveHandleResponseSchema = Schema.Struct({ did: Schema.String });
|
|
56
|
+
|
|
57
|
+
const DidServiceSchema = Schema.Struct({
|
|
58
|
+
id: Schema.String,
|
|
59
|
+
type: Schema.optional(Schema.String),
|
|
60
|
+
serviceEndpoint: Schema.Unknown,
|
|
61
|
+
});
|
|
62
|
+
const DidDocumentSchema = Schema.Struct({
|
|
63
|
+
service: Schema.optional(Schema.Array(DidServiceSchema)),
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const PutRecordResponseSchema = Schema.Struct({
|
|
67
|
+
uri: Schema.String,
|
|
68
|
+
cid: Schema.String,
|
|
69
|
+
});
|
|
70
|
+
export type PutRecordResponse = Schema.Schema.Type<typeof PutRecordResponseSchema>;
|
|
71
|
+
|
|
72
|
+
const ListRecordsEntrySchema = Schema.Struct({
|
|
73
|
+
uri: Schema.String,
|
|
74
|
+
cid: Schema.String,
|
|
75
|
+
value: Schema.Unknown,
|
|
76
|
+
});
|
|
77
|
+
const ListRecordsResponseSchema = Schema.Struct({
|
|
78
|
+
cursor: Schema.optional(Schema.String),
|
|
79
|
+
records: Schema.Array(ListRecordsEntrySchema),
|
|
80
|
+
});
|
|
81
|
+
export type ListRecordsEntry = Schema.Schema.Type<typeof ListRecordsEntrySchema>;
|
|
82
|
+
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// XRPC primitives
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
const decodeJson =
|
|
88
|
+
<T>(schema: Schema.Schema<T>) =>
|
|
89
|
+
(response: HttpClientResponse.HttpClientResponse) =>
|
|
90
|
+
Effect.flatMap(response.json, Schema.decodeUnknown(schema));
|
|
91
|
+
|
|
92
|
+
/** GET an XRPC endpoint and decode the JSON response. */
|
|
93
|
+
const xrpcGet = <T>(url: string, query: Record<string, string>, schema: Schema.Schema<T>) =>
|
|
94
|
+
Effect.gen(function* () {
|
|
95
|
+
const client = yield* HttpClient.HttpClient;
|
|
96
|
+
return yield* HttpClientRequest.get(url).pipe(
|
|
97
|
+
HttpClientRequest.setUrlParams(query),
|
|
98
|
+
client.execute,
|
|
99
|
+
Effect.flatMap(decodeJson(schema)),
|
|
100
|
+
Effect.scoped,
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
/** POST a JSON body to an XRPC endpoint and decode the JSON response. */
|
|
105
|
+
const xrpcPost = <T>(
|
|
106
|
+
url: string,
|
|
107
|
+
body: Record<string, unknown>,
|
|
108
|
+
schema: Schema.Schema<T> | undefined,
|
|
109
|
+
headers: Record<string, string> = {},
|
|
110
|
+
) =>
|
|
111
|
+
Effect.gen(function* () {
|
|
112
|
+
const client = yield* HttpClient.HttpClient;
|
|
113
|
+
const response = yield* HttpClientRequest.post(url).pipe(
|
|
114
|
+
HttpClientRequest.setHeaders(headers),
|
|
115
|
+
HttpClientRequest.bodyJson(body),
|
|
116
|
+
Effect.flatMap((req) => client.execute(req)),
|
|
117
|
+
Effect.scoped,
|
|
118
|
+
);
|
|
119
|
+
if (schema === undefined) {
|
|
120
|
+
return undefined as T;
|
|
121
|
+
}
|
|
122
|
+
return yield* decodeJson(schema)(response);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
/** Resolve a handle (e.g. `alice.bsky.social`) to a DID via the public XRPC. */
|
|
126
|
+
export const resolveHandle = (handle: string) =>
|
|
127
|
+
xrpcGet(`${BSKY_PUBLIC_API}/com.atproto.identity.resolveHandle`, { handle }, ResolveHandleResponseSchema).pipe(
|
|
128
|
+
Effect.map((response) => response.did),
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
/** Resolve a DID to its PDS service endpoint via the PLC directory (or did:web well-known). */
|
|
132
|
+
export const resolvePds = (did: string) =>
|
|
133
|
+
Effect.gen(function* () {
|
|
134
|
+
let doc: Schema.Schema.Type<typeof DidDocumentSchema>;
|
|
135
|
+
if (did.startsWith('did:plc:')) {
|
|
136
|
+
doc = yield* xrpcGet(`${PLC_DIRECTORY}/${did}`, {}, DidDocumentSchema);
|
|
137
|
+
} else if (did.startsWith('did:web:')) {
|
|
138
|
+
const rest = did.slice('did:web:'.length);
|
|
139
|
+
const [host, ...segments] = rest.split(':').map((part) => decodeURIComponent(part));
|
|
140
|
+
const path = segments.length > 0 ? `/${segments.join('/')}/did.json` : '/.well-known/did.json';
|
|
141
|
+
doc = yield* xrpcGet(`https://${host}${path}`, {}, DidDocumentSchema);
|
|
142
|
+
} else {
|
|
143
|
+
return yield* Effect.fail(new Error(`Unsupported DID method: ${did}`));
|
|
144
|
+
}
|
|
145
|
+
const service = doc.service?.find(
|
|
146
|
+
(entry) => entry.id === '#atproto_pds' || entry.type === 'AtprotoPersonalDataServer',
|
|
147
|
+
);
|
|
148
|
+
const endpoint = service?.serviceEndpoint;
|
|
149
|
+
if (typeof endpoint !== 'string' || endpoint.length === 0) {
|
|
150
|
+
return yield* Effect.fail(new Error(`PDS endpoint missing in DID document for ${did}`));
|
|
151
|
+
}
|
|
152
|
+
return endpoint;
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
/** Resolve a handle or DID to its `{ did, pdsBaseUrl }`. */
|
|
156
|
+
const resolveRepo = (handleOrDid: string) =>
|
|
157
|
+
Effect.gen(function* () {
|
|
158
|
+
const did = handleOrDid.startsWith('did:') ? handleOrDid : yield* resolveHandle(handleOrDid);
|
|
159
|
+
const pdsBaseUrl = yield* resolvePds(did);
|
|
160
|
+
return { did, pdsBaseUrl };
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
// Session — an authenticated writer to a publisher's PDS repo.
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
/** Direct PDS auth via an app-password session token. */
|
|
168
|
+
type AppPasswordSession = {
|
|
169
|
+
readonly mode: 'app-password';
|
|
170
|
+
readonly did: string;
|
|
171
|
+
readonly handle: string;
|
|
172
|
+
readonly pdsBaseUrl: string;
|
|
173
|
+
readonly accessJwt: string;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
/** Indirect auth: writes are DPoP-signed by Edge's `/atproto/proxy` using a personal-space token. */
|
|
177
|
+
type DpopProxySession = {
|
|
178
|
+
readonly mode: 'dpop-proxy';
|
|
179
|
+
readonly did: string;
|
|
180
|
+
readonly handle: string;
|
|
181
|
+
readonly pdsBaseUrl: string;
|
|
182
|
+
readonly edgeBaseUrl: string;
|
|
183
|
+
readonly spaceId: string;
|
|
184
|
+
readonly accessTokenId: string;
|
|
185
|
+
readonly token: string;
|
|
186
|
+
readonly authHeader: string | undefined;
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
export type Session = AppPasswordSession | DpopProxySession;
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* App-password session — call `com.atproto.server.createSession` against the caller's resolved
|
|
193
|
+
* PDS, then return the access JWT, DID, and PDS endpoint needed for subsequent writes.
|
|
194
|
+
*/
|
|
195
|
+
export const createSession = (handle: string, appPassword: string) =>
|
|
196
|
+
Effect.gen(function* () {
|
|
197
|
+
// Issue the session against the resolved PDS — bsky.social's monolithic host won't accept
|
|
198
|
+
// logins for users that have been migrated to a shard.
|
|
199
|
+
const { did, pdsBaseUrl } = yield* resolveRepo(handle);
|
|
200
|
+
const response = yield* xrpcPost(
|
|
201
|
+
`${pdsBaseUrl}/xrpc/com.atproto.server.createSession`,
|
|
202
|
+
{ identifier: handle, password: appPassword },
|
|
203
|
+
CreateSessionResponseSchema,
|
|
204
|
+
);
|
|
205
|
+
return {
|
|
206
|
+
mode: 'app-password',
|
|
207
|
+
did: response.did,
|
|
208
|
+
handle: response.handle,
|
|
209
|
+
accessJwt: response.accessJwt,
|
|
210
|
+
pdsBaseUrl,
|
|
211
|
+
} satisfies AppPasswordSession;
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
export type ResolveSessionOptions = {
|
|
215
|
+
/** `--handle` flag value (if any). */
|
|
216
|
+
handle: string | undefined;
|
|
217
|
+
/** `--app-password` flag value (if any). */
|
|
218
|
+
appPassword: string | undefined;
|
|
219
|
+
/** The DXOS client, used to read default credentials from the personal space. */
|
|
220
|
+
client: Client;
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Resolve an authenticated {@link Session} for the registry commands, in precedence order:
|
|
225
|
+
*
|
|
226
|
+
* 1. Explicit `--handle` + `--app-password` (or `$ATPROTO_HANDLE` / `$ATPROTO_APP_PASSWORD`) →
|
|
227
|
+
* app-password session (works without a DXOS identity).
|
|
228
|
+
* 2. The logged-in identity's personal-space "Atmosphere" `AccessToken` → DPoP-proxy session.
|
|
229
|
+
* 3. A context-specific error guiding the user to log in / connect an integration / pass creds.
|
|
230
|
+
*/
|
|
231
|
+
export const resolveSession = (options: ResolveSessionOptions) =>
|
|
232
|
+
Effect.gen(function* () {
|
|
233
|
+
const handle = options.handle ?? Option.getOrUndefined(yield* Config.option(Config.string('ATPROTO_HANDLE')));
|
|
234
|
+
const appPassword =
|
|
235
|
+
options.appPassword ?? Option.getOrUndefined(yield* Config.option(Config.string('ATPROTO_APP_PASSWORD')));
|
|
236
|
+
if (handle && appPassword) {
|
|
237
|
+
return yield* createSession(handle, appPassword);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const fromPersonalSpace = yield* resolvePersonalSpaceSession(options.client);
|
|
241
|
+
if (fromPersonalSpace) {
|
|
242
|
+
return fromPersonalSpace;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (options.client.halo.identity.get()) {
|
|
246
|
+
return yield* Effect.fail(
|
|
247
|
+
new Error(
|
|
248
|
+
'No atproto integration connected. Connect one with `dx integration add`, or pass --handle/--app-password. ' +
|
|
249
|
+
'(`dx account login` cannot add an integration to an existing identity.)',
|
|
250
|
+
),
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
return yield* Effect.fail(
|
|
254
|
+
new Error('No DXOS identity. Run `dx account login` first, or pass --handle/--app-password.'),
|
|
255
|
+
);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Build a DPoP-proxy session from the personal space's "Atmosphere" `AccessToken`, or `undefined`
|
|
260
|
+
* if the user isn't logged in or hasn't connected an atproto integration.
|
|
261
|
+
*/
|
|
262
|
+
const resolvePersonalSpaceSession = (client: Client) =>
|
|
263
|
+
Effect.gen(function* () {
|
|
264
|
+
const space = AppSpace.getPersonalSpace(client);
|
|
265
|
+
if (!space) {
|
|
266
|
+
return undefined;
|
|
267
|
+
}
|
|
268
|
+
const tokens = (yield* Effect.promise(() =>
|
|
269
|
+
space.db.query(Filter.type(AccessToken.AccessToken)).run(),
|
|
270
|
+
)) as AccessToken.AccessToken[];
|
|
271
|
+
const token = tokens.find((object) => object.source === ATMOSPHERE_SOURCE && !!object.account && !!object.token);
|
|
272
|
+
if (!token?.account) {
|
|
273
|
+
return undefined;
|
|
274
|
+
}
|
|
275
|
+
const { did, pdsBaseUrl } = yield* resolveRepo(token.account);
|
|
276
|
+
return {
|
|
277
|
+
mode: 'dpop-proxy',
|
|
278
|
+
did,
|
|
279
|
+
handle: token.account,
|
|
280
|
+
pdsBaseUrl,
|
|
281
|
+
edgeBaseUrl: client.edge.http.baseUrl,
|
|
282
|
+
spaceId: space.id,
|
|
283
|
+
accessTokenId: token.id,
|
|
284
|
+
token: token.token,
|
|
285
|
+
// Best-effort VP auth header for the proxy. The `/atproto/proxy` route currently skips auth
|
|
286
|
+
// in dev; production reads the caller's verifiable presentation when present.
|
|
287
|
+
authHeader: (client.edge.http as unknown as { _authHeader?: string })._authHeader,
|
|
288
|
+
} satisfies DpopProxySession;
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// ---------------------------------------------------------------------------
|
|
292
|
+
// Record operations — auth-mode agnostic; branch on the session.
|
|
293
|
+
// ---------------------------------------------------------------------------
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Proxy an XRPC call through Edge's `/atproto/proxy`, which attaches the DPoP proof signed with
|
|
297
|
+
* the key Edge stored at OAuth time. The proxy forwards `request` verbatim and returns the
|
|
298
|
+
* upstream PDS response body, so callers decode the same schema as the direct path.
|
|
299
|
+
*/
|
|
300
|
+
const proxyCall = <T>(
|
|
301
|
+
session: DpopProxySession,
|
|
302
|
+
params: {
|
|
303
|
+
xrpcMethod: string;
|
|
304
|
+
method: 'GET' | 'POST';
|
|
305
|
+
query?: Record<string, string>;
|
|
306
|
+
jsonBody?: Record<string, unknown>;
|
|
307
|
+
},
|
|
308
|
+
schema: Schema.Schema<T> | undefined,
|
|
309
|
+
) => {
|
|
310
|
+
const query = params.query ? `?${new URLSearchParams(params.query).toString()}` : '';
|
|
311
|
+
const innerHeaders: Record<string, string> = { Accept: 'application/json', Authorization: `DPoP ${session.token}` };
|
|
312
|
+
if (params.jsonBody) {
|
|
313
|
+
innerHeaders['Content-Type'] = 'application/json';
|
|
314
|
+
}
|
|
315
|
+
const outerHeaders: Record<string, string> = {};
|
|
316
|
+
if (session.authHeader) {
|
|
317
|
+
outerHeaders.Authorization = session.authHeader;
|
|
318
|
+
}
|
|
319
|
+
return xrpcPost(
|
|
320
|
+
`${session.edgeBaseUrl.replace(/\/$/, '')}/atproto/proxy`,
|
|
321
|
+
{
|
|
322
|
+
spaceId: session.spaceId,
|
|
323
|
+
accessTokenId: session.accessTokenId,
|
|
324
|
+
request: {
|
|
325
|
+
endpoint: `${session.pdsBaseUrl}/xrpc/${params.xrpcMethod}${query}`,
|
|
326
|
+
method: params.method,
|
|
327
|
+
headers: innerHeaders,
|
|
328
|
+
// Edge's proxy forwards the body to `fetch` as-is, so it must be a pre-serialized string.
|
|
329
|
+
body: params.jsonBody ? JSON.stringify(params.jsonBody) : null,
|
|
330
|
+
},
|
|
331
|
+
},
|
|
332
|
+
schema,
|
|
333
|
+
outerHeaders,
|
|
334
|
+
);
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
/** Write a record at `com.atproto.repo.putRecord` (idempotent on rkey). */
|
|
338
|
+
export const putRecord = (session: Session, collection: string, rkey: string, record: Record<string, unknown>) => {
|
|
339
|
+
const body = { repo: session.did, collection, rkey, record: { $type: collection, ...record } };
|
|
340
|
+
return session.mode === 'app-password'
|
|
341
|
+
? xrpcPost(`${session.pdsBaseUrl}/xrpc/com.atproto.repo.putRecord`, body, PutRecordResponseSchema, {
|
|
342
|
+
Authorization: `Bearer ${session.accessJwt}`,
|
|
343
|
+
})
|
|
344
|
+
: proxyCall(
|
|
345
|
+
session,
|
|
346
|
+
{ xrpcMethod: 'com.atproto.repo.putRecord', method: 'POST', jsonBody: body },
|
|
347
|
+
PutRecordResponseSchema,
|
|
348
|
+
);
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
/** Delete a record at `com.atproto.repo.deleteRecord`. */
|
|
352
|
+
export const deleteRecord = (session: Session, collection: string, rkey: string) => {
|
|
353
|
+
const body = { repo: session.did, collection, rkey };
|
|
354
|
+
return session.mode === 'app-password'
|
|
355
|
+
? xrpcPost(`${session.pdsBaseUrl}/xrpc/com.atproto.repo.deleteRecord`, body, undefined, {
|
|
356
|
+
Authorization: `Bearer ${session.accessJwt}`,
|
|
357
|
+
})
|
|
358
|
+
: proxyCall(session, { xrpcMethod: 'com.atproto.repo.deleteRecord', method: 'POST', jsonBody: body }, undefined);
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* List records in a single collection on the authenticated repo. Paging is left to the caller (the
|
|
363
|
+
* four registry collections are tiny in practice); the cursor is surfaced on the return.
|
|
364
|
+
*/
|
|
365
|
+
export const listRecords = (
|
|
366
|
+
session: Session,
|
|
367
|
+
collection: string,
|
|
368
|
+
options: { limit?: number; cursor?: string } = {},
|
|
369
|
+
) => {
|
|
370
|
+
const query: Record<string, string> = {
|
|
371
|
+
repo: session.did,
|
|
372
|
+
collection,
|
|
373
|
+
...(options.limit !== undefined ? { limit: String(options.limit) } : {}),
|
|
374
|
+
...(options.cursor !== undefined ? { cursor: options.cursor } : {}),
|
|
375
|
+
};
|
|
376
|
+
return session.mode === 'app-password'
|
|
377
|
+
? xrpcGet(`${session.pdsBaseUrl}/xrpc/com.atproto.repo.listRecords`, query, ListRecordsResponseSchema)
|
|
378
|
+
: proxyCall(
|
|
379
|
+
session,
|
|
380
|
+
{ xrpcMethod: 'com.atproto.repo.listRecords', method: 'GET', query },
|
|
381
|
+
ListRecordsResponseSchema,
|
|
382
|
+
);
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
// ---------------------------------------------------------------------------
|
|
386
|
+
// Shared CLI option fragment for auth flags.
|
|
387
|
+
// ---------------------------------------------------------------------------
|
|
388
|
+
|
|
389
|
+
export const AUTH_OPTION_DESCRIPTIONS = {
|
|
390
|
+
handle:
|
|
391
|
+
'AT Protocol handle (e.g. alice.bsky.social). Defaults to the logged-in identity; falls back to $ATPROTO_HANDLE.',
|
|
392
|
+
appPassword:
|
|
393
|
+
'AT Protocol app password (https://bsky.app/settings/app-passwords). Falls back to $ATPROTO_APP_PASSWORD. ' +
|
|
394
|
+
'Not needed when logged in via `dx account login`.',
|
|
395
|
+
} as const;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import * as Command from '@effect/cli/Command';
|
|
6
|
+
import * as Options from '@effect/cli/Options';
|
|
7
|
+
import * as FetchHttpClient from '@effect/platform/FetchHttpClient';
|
|
8
|
+
import * as Console from 'effect/Console';
|
|
9
|
+
import * as Effect from 'effect/Effect';
|
|
10
|
+
import * as Function from 'effect/Function';
|
|
11
|
+
import * as Option from 'effect/Option';
|
|
12
|
+
|
|
13
|
+
import { ClientService } from '@dxos/client';
|
|
14
|
+
|
|
15
|
+
import { AUTH_OPTION_DESCRIPTIONS, NSID, putRecord, resolveHandle, resolveSession } from './util';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* `dx registry verify` — publishes a `org.dxos.experimental.publisher.verification`
|
|
19
|
+
* record on the authenticated repo, with rkey = subject DID. Subject is given as a
|
|
20
|
+
* handle or DID; handles are resolved through the public XRPC.
|
|
21
|
+
*
|
|
22
|
+
* Verification records are public and anyone may author them. The registry indexer only
|
|
23
|
+
* honors records authored by the configured verifier (`REGISTRY_CURATOR_DID`), so this
|
|
24
|
+
* command only affects discovery when run with that verifier's credentials.
|
|
25
|
+
*/
|
|
26
|
+
export const verify = Command.make(
|
|
27
|
+
'verify',
|
|
28
|
+
{
|
|
29
|
+
handle: Options.text('handle').pipe(Options.withDescription(AUTH_OPTION_DESCRIPTIONS.handle), Options.optional),
|
|
30
|
+
appPassword: Options.text('app-password').pipe(
|
|
31
|
+
Options.withDescription(AUTH_OPTION_DESCRIPTIONS.appPassword),
|
|
32
|
+
Options.optional,
|
|
33
|
+
),
|
|
34
|
+
subject: Options.text('subject').pipe(Options.withDescription('Handle or DID of the publisher being verified.')),
|
|
35
|
+
subjectHandle: Options.text('subject-handle').pipe(
|
|
36
|
+
Options.withDescription(
|
|
37
|
+
'Optional human-readable handle to record for the verified subject (defaults to --subject if it is a handle).',
|
|
38
|
+
),
|
|
39
|
+
Options.optional,
|
|
40
|
+
),
|
|
41
|
+
displayName: Options.text('display-name').pipe(
|
|
42
|
+
Options.withDescription('Display name to record for the verified subject.'),
|
|
43
|
+
),
|
|
44
|
+
},
|
|
45
|
+
(options) =>
|
|
46
|
+
Function.pipe(
|
|
47
|
+
Effect.gen(function* () {
|
|
48
|
+
const client = yield* ClientService;
|
|
49
|
+
const session = yield* resolveSession({
|
|
50
|
+
handle: Option.getOrUndefined(options.handle),
|
|
51
|
+
appPassword: Option.getOrUndefined(options.appPassword),
|
|
52
|
+
client,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Subject may be passed as either a handle or a DID; resolve to a DID so the rkey is stable.
|
|
56
|
+
const subjectDid = options.subject.startsWith('did:') ? options.subject : yield* resolveHandle(options.subject);
|
|
57
|
+
const subjectHandle =
|
|
58
|
+
Option.getOrUndefined(options.subjectHandle) ?? (options.subject.startsWith('did:') ? '' : options.subject);
|
|
59
|
+
|
|
60
|
+
const record = {
|
|
61
|
+
subject: subjectDid,
|
|
62
|
+
...(subjectHandle ? { handle: subjectHandle } : {}),
|
|
63
|
+
displayName: options.displayName,
|
|
64
|
+
createdAt: new Date().toISOString(),
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const result = yield* putRecord(session, NSID.PublisherVerification, subjectDid, record);
|
|
68
|
+
yield* Console.log(`Verified ${subjectDid} -> ${result.uri}`);
|
|
69
|
+
}),
|
|
70
|
+
Effect.provide(FetchHttpClient.layer),
|
|
71
|
+
),
|
|
72
|
+
).pipe(
|
|
73
|
+
Command.withDescription(
|
|
74
|
+
'Write a publisher.verification record (indexed only if authored by the configured verifier, REGISTRY_CURATOR_DID).',
|
|
75
|
+
),
|
|
76
|
+
);
|
|
@@ -36,7 +36,7 @@ export const DisableDependentsAlert = ({
|
|
|
36
36
|
onResolvePluginName,
|
|
37
37
|
onConfirm,
|
|
38
38
|
}: DisableDependentsAlertProps) => {
|
|
39
|
-
const { t } = useTranslation(meta.
|
|
39
|
+
const { t } = useTranslation(meta.profile.key);
|
|
40
40
|
const resolveName = onResolvePluginName ?? ((id: string) => id);
|
|
41
41
|
return (
|
|
42
42
|
<AlertDialog.Content>
|
|
@@ -35,12 +35,14 @@ export const Default: Story = {
|
|
|
35
35
|
name: 'Test Plugin',
|
|
36
36
|
author: 'DXOS',
|
|
37
37
|
description: random.lorem.paragraphs(2),
|
|
38
|
-
icon: 'ph--bug--regular',
|
|
39
|
-
iconHue: 'sky',
|
|
38
|
+
icon: { key: 'ph--bug--regular', hue: 'sky' },
|
|
40
39
|
homePage: 'https://example.com',
|
|
41
40
|
source: 'https://github.com/example/test-plugin',
|
|
42
41
|
screenshots: [
|
|
43
|
-
|
|
42
|
+
{
|
|
43
|
+
light:
|
|
44
|
+
'https://media.gcflearnfree.org/content/55e073de7dd48174331f51b3_01_17_2014/getting_started_interactive2.png',
|
|
45
|
+
},
|
|
44
46
|
],
|
|
45
47
|
}),
|
|
46
48
|
).pipe(Plugin.make)(),
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import React, { type PropsWithChildren } from 'react';
|
|
6
6
|
|
|
7
|
-
import { type Plugin, type PluginManager
|
|
7
|
+
import { type Plugin, type PluginManager } from '@dxos/app-framework';
|
|
8
8
|
import {
|
|
9
9
|
Button,
|
|
10
10
|
Carousel,
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
Select,
|
|
16
16
|
Tag,
|
|
17
17
|
ThemedClassName,
|
|
18
|
+
useThemeContext,
|
|
18
19
|
useTranslation,
|
|
19
20
|
} from '@dxos/react-ui';
|
|
20
21
|
import { composable, composableProps } from '@dxos/react-ui';
|
|
@@ -39,7 +40,7 @@ export type PluginDetailProps = {
|
|
|
39
40
|
/** Currently selected version tag in the picker. */
|
|
40
41
|
selectedVersionTag?: string;
|
|
41
42
|
/** Available versions of this plugin from the catalog. When non-empty, a version picker is shown. */
|
|
42
|
-
versions?: readonly
|
|
43
|
+
versions?: readonly Plugin.Release[];
|
|
43
44
|
/**
|
|
44
45
|
* Ids of plugins this plugin declares as dependencies (direct only). Rendered
|
|
45
46
|
* under a "Requires" heading so the user can see what enabling this plugin
|
|
@@ -129,20 +130,17 @@ export const PluginDetail = composable<HTMLDivElement, PluginDetailProps>(
|
|
|
129
130
|
},
|
|
130
131
|
forwardedRef,
|
|
131
132
|
) => {
|
|
132
|
-
const { t } = useTranslation(meta.
|
|
133
|
-
const {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
description,
|
|
138
|
-
homePage,
|
|
139
|
-
source,
|
|
140
|
-
screenshots,
|
|
141
|
-
icon = 'ph--circle--regular',
|
|
142
|
-
iconHue = 'neutral',
|
|
143
|
-
} = plugin.meta;
|
|
133
|
+
const { t } = useTranslation(meta.profile.key);
|
|
134
|
+
const { themeMode } = useThemeContext();
|
|
135
|
+
const { key: slug, name, author, description, homePage, source, screenshots, icon: rawIcon } = plugin.meta.profile;
|
|
136
|
+
const iconKey = rawIcon?.key ?? 'ph--circle--regular';
|
|
137
|
+
const iconHue = rawIcon?.hue ?? 'neutral';
|
|
144
138
|
const styles = getStyles(iconHue);
|
|
145
139
|
|
|
140
|
+
const resolvedScreenshots = (screenshots ?? [])
|
|
141
|
+
.map((entry) => (themeMode === 'dark' ? (entry.dark ?? entry.light) : (entry.light ?? entry.dark)))
|
|
142
|
+
.filter((url): url is string => typeof url === 'string' && url.length > 0);
|
|
143
|
+
|
|
146
144
|
return (
|
|
147
145
|
<ScrollArea.Root {...composableProps(props)} orientation='vertical' ref={forwardedRef}>
|
|
148
146
|
<ScrollArea.Viewport>
|
|
@@ -156,7 +154,7 @@ export const PluginDetail = composable<HTMLDivElement, PluginDetailProps>(
|
|
|
156
154
|
* in col 3.
|
|
157
155
|
*/}
|
|
158
156
|
<div className='dx-document grid grid-cols-[4rem_minmax(0,1fr)_4rem] gap-x-4 p-4 items-start'>
|
|
159
|
-
<Icon classNames={mx('row-start-1 p-1 rounded-md', styles.bg, styles.fg)} icon={
|
|
157
|
+
<Icon classNames={mx('row-start-1 p-1 rounded-md', styles.bg, styles.fg)} icon={iconKey} size={14} />
|
|
160
158
|
|
|
161
159
|
<div className='row-start-1 col-start-2 col-span-2 grid grid-cols-[1fr_min-content] gap-x-3 w-full pt-1'>
|
|
162
160
|
<div className='flex items-center gap-2'>
|
|
@@ -173,7 +171,7 @@ export const PluginDetail = composable<HTMLDivElement, PluginDetailProps>(
|
|
|
173
171
|
</Input.Root>
|
|
174
172
|
)}
|
|
175
173
|
<div className='flex items-center gap-1 pt-0.5 text-sm text-description'>
|
|
176
|
-
{
|
|
174
|
+
{slug}
|
|
177
175
|
{author && <span className='dx-tag dx-tag--info'>{author}</span>}
|
|
178
176
|
</div>
|
|
179
177
|
</div>
|
|
@@ -187,14 +185,14 @@ export const PluginDetail = composable<HTMLDivElement, PluginDetailProps>(
|
|
|
187
185
|
</Section.Root>
|
|
188
186
|
)}
|
|
189
187
|
|
|
190
|
-
{
|
|
188
|
+
{resolvedScreenshots.length > 0 && (
|
|
191
189
|
<Section.Root>
|
|
192
190
|
<Section.Heading title={t('preview.label')} />
|
|
193
191
|
<Section.Body>
|
|
194
|
-
<Carousel.Root count={
|
|
192
|
+
<Carousel.Root count={resolvedScreenshots.length}>
|
|
195
193
|
<Carousel.Content classNames='contents'>
|
|
196
194
|
<Carousel.Viewport>
|
|
197
|
-
{
|
|
195
|
+
{resolvedScreenshots.map((src, index) => (
|
|
198
196
|
<Carousel.Slide key={src} index={index} src={src} alt={name} />
|
|
199
197
|
))}
|
|
200
198
|
</Carousel.Viewport>
|
|
@@ -223,7 +221,7 @@ export const PluginDetail = composable<HTMLDivElement, PluginDetailProps>(
|
|
|
223
221
|
</Link>
|
|
224
222
|
)}
|
|
225
223
|
|
|
226
|
-
{onOpenSpec && <Chip id={
|
|
224
|
+
{onOpenSpec && <Chip id={slug} name={t('open-spec.label')} onClick={onOpenSpec} />}
|
|
227
225
|
</div>
|
|
228
226
|
</Section.Body>
|
|
229
227
|
</Section.Root>
|
|
@@ -275,9 +273,9 @@ export const PluginDetail = composable<HTMLDivElement, PluginDetailProps>(
|
|
|
275
273
|
<Select.Content>
|
|
276
274
|
<Select.Viewport>
|
|
277
275
|
{versions.map((versionEntry) => (
|
|
278
|
-
<Select.Option key={versionEntry.
|
|
279
|
-
{versionEntry.
|
|
280
|
-
{installedVersionTag === versionEntry.
|
|
276
|
+
<Select.Option key={versionEntry.version} value={versionEntry.version}>
|
|
277
|
+
{versionEntry.version}
|
|
278
|
+
{installedVersionTag === versionEntry.version ? ` (${t('installed.label')})` : ''}
|
|
281
279
|
</Select.Option>
|
|
282
280
|
))}
|
|
283
281
|
</Select.Viewport>
|
|
@@ -29,7 +29,7 @@ export type PluginFailureBadgeProps = {
|
|
|
29
29
|
* from "the plugin crashed".
|
|
30
30
|
*/
|
|
31
31
|
export const PluginFailureBadge = ({ failure, iconClassNames, size = 4 }: PluginFailureBadgeProps) => {
|
|
32
|
-
const { t } = useTranslation(meta.
|
|
32
|
+
const { t } = useTranslation(meta.profile.key);
|
|
33
33
|
|
|
34
34
|
const phaseLabel = failure.phase === 'load' ? t('failure-phase-load.label') : t('failure-phase-activation.label');
|
|
35
35
|
const reasonLabel =
|
|
@@ -78,8 +78,10 @@ export const PluginItem = ({
|
|
|
78
78
|
onSettings,
|
|
79
79
|
failure,
|
|
80
80
|
}: PluginItemProps) => {
|
|
81
|
-
const { t } = useTranslation(meta.
|
|
82
|
-
const { id, name, description, tags, icon
|
|
81
|
+
const { t } = useTranslation(meta.profile.key);
|
|
82
|
+
const { key: id, name, description, tags, icon: rawIcon } = plugin.meta.profile;
|
|
83
|
+
const icon = rawIcon?.key ?? 'ph--circle--regular';
|
|
84
|
+
const iconHue = rawIcon?.hue ?? 'neutral';
|
|
83
85
|
const displayTags = useMemo(() => {
|
|
84
86
|
if (!extraTags || extraTags.length === 0) {
|
|
85
87
|
return tags ?? [];
|
|
@@ -156,7 +158,7 @@ export const PluginItem = ({
|
|
|
156
158
|
</div>
|
|
157
159
|
|
|
158
160
|
<div className='flex -ms-0.5 overflow-x-auto scrollbar-none'>
|
|
159
|
-
{displayTags.map((tag) => (
|
|
161
|
+
{displayTags.map((tag: string) => (
|
|
160
162
|
<Tag key={tag} palette={tagColors[tag as RegistryTagType]} classNames='text-xs uppercase'>
|
|
161
163
|
{tag}
|
|
162
164
|
</Tag>
|
|
@@ -38,8 +38,7 @@ const DefaultStory = () => {
|
|
|
38
38
|
name: `${random.commerce.productName()}`,
|
|
39
39
|
description: random.lorem.sentences(Math.ceil(Math.random() * 3)),
|
|
40
40
|
tags: random.helpers.uniqueArray(RegistryTagType.literals as any, Math.floor(Math.random() * 3)),
|
|
41
|
-
icon: random.helpers.arrayElement(icons),
|
|
42
|
-
iconHue: getHashHue(random.string.uuid()),
|
|
41
|
+
icon: { key: random.helpers.arrayElement(icons), hue: getHashHue(random.string.uuid()) },
|
|
43
42
|
homePage: random.datatype.boolean({ probability: 0.5 }) ? random.internet.url() : undefined,
|
|
44
43
|
source: random.internet.url(),
|
|
45
44
|
}),
|
|
@@ -39,11 +39,11 @@ export const PluginList = ({
|
|
|
39
39
|
<List classNames='grid grid-cols-[repeat(auto-fill,minmax(18rem,1fr))] auto-rows-[max-content] gap-4 p-4'>
|
|
40
40
|
{plugins.map((plugin) => (
|
|
41
41
|
<PluginItem
|
|
42
|
-
key={plugin.meta.
|
|
42
|
+
key={plugin.meta.profile.key}
|
|
43
43
|
plugin={plugin}
|
|
44
|
-
extraTags={extraTagsById?.[plugin.meta.
|
|
45
|
-
hasUpdate={updateAvailableIds?.has(plugin.meta.
|
|
46
|
-
failure={failuresById?.[plugin.meta.
|
|
44
|
+
extraTags={extraTagsById?.[plugin.meta.profile.key]}
|
|
45
|
+
hasUpdate={updateAvailableIds?.has(plugin.meta.profile.key)}
|
|
46
|
+
failure={failuresById?.[plugin.meta.profile.key]}
|
|
47
47
|
{...props}
|
|
48
48
|
/>
|
|
49
49
|
))}
|