@cosmicdrift/kumiko-framework 0.368.0 → 0.369.0
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/agents/features.md +4 -4
- package/dist/engine/boot-validator/screens.js +29 -0
- package/dist/engine/build-config-feature-schema.js +197 -65
- package/dist/engine/extension-selector-plugins.d.ts +3 -0
- package/dist/engine/extension-selector-plugins.js +14 -0
- package/dist/engine/feature-config-events-jobs.d.ts +2 -0
- package/dist/engine/feature-config-events-jobs.js +1 -0
- package/dist/i18n/settings-hub-keys.js +1 -2
- package/dist/upgrade-cli.js +6 -4
- package/package.json +5 -5
- package/src/changes.json +6 -0
package/agents/features.md
CHANGED
|
@@ -106,7 +106,7 @@ Requires: `delivery`, `secrets`
|
|
|
106
106
|
|
|
107
107
|
## channel-email
|
|
108
108
|
|
|
109
|
-
Wires an `EmailTransport` (typically `mail-transport-smtp` in production, `createInMemoryTransport()` in tests) into the delivery system as the `email` channel. Requires `delivery`; pass an `EmailChannelOptions` with a `transport` and a `resolveEmail` function that maps a user ID to their email address. Mails render from stored templates (`renderer-mail-html`): a notification routed to this channel needs `template` + `meta` on `r.notification`, otherwise its delivery fails. Pass `tenantTransport: tenantMailTransport` (from `mail-foundation`) to send
|
|
109
|
+
Wires an `EmailTransport` (typically `mail-transport-smtp` in production, `createInMemoryTransport()` in tests) into the delivery system as the `email` channel. Requires `delivery`; pass an `EmailChannelOptions` with a `transport` and a `resolveEmail` function that maps a user ID to their email address. Mails render from stored templates (`renderer-mail-html`): a notification routed to this channel needs `template` + `meta` on `r.notification`, otherwise its delivery fails. Pass `tenantTransport: tenantMailTransport` (from `mail-foundation`) to send the notifications declared with `tenantTransport: true` through the sending tenant's own configured provider: a broken provider fails the send, a tenant without one uses the app `transport`; every other notification (password reset, sign-in) always goes through the app `transport`. `transport` may be left out when `tenantTransport` is set; a mail that then has no transport fails with `missing_credentials`. Notifications declared with `unsubscribe: true` get a signed `data.unsubscribeUrl` from delivery, which turns into the `List-Unsubscribe` headers.
|
|
110
110
|
|
|
111
111
|
Category: notifications
|
|
112
112
|
|
|
@@ -206,7 +206,7 @@ Exposes APIs: `retention.policyFor`
|
|
|
206
206
|
|
|
207
207
|
## delivery
|
|
208
208
|
|
|
209
|
-
The notification dispatch core: call `ctx.notify(notificationType, { to, route, data, priority, idempotencyKey })` from any handler to fan out a notification across all registered channels (email, in-app, push). Inside a write handler the delivery runs after the write has committed (the call returns `{ kind: "deferred" }`, a rolled-back write sends nothing); `immediate: true` delivers synchronously and returns `{ kind: "dispatched", deliveries }`. It stores per-user channel preferences in the `notification-preference` entity, opt-outs for no-account recipient addresses in `notification-address-opt-out` (keyed by a blind-index hash, never the plaintext address), logs every attempt to `store_delivery_attempts`, and enforces idempotency and rate-limiting — add `channel-email`, `channel-in-app`, or `channel-push` on top to actually send anything. Unsubscribe/resubscribe links are served by `createUnsubscribeRoutes({ secret })` mounted via the app's `extraRoutes` at `/api/delivery/unsubscribe` and `/api/delivery/resubscribe`: `GET /unsubscribe?token=` renders a confirmation page (no write), `POST /unsubscribe` performs the opt-out (RFC 8058 one-click, token from the form body or query), `POST /resubscribe` undoes it (JSON `{token}` or form body only, never the query — reachable by a mail-client link prefetcher) — sign links with `signUnsubscribeToken` / `signAddressUnsubscribeToken` using the same secret. `channel-email` sets `List-Unsubscribe` / `List-Unsubscribe-Post` automatically when a message's `data.unsubscribeUrl` points at the unsubscribe route. A notification declared with `unsubscribe: true` gets that link from delivery itself when the service has `unsubscribeLinks` (`runProdApp` option `unsubscribe: { secret }` mounts the routes and wires it; base URL from `auth.publicOrigin`, `auth.mail.baseUrl` or `APP_BASE_URL`): user recipients get a user token, `route` recipients an address token (needs a blind-index key, otherwise no link); a `data.unsubscribeUrl` from the caller wins. `unsubscribe.tenantOrigin: (ctx) => origin | null` points the link at the tenant's own host (subdomain or custom domain, must serve this app's `/api`); null, a throw or a non-https value fall back to the app origin. The token carries the mail's locale, so the confirmation and result pages render in the mail's language. To react to a no-account opt-out (e.g. remove the app's own subscriber row), declare `r.job(name, { trigger: { on: DeliveryHandlers.unsubscribeAddress } }, handler)` on a lane the app consumes: it runs after each committed opt-out, also an idempotent repeat, with `unsubscribeAddressPayloadSchema` (`addressHash`, `notificationType`, `channel`) as payload and the tenant in `ctx.triggeredBy.tenantId`; match the subscriber via `hashUnsubscribeAddress(email)`. Declare `unsubscribeUrl` as an optional template variable if the mail body shows it.
|
|
209
|
+
The notification dispatch core: call `ctx.notify(notificationType, { to, route, data, priority, idempotencyKey })` from any handler to fan out a notification across all registered channels (email, in-app, push). Inside a write handler the delivery runs after the write has committed (the call returns `{ kind: "deferred" }`, a rolled-back write sends nothing); `immediate: true` delivers synchronously and returns `{ kind: "dispatched", deliveries }`. It stores per-user channel preferences in the `notification-preference` entity, opt-outs for no-account recipient addresses in `notification-address-opt-out` (keyed by a blind-index hash, never the plaintext address), logs every attempt to `store_delivery_attempts`, and enforces idempotency and rate-limiting — add `channel-email`, `channel-in-app`, or `channel-push` on top to actually send anything. Unsubscribe/resubscribe links are served by `createUnsubscribeRoutes({ secret })` mounted via the app's `extraRoutes` at `/api/delivery/unsubscribe` and `/api/delivery/resubscribe`: `GET /unsubscribe?token=` renders a confirmation page (no write), `POST /unsubscribe` performs the opt-out (RFC 8058 one-click, token from the form body or query), `POST /resubscribe` undoes it (JSON `{token}` or form body only, never the query — reachable by a mail-client link prefetcher) — sign links with `signUnsubscribeToken` / `signAddressUnsubscribeToken` using the same secret. `channel-email` sets `List-Unsubscribe` / `List-Unsubscribe-Post` automatically when a message's `data.unsubscribeUrl` points at the unsubscribe route. A notification declared with `unsubscribe: true` gets that link from delivery itself when the service has `unsubscribeLinks` (the `runProdApp` and `runDevApp` option `unsubscribe: { secret }` mounts the routes and wires it; base URL from `auth.publicOrigin`, `auth.mail.baseUrl` or `APP_BASE_URL`): user recipients get a user token, `route` recipients an address token (needs a blind-index key, otherwise no link); a `data.unsubscribeUrl` from the caller wins. `unsubscribe.tenantOrigin: (ctx) => origin | null` points the link at the tenant's own host (subdomain or custom domain, must serve this app's `/api`); null, a throw or a non-https value fall back to the app origin. The token carries the mail's locale, so the confirmation and result pages render in the mail's language. An app serving its own (e.g. legacy) unsubscribe links renders the same result page with `renderUnsubscribeResultPage({ kind, locale })`. To react to a no-account opt-out (e.g. remove the app's own subscriber row), declare `r.job(name, { trigger: { on: DeliveryHandlers.unsubscribeAddress } }, handler)` on a lane the app consumes: it runs after each committed opt-out, also an idempotent repeat, with `unsubscribeAddressPayloadSchema` (`addressHash`, `notificationType`, `channel`) as payload and the tenant in `ctx.triggeredBy.tenantId`; match the subscriber via `hashUnsubscribeAddress(email)`. Declare `unsubscribeUrl` as an optional template variable if the mail body shows it.
|
|
210
210
|
|
|
211
211
|
Category: notifications
|
|
212
212
|
|
|
@@ -413,7 +413,7 @@ German UI and mail copy for framework screens. Opt-in; not included by includeBu
|
|
|
413
413
|
|
|
414
414
|
## mail-foundation
|
|
415
415
|
|
|
416
|
-
Defines the `mailTransport` extension point and a per-tenant `provider` config key that selects which registered transport plugin to use at runtime. Call `createTransportForTenant(ctx, tenantId)` to get an `EmailTransport` ready for sending — use this feature together with at least one `mail-transport-*` feature; use `delivery` + `channel-email` instead when you need the full notification pipeline with delivery attempts and user preferences. Pass `tenantMailTransport` as `tenantTransport` to `createChannelEmailFeature`
|
|
416
|
+
Defines the `mailTransport` extension point and a per-tenant `provider` config key that selects which registered transport plugin to use at runtime. Call `createTransportForTenant(ctx, tenantId)` to get an `EmailTransport` ready for sending — use this feature together with at least one `mail-transport-*` feature; use `delivery` + `channel-email` instead when you need the full notification pipeline with delivery attempts and user preferences. Pass `tenantMailTransport` as `tenantTransport` to `createChannelEmailFeature`: notifications declared with `tenantTransport: true` then go through the tenant's own transport when it has a provider and through the app transport otherwise. Tenant admins pick the provider and fill in its settings on the generated Email sending settings screen; an empty provider is a valid choice and means the platform sends.
|
|
417
417
|
|
|
418
418
|
Category: notifications
|
|
419
419
|
|
|
@@ -558,7 +558,7 @@ Requires: `renderer-foundation`
|
|
|
558
558
|
|
|
559
559
|
## secrets
|
|
560
560
|
|
|
561
|
-
Stores arbitrary per-tenant secrets (API keys, tokens, credentials) encrypted at rest using AES-256 with a KEK loaded from `KUMIKO_SECRETS_MASTER_KEY_V1` (and successive versions for rotation). Read a secret in handlers via `ctx.secrets.get(tenantId, handle)`, which automatically appends a `tenantSecretRead` audit event so every access is traceable. A `rotate` job re-encrypts all envelopes after a KEK version bump. The `set`/`delete`/`list` handlers share one access rule — default { roles: ["TenantAdmin"] }; adopt the host's role vocabulary with createSecretsFeature({ roles }) or open it to every authenticated tenant user with { access: { openToAll: { reason } } } (larger blast radius: any tenant member can then read secret previews and write/delete secrets, not just admins).
|
|
561
|
+
Stores arbitrary per-tenant secrets (API keys, tokens, credentials) encrypted at rest using AES-256 with a KEK loaded from `KUMIKO_SECRETS_MASTER_KEY_V1` (and successive versions for rotation). Read a secret in handlers via `ctx.secrets.get(tenantId, handle)`, which automatically appends a `tenantSecretRead` audit event so every access is traceable. A `rotate` job re-encrypts all envelopes after a KEK version bump. The `set`/`delete`/`list` handlers share one access rule — default { roles: ["TenantAdmin"] }; adopt the host's role vocabulary with createSecretsFeature({ roles }) or open it to every authenticated tenant user with { access: { openToAll: { reason } } } (larger blast radius: any tenant member can then read secret previews and write/delete secrets, not just admins). Server-side code writes a tenant secret with `dispatchSystemWrite({ handlerQn: "secrets:write:set", payload, tenantId })`: set and delete also admit the in-process system identity, so such writes pass the same declared-key and value checks.
|
|
562
562
|
|
|
563
563
|
Category: infrastructure
|
|
564
564
|
|
|
@@ -551,6 +551,25 @@ function validateOptionsQueryFieldRefs(where, fieldName, payload, siblingFieldNa
|
|
|
551
551
|
function isWriteOnlyField(fdef) {
|
|
552
552
|
return fdef?.writeOnly === true; // @cast-boundary schema-walk
|
|
553
553
|
}
|
|
554
|
+
// A secret on a configEdit form keeps its value out of the client like an encrypted
|
|
555
|
+
// config key does, and the secrets store only exists per tenant.
|
|
556
|
+
function validateConfigEditSecretField(featureName, screen, fieldName, secretQn, featureMap) {
|
|
557
|
+
const where = `[Feature ${featureName}] Screen "${screen.id}" (configEdit) field "${fieldName}"`;
|
|
558
|
+
if (screen.configKeys[fieldName] !== undefined) {
|
|
559
|
+
throw new Error(`${where} is mapped in both configKeys and secretKeys — keep one.`);
|
|
560
|
+
}
|
|
561
|
+
if (screen.scope !== "tenant") {
|
|
562
|
+
throw new Error(`${where} maps secret "${secretQn}" on a ${screen.scope}-scope screen — secrets are tenant-scoped.`);
|
|
563
|
+
}
|
|
564
|
+
const fdef = screen.fields[fieldName];
|
|
565
|
+
if (fdef?.type !== "text" || !isWriteOnlyField(fdef)) {
|
|
566
|
+
throw new Error(`${where} maps secret "${secretQn}" but is no writeOnly text field — a stored secret never goes back to the client.`);
|
|
567
|
+
}
|
|
568
|
+
const declared = [...featureMap.values()].some((feature) => Object.values(feature.secretKeys ?? {}).some((def) => def.qualifiedName === secretQn));
|
|
569
|
+
if (!declared) {
|
|
570
|
+
throw new Error(`${where} → secret "${secretQn}" is not declared via r.secret() in any feature.`);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
554
573
|
// Inline forms have no stored value to hide or keep: writeOnly there would
|
|
555
574
|
// render a "set / keep" affordance with nothing behind it.
|
|
556
575
|
function rejectWriteOnlyFormField(featureName, screenId, context, fieldName, fdef) {
|
|
@@ -1477,9 +1496,19 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
|
|
|
1477
1496
|
}
|
|
1478
1497
|
}
|
|
1479
1498
|
validateWizardLayout(feature.name, screenId, "configEdit", screen.layout, featureMap);
|
|
1499
|
+
for (const fname of Object.keys(screen.secretKeys ?? {})) {
|
|
1500
|
+
if (!fieldNames.has(fname)) {
|
|
1501
|
+
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (configEdit) secretKeys names unknown field "${fname}".`);
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1480
1504
|
// configKeys: jeder fieldName muss einen Mapping-Eintrag haben,
|
|
1481
1505
|
// jeder qualifizierte Key muss in der Registry existieren.
|
|
1482
1506
|
for (const fname of fieldNames) {
|
|
1507
|
+
const secretQn = screen.secretKeys?.[fname];
|
|
1508
|
+
if (secretQn !== undefined) {
|
|
1509
|
+
validateConfigEditSecretField(feature.name, screen, fname, secretQn, featureMap);
|
|
1510
|
+
continue;
|
|
1511
|
+
}
|
|
1483
1512
|
const qualified = screen.configKeys[fname];
|
|
1484
1513
|
if (qualified === undefined) {
|
|
1485
1514
|
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (configEdit) field "${fname}" has ` +
|
|
@@ -17,9 +17,10 @@
|
|
|
17
17
|
import { isOptionsQueryFieldRef } from "@cosmicdrift/kumiko-types/fields";
|
|
18
18
|
import { CONFIG_EDIT_ENTITY, fieldLabelKey } from "../i18n/required-surface-keys.js";
|
|
19
19
|
import { isEncryptedAtRest } from "./config-helpers.js";
|
|
20
|
-
import { EXTENSION_SELECTOR_HINT_KEY,
|
|
20
|
+
import { EXTENSION_SELECTOR_HINT_KEY, isSelectableInSettings, settingsSelectablePluginIds, } from "./extension-selector-plugins.js";
|
|
21
21
|
import { createBooleanField, createNumberField, createSelectField, createTextField, } from "./factories.js";
|
|
22
22
|
import { isKebabSegment, toKebab } from "./qualified-name.js";
|
|
23
|
+
import { isFieldsEditSection, sectionFieldSpecs } from "./screen-helpers.js";
|
|
23
24
|
import { isOpenToAllGranted } from "./types/handlers.js";
|
|
24
25
|
// Namespace, unter dem buildAppSchema die generierten Screens/Navs einhängt
|
|
25
26
|
// (find-or-create FeatureSchema). MUSS gleich CONFIG_FEATURE aus dem config
|
|
@@ -45,7 +46,7 @@ const PROVIDER_PANEL_TITLE_ENTRY = {
|
|
|
45
46
|
es: "Proveedor",
|
|
46
47
|
};
|
|
47
48
|
const PROVIDER_SECTION_TITLE_KEY = "config.settings.provider";
|
|
48
|
-
const
|
|
49
|
+
const SECRETS_SECTION_DESCRIPTION_KEY = "config.secrets.description";
|
|
49
50
|
const audienceNavShortId = (scope) => `audience-${scope}`;
|
|
50
51
|
// Generated post-boot, never via r.nav() — the boot validator exempts exactly these QNs (an app references one to place the settings group inline).
|
|
51
52
|
export const SETTINGS_HUB_AUDIENCE_NAV_QNS = SCOPES_BROAD_TO_DEEP.map((scope) => `${SETTINGS_HUB_FEATURE}:nav:${audienceNavShortId(scope)}`);
|
|
@@ -73,25 +74,43 @@ const NO_SELECTOR_GATING = {
|
|
|
73
74
|
};
|
|
74
75
|
// Verbatim (unprefixed) keys as the client sees them — NOT getAllTranslations()
|
|
75
76
|
// (server-merged, "feature:"-prefixed, see build-app-schema.ts:77-81).
|
|
77
|
+
// Merged per locale: a locale feature (localeDe) declares the same key with only its
|
|
78
|
+
// own language, and mounted first it must not drop the owning feature's en text.
|
|
76
79
|
function collectDeclaredTranslations(registry) {
|
|
77
80
|
const declared = new Map();
|
|
78
81
|
for (const f of registry.features.values()) {
|
|
79
82
|
for (const [key, values] of Object.entries(f.translations ?? {})) {
|
|
80
|
-
|
|
81
|
-
declared.set(key, values);
|
|
83
|
+
declared.set(key, { ...values, ...declared.get(key) });
|
|
82
84
|
}
|
|
83
85
|
}
|
|
84
86
|
return declared;
|
|
85
87
|
}
|
|
88
|
+
// A selector whose plugins are all hidden from settings still renders as a select
|
|
89
|
+
// (with no option), never as a free text field.
|
|
86
90
|
function collectSelectorOptions(registry) {
|
|
87
91
|
const options = new Map();
|
|
88
92
|
for (const [extensionName, selectorKey] of registry.getAllExtensionSelectors()) {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
93
|
+
if (registry.getExtensionUsages(extensionName).length === 0)
|
|
94
|
+
continue;
|
|
95
|
+
options.set(selectorKey, settingsSelectablePluginIds(registry, extensionName));
|
|
92
96
|
}
|
|
93
97
|
return options;
|
|
94
98
|
}
|
|
99
|
+
// selector key → plugin id → plugin feature, for every selectable plugin, also those
|
|
100
|
+
// without settings of their own (no panel, but still an option with a name).
|
|
101
|
+
function collectSelectorOptionFeatures(registry) {
|
|
102
|
+
const byKey = new Map();
|
|
103
|
+
for (const [extensionName, selectorKey] of registry.getAllExtensionSelectors()) {
|
|
104
|
+
const featureByPluginId = new Map();
|
|
105
|
+
for (const usage of registry.getExtensionUsages(extensionName).filter(isSelectableInSettings)) {
|
|
106
|
+
if (usage.featureName === undefined || featureByPluginId.has(usage.entityName))
|
|
107
|
+
continue;
|
|
108
|
+
featureByPluginId.set(usage.entityName, toKebab(usage.featureName));
|
|
109
|
+
}
|
|
110
|
+
byKey.set(selectorKey, featureByPluginId);
|
|
111
|
+
}
|
|
112
|
+
return byKey;
|
|
113
|
+
}
|
|
95
114
|
function collectVisibleSelectorOwners(registry, visible) {
|
|
96
115
|
const ownerGroupByExtension = new Map();
|
|
97
116
|
const selectorFeatures = new Set();
|
|
@@ -141,6 +160,9 @@ function planSelectorGating(registry, visible, declaredSecrets, secretsEnabled)
|
|
|
141
160
|
const secretQns = new Set();
|
|
142
161
|
for (const [extension, ownerGroup] of [...ownerGroupByExtension].sort(([a], [b]) => a.localeCompare(b))) {
|
|
143
162
|
const usages = [...registry.getExtensionUsages(extension)].sort((a, b) => (a.featureName ?? "").localeCompare(b.featureName ?? ""));
|
|
163
|
+
const selectorQn = registry.getAllExtensionSelectors().get(extension);
|
|
164
|
+
if (selectorQn === undefined)
|
|
165
|
+
continue;
|
|
144
166
|
for (const usage of usages) {
|
|
145
167
|
if (usage.featureName === undefined)
|
|
146
168
|
continue;
|
|
@@ -148,11 +170,22 @@ function planSelectorGating(registry, visible, declaredSecrets, secretsEnabled)
|
|
|
148
170
|
if (usageCountByFeature.get(feature) !== 1 || selectorFeatures.has(feature))
|
|
149
171
|
continue;
|
|
150
172
|
const scopedKeys = visible.filter((v) => v.key.ownerFeature === feature);
|
|
151
|
-
|
|
173
|
+
// The provider form has no per-field access gate, so a secret with its own write
|
|
174
|
+
// roles stays on the secrets screen, which enforces them.
|
|
175
|
+
const secrets = secretsEnabled
|
|
176
|
+
? declaredSecrets.filter((s) => s.feature === feature && s.def.writeRoles === undefined)
|
|
177
|
+
: [];
|
|
152
178
|
if (scopedKeys.length === 0 && secrets.length === 0)
|
|
153
179
|
continue;
|
|
154
180
|
const plugins = pluginsByOwnerGroup.get(ownerGroup) ?? [];
|
|
155
|
-
plugins.push({
|
|
181
|
+
plugins.push({
|
|
182
|
+
feature,
|
|
183
|
+
extension,
|
|
184
|
+
selectorQn,
|
|
185
|
+
pluginId: usage.entityName,
|
|
186
|
+
scopedKeys,
|
|
187
|
+
secrets,
|
|
188
|
+
});
|
|
156
189
|
pluginsByOwnerGroup.set(ownerGroup, plugins);
|
|
157
190
|
pluginFeatures.add(feature);
|
|
158
191
|
for (const s of secrets)
|
|
@@ -204,9 +237,6 @@ function buildFeatureScreensAndNavs(scope, visible, hub, gating, secretsAccess)
|
|
|
204
237
|
}
|
|
205
238
|
return out;
|
|
206
239
|
}
|
|
207
|
-
function pluginTenantScreenId(plugin) {
|
|
208
|
-
return `${plugin.feature}-tenant`;
|
|
209
|
-
}
|
|
210
240
|
function assertUniquePanelIds(ownerGroup, panels) {
|
|
211
241
|
const seen = new Set();
|
|
212
242
|
for (const panel of panels) {
|
|
@@ -218,63 +248,38 @@ function assertUniquePanelIds(ownerGroup, panels) {
|
|
|
218
248
|
}
|
|
219
249
|
return panels;
|
|
220
250
|
}
|
|
221
|
-
//
|
|
222
|
-
//
|
|
251
|
+
// One form for the provider and the settings of every provider: a single save writes
|
|
252
|
+
// all of it, so switching sections can't drop typed input. Each plugin's fields are
|
|
253
|
+
// shown only while its provider is picked in the form; hidden fields are not saved.
|
|
223
254
|
function buildSelectorOwnerDashboard(ownerGroup, ownerKeys, ownerAccess, plugins, ownerPanels, hub, secretsAccess) {
|
|
224
255
|
const selectionScreenId = `${ownerGroup}-tenant-selection`;
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
submitLabel: SAVE_PROVIDER_LABEL_KEY,
|
|
230
|
-
}),
|
|
231
|
-
dormant: true,
|
|
232
|
-
};
|
|
233
|
-
const screens = [selectionScreen];
|
|
234
|
-
const configPanels = [];
|
|
235
|
-
const secretsPanels = [];
|
|
256
|
+
const form = new ProviderFormBuilder(selectionScreenId, buildScreen(selectionScreenId, "tenant", ownerGroup, ownerKeys, ownerAccess, hub, {
|
|
257
|
+
description: EXTENSION_SELECTOR_HINT_KEY,
|
|
258
|
+
sectionTitle: PROVIDER_SECTION_TITLE_KEY,
|
|
259
|
+
}));
|
|
236
260
|
const accessRules = [ownerAccess];
|
|
237
261
|
const translations = {};
|
|
238
262
|
translations[`screen:${selectionScreenId}.title`] = PROVIDER_PANEL_TITLE_ENTRY;
|
|
239
|
-
Object.assign(translations, providerOptionTranslations(
|
|
263
|
+
Object.assign(translations, providerOptionTranslations(form.base, hub));
|
|
240
264
|
for (const plugin of plugins) {
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
eq: plugin.pluginId,
|
|
245
|
-
};
|
|
265
|
+
const providerFieldId = form.fieldIdOfConfigKey(plugin.selectorQn);
|
|
266
|
+
const visible = { field: providerFieldId, eq: plugin.pluginId };
|
|
267
|
+
let pluginSection;
|
|
246
268
|
if (plugin.scopedKeys.length > 0) {
|
|
247
269
|
const pluginAccess = rolesToAccess(plugin.scopedKeys.flatMap((v) => v.roles));
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
...buildScreen(pluginTenantScreenId(plugin), "tenant", plugin.feature, ordered, pluginAccess, hub, { description: null }),
|
|
251
|
-
dormant: true,
|
|
252
|
-
});
|
|
270
|
+
const pluginScreen = buildScreen(`${plugin.feature}-tenant`, "tenant", plugin.feature, sortByMaskOrder(plugin.scopedKeys.map((v) => v.key)), pluginAccess, hub, { description: null, fieldIdOf: qualifiedFieldId });
|
|
271
|
+
pluginSection = form.addConfigFields(pluginScreen, visible);
|
|
253
272
|
accessRules.push(pluginAccess);
|
|
254
|
-
configPanels.push({
|
|
255
|
-
kind: "screen",
|
|
256
|
-
id: `${plugin.feature}-config`,
|
|
257
|
-
screen: pluginTenantScreenId(plugin),
|
|
258
|
-
visibleWhen,
|
|
259
|
-
chromeless: true,
|
|
260
|
-
});
|
|
261
273
|
}
|
|
262
274
|
if (plugin.secrets.length > 0) {
|
|
263
|
-
const
|
|
264
|
-
|
|
265
|
-
screens.push({ ...generated.screen, dormant: true });
|
|
275
|
+
const generated = buildSecretsEditScreen(`${plugin.feature}-tenant-secrets`, plugin.secrets, secretsAccess, hub.declaredTranslationKeys);
|
|
276
|
+
form.addSecretFields(generated.screen, plugin.feature, visible, pluginSection);
|
|
266
277
|
Object.assign(translations, generated.translations);
|
|
267
278
|
accessRules.push(secretsAccess);
|
|
268
|
-
secretsPanels.push({
|
|
269
|
-
kind: "screen",
|
|
270
|
-
id: `${plugin.feature}-secrets`,
|
|
271
|
-
screen: secretsScreenId,
|
|
272
|
-
visibleWhen,
|
|
273
|
-
chromeless: true,
|
|
274
|
-
});
|
|
275
279
|
}
|
|
276
280
|
}
|
|
277
281
|
const access = unionAccessRules(accessRules);
|
|
282
|
+
const selectionScreen = { ...form.build(access), dormant: true };
|
|
278
283
|
const dashboard = {
|
|
279
284
|
id: `${ownerGroup}-tenant`,
|
|
280
285
|
type: "dashboard",
|
|
@@ -282,16 +287,136 @@ function buildSelectorOwnerDashboard(ownerGroup, ownerKeys, ownerAccess, plugins
|
|
|
282
287
|
panels: assertUniquePanelIds(ownerGroup, [
|
|
283
288
|
{ kind: "screen", id: "selection", screen: selectionScreenId, chromeless: true },
|
|
284
289
|
...ownerPanels,
|
|
285
|
-
...configPanels,
|
|
286
|
-
...secretsPanels,
|
|
287
290
|
]),
|
|
288
291
|
access,
|
|
289
292
|
};
|
|
290
|
-
return { screens: [dashboard,
|
|
293
|
+
return { screens: [dashboard, selectionScreen], access, translations };
|
|
294
|
+
}
|
|
295
|
+
// A plugin key keeps its feature in the field id: the provider form holds the keys of
|
|
296
|
+
// several features side by side.
|
|
297
|
+
function qualifiedFieldId(k) {
|
|
298
|
+
return `${k.ownerFeature}-${k.shortKey}`;
|
|
299
|
+
}
|
|
300
|
+
class ProviderFormBuilder {
|
|
301
|
+
screenId;
|
|
302
|
+
base;
|
|
303
|
+
configKeys;
|
|
304
|
+
secretKeys = {};
|
|
305
|
+
fields;
|
|
306
|
+
fieldLabels;
|
|
307
|
+
fieldDescriptions;
|
|
308
|
+
requiredFields;
|
|
309
|
+
sections;
|
|
310
|
+
constructor(screenId, base) {
|
|
311
|
+
this.screenId = screenId;
|
|
312
|
+
this.base = base;
|
|
313
|
+
this.configKeys = { ...base.configKeys };
|
|
314
|
+
this.fields = { ...base.fields };
|
|
315
|
+
this.fieldLabels = { ...base.fieldLabels };
|
|
316
|
+
this.fieldDescriptions = { ...base.fieldDescriptions };
|
|
317
|
+
this.requiredFields = [...(base.requiredFields ?? [])];
|
|
318
|
+
this.sections = base.layout.sections.filter(isFieldsEditSection);
|
|
319
|
+
}
|
|
320
|
+
fieldIdOfConfigKey(qn) {
|
|
321
|
+
const entry = Object.entries(this.base.configKeys).find(([, keyQn]) => keyQn === qn);
|
|
322
|
+
if (entry === undefined) {
|
|
323
|
+
throw new Error(`[Settings-Hub] selector key "${qn}" is not on "${this.screenId}".`);
|
|
324
|
+
}
|
|
325
|
+
return entry[0];
|
|
326
|
+
}
|
|
327
|
+
addConfigFields(pluginScreen, visible) {
|
|
328
|
+
for (const [id, qn] of Object.entries(pluginScreen.configKeys)) {
|
|
329
|
+
this.claim(id, qn);
|
|
330
|
+
this.configKeys[id] = qn;
|
|
331
|
+
const field = pluginScreen.fields[id];
|
|
332
|
+
if (field !== undefined)
|
|
333
|
+
this.fields[id] = field;
|
|
334
|
+
}
|
|
335
|
+
Object.assign(this.fieldLabels, pluginScreen.fieldLabels);
|
|
336
|
+
Object.assign(this.fieldDescriptions, pluginScreen.fieldDescriptions);
|
|
337
|
+
this.requiredFields.push(...(pluginScreen.requiredFields ?? []));
|
|
338
|
+
const section = pluginScreen.layout.sections.find(isFieldsEditSection);
|
|
339
|
+
if (section === undefined)
|
|
340
|
+
return undefined;
|
|
341
|
+
const { groups: _groups, ...flatSection } = section;
|
|
342
|
+
const gated = {
|
|
343
|
+
...flatSection,
|
|
344
|
+
fields: gateFields(sectionFieldSpecs(section), visible),
|
|
345
|
+
};
|
|
346
|
+
this.sections.push(gated);
|
|
347
|
+
return gated;
|
|
348
|
+
}
|
|
349
|
+
// A plugin's sign-in data gets its own section when the plugin names one, else it
|
|
350
|
+
// continues the plugin's settings section.
|
|
351
|
+
addSecretFields(secretsScreen, feature, visible, pluginSection) {
|
|
352
|
+
for (const [id, qn] of Object.entries(secretsScreen.secretKeys)) {
|
|
353
|
+
this.claim(id, qn);
|
|
354
|
+
this.secretKeys[id] = qn;
|
|
355
|
+
this.fields[id] = createTextField({
|
|
356
|
+
personal: false,
|
|
357
|
+
reason: "system_metadata",
|
|
358
|
+
writeOnly: true,
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
Object.assign(this.fieldLabels, secretsScreen.fieldLabels);
|
|
362
|
+
Object.assign(this.fieldDescriptions, secretsScreen.fieldHints);
|
|
363
|
+
this.requiredFields.push(...(secretsScreen.requiredFields ?? []));
|
|
364
|
+
const secretsTitleKey = `${feature}.settings.secrets`;
|
|
365
|
+
for (const section of secretsScreen.sections) {
|
|
366
|
+
// kumiko-lint-ignore section-fields-raw a secretsEdit section is a flat key list without groups
|
|
367
|
+
const fields = gateFields(section.fields, visible);
|
|
368
|
+
const ownSection = section.title === secretsTitleKey || pluginSection === undefined;
|
|
369
|
+
if (ownSection) {
|
|
370
|
+
this.sections.push({
|
|
371
|
+
title: section.title ?? `${feature}.settings`,
|
|
372
|
+
description: SECRETS_SECTION_DESCRIPTION_KEY,
|
|
373
|
+
fields,
|
|
374
|
+
});
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
const index = this.sections.indexOf(pluginSection);
|
|
378
|
+
this.sections[index] = {
|
|
379
|
+
...pluginSection,
|
|
380
|
+
fields: [...sectionFieldSpecs(pluginSection), ...fields],
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
build(access) {
|
|
385
|
+
const { fieldDescriptions: _baseDescriptions, requiredFields: _baseRequired, ...base } = this.base;
|
|
386
|
+
return {
|
|
387
|
+
...base,
|
|
388
|
+
configKeys: this.configKeys,
|
|
389
|
+
...(Object.keys(this.secretKeys).length > 0 && { secretKeys: this.secretKeys }),
|
|
390
|
+
fields: this.fields,
|
|
391
|
+
fieldLabels: this.fieldLabels,
|
|
392
|
+
...(Object.keys(this.fieldDescriptions).length > 0 && {
|
|
393
|
+
fieldDescriptions: this.fieldDescriptions,
|
|
394
|
+
}),
|
|
395
|
+
...(this.requiredFields.length > 0 && { requiredFields: this.requiredFields }),
|
|
396
|
+
layout: { ...base.layout, sections: this.sections },
|
|
397
|
+
access,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
claim(id, qn) {
|
|
401
|
+
const previous = this.configKeys[id] ?? this.secretKeys[id];
|
|
402
|
+
if (previous !== undefined) {
|
|
403
|
+
throw new Error(`[Settings-Hub] "${previous}" and "${qn}" both resolve to field id "${id}" on "${this.screenId}" — rename one key.`);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
function gateFields(fields, visible) {
|
|
408
|
+
return fields.map((spec) => typeof spec === "string" ? { field: spec, visible } : { ...spec, visible });
|
|
291
409
|
}
|
|
292
410
|
// Everything generated for one audience scope: the audience-parent nav, the
|
|
293
411
|
// per-feature configEdit screens+navs, and (tenant-scope only, when secrets
|
|
294
412
|
// are enabled) the secrets screen. Null when nothing is visible at this scope.
|
|
413
|
+
// The secrets set handler also admits the machine role for server-side writes; like the
|
|
414
|
+
// config keys' write roles, that role must not reach a screen or nav access gate.
|
|
415
|
+
function withoutMachineRole(rule) {
|
|
416
|
+
if (rule === undefined || !("roles" in rule))
|
|
417
|
+
return rule;
|
|
418
|
+
return { ...rule, roles: rule.roles.filter((role) => role !== MACHINE_WRITE_ROLE) };
|
|
419
|
+
}
|
|
295
420
|
function buildScopeResult(scope, masked, secretsEnabled, secretsWriteHandler, declaredSecrets, hub, registry) {
|
|
296
421
|
const visible = scopedKeysAt(masked, scope);
|
|
297
422
|
// Secrets attach to the tenant-audience nav regardless of whether any
|
|
@@ -300,7 +425,7 @@ function buildScopeResult(scope, masked, secretsEnabled, secretsWriteHandler, de
|
|
|
300
425
|
if (visible.length === 0 && !includeSecrets)
|
|
301
426
|
return null;
|
|
302
427
|
const configAccess = rolesToAccess(visible.flatMap((v) => v.roles));
|
|
303
|
-
const secretsAccess = secretsWriteHandler?.access;
|
|
428
|
+
const secretsAccess = withoutMachineRole(secretsWriteHandler?.access);
|
|
304
429
|
const gating = scope === "tenant"
|
|
305
430
|
? planSelectorGating(registry, visible, declaredSecrets, secretsEnabled)
|
|
306
431
|
: NO_SELECTOR_GATING;
|
|
@@ -346,6 +471,7 @@ export function buildConfigFeatureSchema(registry) {
|
|
|
346
471
|
const hub = {
|
|
347
472
|
declaredTranslationKeys: new Set(translationsByKey.keys()),
|
|
348
473
|
selectorOptions: collectSelectorOptions(registry),
|
|
474
|
+
selectorOptionFeatures: collectSelectorOptionFeatures(registry),
|
|
349
475
|
translationsByKey,
|
|
350
476
|
};
|
|
351
477
|
const screens = [];
|
|
@@ -440,7 +566,12 @@ function buildSecretsEditScreen(screenId, secrets, access, declaredTranslationKe
|
|
|
440
566
|
if (s.def.writeRoles !== undefined)
|
|
441
567
|
fieldAccess[fieldId] = { roles: [...s.def.writeRoles] };
|
|
442
568
|
}
|
|
443
|
-
|
|
569
|
+
// Opt-in `${feature}.settings.secrets`: in a selector dashboard this section sits right
|
|
570
|
+
// under the plugin's own settings section, which already carries `${feature}.settings`.
|
|
571
|
+
const secretsTitleKey = `${feature}.settings.secrets`;
|
|
572
|
+
const titleKey = declaredTranslationKeys.has(secretsTitleKey)
|
|
573
|
+
? secretsTitleKey
|
|
574
|
+
: `${feature}.settings`;
|
|
444
575
|
sections.push({
|
|
445
576
|
...(declaredTranslationKeys.has(titleKey) && { title: titleKey }),
|
|
446
577
|
fields: fieldIds,
|
|
@@ -514,7 +645,8 @@ function buildScreen(shortId, scope, feature, keys, access, hub, options = {}) {
|
|
|
514
645
|
// feature's group (100% of today's apps — byte-identical output). Only a
|
|
515
646
|
// cross-feature `group` (feature !== ownerFeature) needs the owner prefix,
|
|
516
647
|
// to keep two features sharing a group from clobbering the same shortKey.
|
|
517
|
-
const fieldId =
|
|
648
|
+
const fieldId = options.fieldIdOf ??
|
|
649
|
+
((k) => (k.feature === k.ownerFeature ? k.shortKey : qualifiedFieldId(k)));
|
|
518
650
|
const seenFieldIds = new Map();
|
|
519
651
|
for (const k of keys) {
|
|
520
652
|
const id = fieldId(k);
|
|
@@ -544,7 +676,6 @@ function buildScreen(shortId, scope, feature, keys, access, hub, options = {}) {
|
|
|
544
676
|
...(Object.keys(fieldDescriptions).length > 0 && { fieldDescriptions }),
|
|
545
677
|
...(requiredFields.length > 0 && { requiredFields }),
|
|
546
678
|
...(screenDescription !== undefined && { description: screenDescription }),
|
|
547
|
-
...(options.submitLabel !== undefined && { submitLabel: options.submitLabel }),
|
|
548
679
|
layout: { sections: [section], width: "full", variant: "settings-list" },
|
|
549
680
|
access,
|
|
550
681
|
};
|
|
@@ -589,16 +720,17 @@ function buildSettingsSection(feature, scope, fieldIds, hub, options) {
|
|
|
589
720
|
}
|
|
590
721
|
// Option labels of the provider select: each plugin feature's `<feature>.settings`
|
|
591
722
|
// copy, under the option-label key the configEdit renderer already derives.
|
|
592
|
-
function providerOptionTranslations(selectionScreen,
|
|
723
|
+
function providerOptionTranslations(selectionScreen, hub) {
|
|
593
724
|
const out = {};
|
|
594
725
|
for (const [fieldId, qn] of Object.entries(selectionScreen.configKeys)) {
|
|
595
|
-
|
|
726
|
+
const featureByPluginId = hub.selectorOptionFeatures.get(qn);
|
|
727
|
+
if (featureByPluginId === undefined)
|
|
596
728
|
continue;
|
|
597
|
-
for (const
|
|
598
|
-
const label = hub.translationsByKey.get(`${
|
|
729
|
+
for (const [pluginId, feature] of featureByPluginId) {
|
|
730
|
+
const label = hub.translationsByKey.get(`${feature}.settings`);
|
|
599
731
|
if (label === undefined)
|
|
600
732
|
continue;
|
|
601
|
-
const key = `${fieldLabelKey(SETTINGS_HUB_FEATURE, CONFIG_EDIT_ENTITY, fieldId)}:option:${
|
|
733
|
+
const key = `${fieldLabelKey(SETTINGS_HUB_FEATURE, CONFIG_EDIT_ENTITY, fieldId)}:option:${pluginId}`;
|
|
602
734
|
out[key] = label;
|
|
603
735
|
}
|
|
604
736
|
}
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { Registry } from "./types/feature.js";
|
|
2
|
+
import type { RegistrarExtensionRegistration } from "./types/index.js";
|
|
2
3
|
export declare const SELECTED_EXTENSIONS_QUERY = "config:query:config-value:selected-extensions";
|
|
3
4
|
export declare const EXTENSION_SELECTOR_HINT_KEY = "config.settings.extensionSelectorHint";
|
|
4
5
|
export declare function selectablePluginIds(registry: Pick<Registry, "getExtensionUsages">, extensionName: string): string[];
|
|
6
|
+
export declare function isSelectableInSettings(usage: RegistrarExtensionRegistration): boolean;
|
|
7
|
+
export declare function settingsSelectablePluginIds(registry: Pick<Registry, "getExtensionUsages">, extensionName: string): string[];
|
|
5
8
|
export declare function extensionSelectorTargets(registry: Pick<Registry, "getAllExtensionSelectors">): ReadonlyMap<string, string>;
|
|
@@ -5,6 +5,20 @@ export const EXTENSION_SELECTOR_HINT_KEY = "config.settings.extensionSelectorHin
|
|
|
5
5
|
export function selectablePluginIds(registry, extensionName) {
|
|
6
6
|
return [...new Set(registry.getExtensionUsages(extensionName).map((u) => u.entityName))].sort();
|
|
7
7
|
}
|
|
8
|
+
// A plugin registered with `selectableInSettings: false` (a test inbox) stays a valid
|
|
9
|
+
// stored value for seeds and dev setups, but the generated settings never offer it.
|
|
10
|
+
const SELECTABLE_IN_SETTINGS_OPTION = "selectableInSettings";
|
|
11
|
+
export function isSelectableInSettings(usage) {
|
|
12
|
+
return usage.options?.[SELECTABLE_IN_SETTINGS_OPTION] !== false;
|
|
13
|
+
}
|
|
14
|
+
export function settingsSelectablePluginIds(registry, extensionName) {
|
|
15
|
+
return [
|
|
16
|
+
...new Set(registry
|
|
17
|
+
.getExtensionUsages(extensionName)
|
|
18
|
+
.filter(isSelectableInSettings)
|
|
19
|
+
.map((u) => u.entityName)),
|
|
20
|
+
].sort();
|
|
21
|
+
}
|
|
8
22
|
export function extensionSelectorTargets(registry) {
|
|
9
23
|
const extensionByKey = new Map();
|
|
10
24
|
for (const [extensionName, selectorKey] of registry.getAllExtensionSelectors()) {
|
|
@@ -28,6 +28,7 @@ export declare function buildConfigEventsJobsMethods<TName extends string>(state
|
|
|
28
28
|
readonly renamedFrom?: readonly string[];
|
|
29
29
|
readonly systemOnly?: boolean;
|
|
30
30
|
readonly unsubscribe?: boolean;
|
|
31
|
+
readonly tenantTransport?: boolean;
|
|
31
32
|
}), definition?: {
|
|
32
33
|
readonly trigger?: {
|
|
33
34
|
readonly on: NameOrRef;
|
|
@@ -43,6 +44,7 @@ export declare function buildConfigEventsJobsMethods<TName extends string>(state
|
|
|
43
44
|
readonly renamedFrom?: readonly string[];
|
|
44
45
|
readonly systemOnly?: boolean;
|
|
45
46
|
readonly unsubscribe?: boolean;
|
|
47
|
+
readonly tenantTransport?: boolean;
|
|
46
48
|
}): void;
|
|
47
49
|
translations(def: TranslationsDef): void;
|
|
48
50
|
defineEvent: <const TInner extends string, TPayload>(eventName: TInner, schema: ZodType<TPayload>, options: {
|
|
@@ -222,6 +222,7 @@ export function buildConfigEventsJobsMethods(state, name) {
|
|
|
222
222
|
: {}),
|
|
223
223
|
...(resolvedDefinition.systemOnly === true ? { systemOnly: true } : {}),
|
|
224
224
|
...(resolvedDefinition.unsubscribe === true ? { unsubscribe: true } : {}),
|
|
225
|
+
...(resolvedDefinition.tenantTransport === true ? { tenantTransport: true } : {}),
|
|
225
226
|
};
|
|
226
227
|
},
|
|
227
228
|
translations(def) {
|
|
@@ -16,10 +16,9 @@ export const SETTINGS_HUB_I18N = {
|
|
|
16
16
|
"config.settings.tenant": { en: "Tenant" },
|
|
17
17
|
"config.settings.user": { en: "Personal" },
|
|
18
18
|
"config.settings.extensionSelectorHint": {
|
|
19
|
-
en: "Choose a provider
|
|
19
|
+
en: "Choose a provider. Its settings appear below and are saved together with it.",
|
|
20
20
|
},
|
|
21
21
|
"config.settings.provider": { en: "Provider" },
|
|
22
|
-
"config.settings.saveProvider": { en: "Save provider" },
|
|
23
22
|
"config.settings.audience.system": {
|
|
24
23
|
en: "Applies to every tenant unless a tenant sets its own value.",
|
|
25
24
|
},
|
package/dist/upgrade-cli.js
CHANGED
|
@@ -446,7 +446,7 @@ function writePartialMarker(out, targetDir, pending, manualEntries, failedVersio
|
|
|
446
446
|
});
|
|
447
447
|
out.err(` ⚠ ${ran.length} codemod(s) already applied; wrote marker at ${version} so a re-run resumes at the failed one.`);
|
|
448
448
|
}
|
|
449
|
-
// Runs every pending
|
|
449
|
+
// Runs every pending entry's codemod, oldest version first (so a
|
|
450
450
|
// later codemod can assume an earlier one already ran). Stops on the first
|
|
451
451
|
// failure; codemods that already ran are kept in a partial marker. Writes the
|
|
452
452
|
// marker whenever dryRun is false —
|
|
@@ -474,7 +474,9 @@ markerVersion, previous) {
|
|
|
474
474
|
return 0;
|
|
475
475
|
}
|
|
476
476
|
const breaking = pending.filter((e) => e.type === "breaking");
|
|
477
|
-
|
|
477
|
+
// A codemod is worth running whatever the entry's type: an improvement that
|
|
478
|
+
// moves a module ships one too, and skipping it leaves the deprecated path in place.
|
|
479
|
+
const codemodEntries = pending
|
|
478
480
|
.filter(hasCodemod)
|
|
479
481
|
.sort((a, b) => compareVersions(a.version, b.version));
|
|
480
482
|
const manualEntries = breaking.filter(needsManualMigration);
|
|
@@ -485,7 +487,7 @@ markerVersion, previous) {
|
|
|
485
487
|
}
|
|
486
488
|
if (codemodEntries.length === 0) {
|
|
487
489
|
out.log(breaking.length > 0
|
|
488
|
-
? " No automatable codemods among the pending
|
|
490
|
+
? " No automatable codemods among the pending changes."
|
|
489
491
|
: " ✓ No breaking changes pending.");
|
|
490
492
|
if (!dryRun) {
|
|
491
493
|
const carried = carryOverManual(previous, manualEntries.map(toMarkerManual));
|
|
@@ -636,7 +638,7 @@ kumiko-upgrade --resolve <id|version>[,<id|version>...] --reason "<text>" [--not
|
|
|
636
638
|
--dir <path> target directory to check/apply against (default: cwd)
|
|
637
639
|
--json machine-readable output
|
|
638
640
|
--verbose include full migration details for each pending entry
|
|
639
|
-
--apply run codemods for pending
|
|
641
|
+
--apply run codemods for pending changes and write the upgrade marker
|
|
640
642
|
(one run moves the marker to the installed version; manual migrations stay listed as open)
|
|
641
643
|
--resolve <refs> mark open manual migrations as done; ref = full id or a version with exactly one open entry
|
|
642
644
|
--reason <text> required with --resolve, max 500 characters
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.369.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -221,8 +221,8 @@
|
|
|
221
221
|
"./package.json": "./package.json"
|
|
222
222
|
},
|
|
223
223
|
"dependencies": {
|
|
224
|
-
"@cosmicdrift/kumiko-http": "0.
|
|
225
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
224
|
+
"@cosmicdrift/kumiko-http": "0.369.0",
|
|
225
|
+
"@cosmicdrift/kumiko-types": "0.369.0",
|
|
226
226
|
"bullmq": "^5.76.7",
|
|
227
227
|
"bun-types": "^1.3.13",
|
|
228
228
|
"hono": "^4.13.1",
|
|
@@ -238,8 +238,8 @@
|
|
|
238
238
|
"zod": "^4.4.3"
|
|
239
239
|
},
|
|
240
240
|
"devDependencies": {
|
|
241
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
242
|
-
"@cosmicdrift/kumiko-testing": "0.
|
|
241
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.369.0",
|
|
242
|
+
"@cosmicdrift/kumiko-testing": "0.369.0",
|
|
243
243
|
"bun-types": "^1.3.13",
|
|
244
244
|
"pino-pretty": "^13.1.3"
|
|
245
245
|
},
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.369.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "kumiko-upgrade --apply runs codemods of improvement entries too",
|
|
6
|
+
"detail": "The apply step only ran codemods attached to breaking entries. The 0.368.0 entry that moves the test stack to kumiko-testing is an improvement with a codemod (testing-move), so `bun kumiko-upgrade --apply` skipped it and left the deprecated framework paths in place. Every pending entry with a codemod now runs, in version order; manual-migration reporting is unchanged. Apps whose marker is already at 0.368.0 no longer see that entry as pending, so they run `bun kumiko-upgrade --apply --from 0.367.0` once, or the codemod directly: `bun node_modules/@cosmicdrift/kumiko-framework/src/scripts/codemod/testing-move.ts . --write`."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.368.0",
|
|
4
10
|
"type": "fix",
|