@frockbot/plugin-settings 0.2.4 → 0.2.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/plugin-settings",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -21,14 +21,14 @@
21
21
  "typecheck": "vue-tsc --noEmit -p tsconfig.json"
22
22
  },
23
23
  "dependencies": {
24
- "@frockbot/catalog-core": "0.2.4",
25
- "@frockbot/client-core": "0.2.4",
26
- "@frockbot/client-ui": "0.2.4",
27
- "@frockbot/configuration-core": "0.2.4",
28
- "@frockbot/connection-core": "0.2.4",
29
- "@frockbot/kernel-composition": "0.2.4",
30
- "@frockbot/plugin-auth": "0.2.4",
31
- "@frockbot/plugin-shell": "0.2.4",
24
+ "@frockbot/catalog-core": "0.2.5",
25
+ "@frockbot/client-core": "0.2.5",
26
+ "@frockbot/client-ui": "0.2.5",
27
+ "@frockbot/configuration-core": "0.2.5",
28
+ "@frockbot/connection-core": "0.2.5",
29
+ "@frockbot/kernel-composition": "0.2.5",
30
+ "@frockbot/plugin-auth": "0.2.5",
31
+ "@frockbot/plugin-shell": "0.2.5",
32
32
  "cordis": "4.0.0-rc.8",
33
33
  "vue": "3.5.41"
34
34
  },
@@ -37,16 +37,19 @@ const search = ref("");
37
37
  const busyPackageId = ref<string>();
38
38
 
39
39
  const installations = computed(() => web.value.userSettings?.packages ?? []);
40
+ const userPackages = computed(() =>
41
+ web.value.pluginCatalog.filter((item) => !item.platformOwned),
42
+ );
40
43
  const installedPluginCount = computed(
41
44
  () =>
42
- web.value.pluginCatalog.filter((item) =>
45
+ userPackages.value.filter((item) =>
43
46
  isPackageInstalled(installations.value, item.packageId),
44
47
  ).length,
45
48
  );
46
49
  const filteredCatalog = computed(() => {
47
50
  const query = search.value.trim().toLocaleLowerCase();
48
- if (!query) return web.value.pluginCatalog;
49
- return web.value.pluginCatalog.filter(
51
+ if (!query) return userPackages.value;
52
+ return userPackages.value.filter(
50
53
  (item) =>
51
54
  item.displayName.toLocaleLowerCase().includes(query) ||
52
55
  item.packageId.toLocaleLowerCase().includes(query),
@@ -143,6 +146,14 @@ async function setEnabled(packageId: string, next: boolean): Promise<void> {
143
146
  </span>
144
147
  {{ installedPluginCount }} installed
145
148
  </div>
149
+ <!--
150
+ A refused command is the answer to the click that caused it, so it is
151
+ shown above the list where the click happened, not below a long grid
152
+ where it scrolls out of view.
153
+ -->
154
+ <p v-if="web.settingsError" class="settings-error" role="alert">
155
+ {{ web.settingsError }}
156
+ </p>
146
157
  <label class="plugin-search">
147
158
  <UiIcon name="search" />
148
159
  <input
@@ -223,7 +234,7 @@ async function setEnabled(packageId: string, next: boolean): Promise<void> {
223
234
  </div>
224
235
 
225
236
  <p
226
- v-if="web.pluginCatalog.length === 0 && !web.settingsError"
237
+ v-if="userPackages.length === 0 && !web.settingsError"
227
238
  class="plugin-empty"
228
239
  >
229
240
  This application ships no Packages.
@@ -231,9 +242,6 @@ async function setEnabled(packageId: string, next: boolean): Promise<void> {
231
242
  <p v-else-if="filteredCatalog.length === 0" class="plugin-empty">
232
243
  No Package matches that search.
233
244
  </p>
234
- <p v-if="web.settingsError" class="settings-error" role="alert">
235
- {{ web.settingsError }}
236
- </p>
237
245
  </div>
238
246
  </template>
239
247
 
package/src/user.test.ts CHANGED
@@ -407,6 +407,180 @@ describe("User settings backend Contribution", () => {
407
407
  ).resolves.toMatchObject({ status: "applied" });
408
408
  });
409
409
 
410
+ test("seeds rollout dependencies before validating a pre-existing enablement graph", async () => {
411
+ const storage = new MemoryStorage();
412
+ const availablePackages: AvailableUserPackage[] = [
413
+ {
414
+ packageId: "custom-models",
415
+ version: "0.0.1",
416
+ installByDefault: true,
417
+ defaultEnablement: "disabled",
418
+ dependencies: {
419
+ settings: ">=0.0.1",
420
+ shell: ">=0.0.1",
421
+ "ui-theme": ">=0.0.1",
422
+ },
423
+ },
424
+ {
425
+ packageId: "provider-ollama-cloud",
426
+ version: "0.0.1",
427
+ installByDefault: true,
428
+ defaultEnablement: "disabled",
429
+ dependencies: {
430
+ "custom-models": ">=0.0.1",
431
+ credentials: ">=0.0.1",
432
+ settings: ">=0.0.1",
433
+ },
434
+ },
435
+ { packageId: "credentials", version: "0.0.1", installByDefault: true },
436
+ {
437
+ packageId: "settings",
438
+ version: "0.0.1",
439
+ installByDefault: true,
440
+ dependencies: { shell: ">=0.0.1", "ui-theme": ">=0.0.1" },
441
+ },
442
+ {
443
+ packageId: "shell",
444
+ version: "0.0.1",
445
+ installByDefault: true,
446
+ dependencies: { "ui-theme": ">=0.0.1" },
447
+ },
448
+ { packageId: "ui-theme", version: "0.0.1", installByDefault: true },
449
+ {
450
+ packageId: "mcp",
451
+ version: "0.0.1",
452
+ installByDefault: true,
453
+ dependencies: {
454
+ credentials: ">=0.0.1",
455
+ settings: ">=0.0.1",
456
+ },
457
+ },
458
+ ];
459
+ await storage.put("user-id", "pre-rollout-user");
460
+ await storage.put("user-default-packages-bootstrap:v1", {
461
+ schemaVersion: 1,
462
+ });
463
+ await storage.put("user-configuration", {
464
+ schemaVersion: 1,
465
+ revision: 12,
466
+ profile: { name: "Pre-rollout User" },
467
+ packages: [
468
+ {
469
+ packageId: "shell",
470
+ version: "0.0.1",
471
+ state: "installed",
472
+ provenance: "first-party",
473
+ },
474
+ {
475
+ packageId: "mcp",
476
+ version: "0.0.1",
477
+ state: "installed",
478
+ provenance: "first-party",
479
+ },
480
+ {
481
+ packageId: "provider-ollama-cloud",
482
+ version: "0.0.1",
483
+ state: "installed",
484
+ provenance: "first-party",
485
+ },
486
+ ],
487
+ connections: [],
488
+ });
489
+ const settings = createUserSettingsBackendContribution({
490
+ storage,
491
+ availablePackages,
492
+ });
493
+
494
+ const migrated = await settings.readConfiguration({
495
+ schemaVersion: 1,
496
+ userId: "pre-rollout-user",
497
+ });
498
+
499
+ expect(migrated.packages).toContainEqual(
500
+ expect.objectContaining({ packageId: "shell", state: "installed" }),
501
+ );
502
+ expect(migrated.packages).toContainEqual(
503
+ expect.objectContaining({ packageId: "mcp", state: "installed" }),
504
+ );
505
+ expect(migrated.packages).toContainEqual(
506
+ expect.objectContaining({
507
+ packageId: "custom-models",
508
+ state: "disabled",
509
+ }),
510
+ );
511
+ expect(migrated.packages).toContainEqual(
512
+ expect.objectContaining({
513
+ packageId: "provider-ollama-cloud",
514
+ state: "disabled",
515
+ }),
516
+ );
517
+ });
518
+
519
+ test("repairs v2 rollout damage once and preserves choices made after v3", async () => {
520
+ const storage = new MemoryStorage();
521
+ await storage.put("user-id", "damaged-user");
522
+ await storage.put("user-default-packages-bootstrap:v1", {
523
+ schemaVersion: 2,
524
+ });
525
+ await storage.put("user-configuration", {
526
+ schemaVersion: 1,
527
+ revision: 38,
528
+ profile: { name: "Damaged User" },
529
+ packages: [
530
+ {
531
+ packageId: "web",
532
+ version: "0.0.1",
533
+ state: "disabled",
534
+ provenance: "first-party",
535
+ },
536
+ ],
537
+ connections: [],
538
+ });
539
+ const settings = createUserSettingsBackendContribution({
540
+ storage,
541
+ availablePackages: [
542
+ { packageId: "web", version: "0.0.1", installByDefault: true },
543
+ ],
544
+ });
545
+
546
+ const repaired = await settings.readConfiguration({
547
+ schemaVersion: 1,
548
+ userId: "damaged-user",
549
+ });
550
+ expect(repaired).toMatchObject({
551
+ revision: 39,
552
+ packages: [{ packageId: "web", state: "installed" }],
553
+ });
554
+ expect(
555
+ await storage.get<{ schemaVersion: number }>(
556
+ "user-default-packages-bootstrap:v1",
557
+ ),
558
+ ).toEqual({
559
+ schemaVersion: 3,
560
+ });
561
+
562
+ await settings.executeConfiguration({
563
+ schemaVersion: 1,
564
+ userId: "damaged-user",
565
+ command: {
566
+ schemaVersion: 1,
567
+ type: "user/set-package-enabled",
568
+ commandId: "disable-web-after-v3",
569
+ expectedRevision: 39,
570
+ packageId: "web",
571
+ enabled: false,
572
+ },
573
+ });
574
+ const chosen = await settings.readConfiguration({
575
+ schemaVersion: 1,
576
+ userId: "damaged-user",
577
+ });
578
+ expect(chosen).toMatchObject({
579
+ revision: 40,
580
+ packages: [{ packageId: "web", state: "disabled" }],
581
+ });
582
+ });
583
+
410
584
  test("installs a Package disabled when explicitly requested", async () => {
411
585
  const settings = contribution();
412
586
  const receipt = await settings.executeConfiguration({
package/src/user.ts CHANGED
@@ -34,7 +34,7 @@ import type { Plugin } from "cordis";
34
34
 
35
35
  const STATE_KEY = "user-configuration";
36
36
  const DEFAULT_PACKAGES_BOOTSTRAP_KEY = "user-default-packages-bootstrap:v1";
37
- const DEFAULT_PACKAGES_BOOTSTRAP_VERSION = 2;
37
+ const DEFAULT_PACKAGES_BOOTSTRAP_VERSION = 3;
38
38
  /**
39
39
  * The pinned Catalog generation lives beside the settings view rather than in
40
40
  * it, so pinning on a read never bumps the settings revision a client is
@@ -132,6 +132,8 @@ export interface AvailableUserPackage {
132
132
  installByDefault?: boolean;
133
133
  /** The seeded installation state. Omission preserves the enabled default. */
134
134
  defaultEnablement?: "enabled" | "disabled";
135
+ /** Derived from immutable manifest facts by the application compiler. */
136
+ platformOwned?: boolean;
135
137
  /** The Package manifest's Package-id-to-version-range dependency record. */
136
138
  dependencies?: Readonly<Record<string, string>>;
137
139
  /**
@@ -402,6 +404,9 @@ export class UserSettingsBackendContribution {
402
404
  /** Catalog-relative facts supplied to the raw stored-settings migration. */
403
405
  private readonly storedSettingsPackages: readonly StoredUserSettingsPackageV1[];
404
406
 
407
+ /** Package ids whose installation state is platform policy, not a User choice. */
408
+ private readonly platformOwnedPackageIds: ReadonlySet<string>;
409
+
405
410
  /** Declared Package dependencies, by Package id and version. */
406
411
  private readonly packageDependencies: ReadonlyMap<
407
412
  string,
@@ -418,6 +423,16 @@ export class UserSettingsBackendContribution {
418
423
  */
419
424
  private readonly enablementRolloutPackages: readonly PackageInstallationView[];
420
425
 
426
+ /**
427
+ * Default-enabled rows damaged by v0.2.3's v1→v2 migration. The old pass
428
+ * validated dependencies before it added the rollout's dependency closure,
429
+ * so it wrote these rows disabled even though the User had made no choice.
430
+ */
431
+ private readonly defaultEnabledPackages: ReadonlyMap<
432
+ string,
433
+ PackageInstallationView
434
+ >;
435
+
421
436
  /** Declared User-level settings, by Package id and version. */
422
437
  private readonly packageSettingDefinitions: ReadonlyMap<
423
438
  string,
@@ -436,7 +451,13 @@ export class UserSettingsBackendContribution {
436
451
  packageId: pkg.packageId,
437
452
  version: pkg.version,
438
453
  ...(pkg.dependencies ? { dependencies: pkg.dependencies } : {}),
454
+ ...(pkg.platformOwned ? { platformOwned: true } : {}),
439
455
  }));
456
+ this.platformOwnedPackageIds = new Set(
457
+ host.availablePackages
458
+ .filter((pkg) => pkg.platformOwned)
459
+ .map((pkg) => pkg.packageId),
460
+ );
440
461
  this.availablePackages = new Set(
441
462
  host.availablePackages.map(
442
463
  ({ packageId, version }) => `${packageId}\u0000${version}`,
@@ -455,7 +476,7 @@ export class UserSettingsBackendContribution {
455
476
  ]),
456
477
  );
457
478
  this.defaultPackages = host.availablePackages.flatMap((pkg) =>
458
- pkg.installByDefault
479
+ pkg.installByDefault || pkg.platformOwned
459
480
  ? [
460
481
  {
461
482
  packageId: pkg.packageId,
@@ -489,6 +510,80 @@ export class UserSettingsBackendContribution {
489
510
  this.enablementRolloutPackages = this.defaultPackages.filter((pkg) =>
490
511
  rolloutPackageIds.has(pkg.packageId),
491
512
  );
513
+ this.defaultEnabledPackages = new Map(
514
+ this.defaultPackages
515
+ .filter((pkg) => pkg.state === "installed")
516
+ .map((pkg) => [pkg.packageId, pkg]),
517
+ );
518
+ }
519
+
520
+ private addMissingPackages(
521
+ current: UserSettingsViewV1,
522
+ defaults: readonly PackageInstallationView[],
523
+ ): UserSettingsViewV1 {
524
+ const installedPackageIds = new Set(
525
+ current.packages.map((pkg) => pkg.packageId),
526
+ );
527
+ const additions = defaults.filter(
528
+ (pkg) => !installedPackageIds.has(pkg.packageId),
529
+ );
530
+ return additions.length === 0
531
+ ? current
532
+ : {
533
+ ...current,
534
+ packages: [
535
+ ...current.packages,
536
+ ...additions.map((pkg) => structuredClone(pkg)),
537
+ ],
538
+ };
539
+ }
540
+
541
+ /** Apply the default-disabled choices first introduced by marker v2. */
542
+ private applyEnablementRolloutDefaults(
543
+ current: UserSettingsViewV1,
544
+ ): UserSettingsViewV1 {
545
+ let changed = false;
546
+ const rolloutDefaults = new Map(
547
+ this.enablementRolloutPackages.map((pkg) => [pkg.packageId, pkg]),
548
+ );
549
+ const packages = current.packages.map((pkg) => {
550
+ const defaultPackage = rolloutDefaults.get(pkg.packageId);
551
+ if (
552
+ !defaultPackage ||
553
+ defaultPackage.version !== pkg.version ||
554
+ defaultPackage.state !== "disabled" ||
555
+ pkg.state !== "installed" ||
556
+ pkg.failure !== undefined ||
557
+ pkg.provenance === "catalog"
558
+ ) {
559
+ return pkg;
560
+ }
561
+ changed = true;
562
+ return { ...pkg, state: "disabled" as const };
563
+ });
564
+ return changed ? { ...current, packages } : current;
565
+ }
566
+
567
+ /** One-shot repair for rows the v0.2.3 marker-v2 rollout disabled. */
568
+ private repairEnablementRolloutV2(
569
+ current: UserSettingsViewV1,
570
+ ): UserSettingsViewV1 {
571
+ let changed = false;
572
+ const packages = current.packages.map((pkg) => {
573
+ const defaultPackage = this.defaultEnabledPackages.get(pkg.packageId);
574
+ if (
575
+ !defaultPackage ||
576
+ defaultPackage.version !== pkg.version ||
577
+ pkg.state !== "disabled" ||
578
+ pkg.failure !== undefined ||
579
+ pkg.provenance === "catalog"
580
+ ) {
581
+ return pkg;
582
+ }
583
+ changed = true;
584
+ return { ...pkg, state: "installed" as const };
585
+ });
586
+ return changed ? { ...current, packages } : current;
492
587
  }
493
588
 
494
589
  /**
@@ -511,70 +606,82 @@ export class UserSettingsBackendContribution {
511
606
  const marker = await transaction.get<unknown>(
512
607
  DEFAULT_PACKAGES_BOOTSTRAP_KEY,
513
608
  );
609
+ const stored = await transaction.get<unknown>(STATE_KEY);
514
610
  if (marker !== undefined) {
515
611
  if (
516
612
  !marker ||
517
613
  typeof marker !== "object" ||
518
614
  Array.isArray(marker) ||
519
615
  Object.keys(marker).length !== 1 ||
520
- ((marker as { schemaVersion?: unknown }).schemaVersion !== 1 &&
521
- (marker as { schemaVersion?: unknown }).schemaVersion !==
522
- DEFAULT_PACKAGES_BOOTSTRAP_VERSION)
616
+ ![1, 2, DEFAULT_PACKAGES_BOOTSTRAP_VERSION].includes(
617
+ (marker as { schemaVersion?: unknown }).schemaVersion as number,
618
+ )
523
619
  ) {
524
620
  throw new Error("Stored default Package bootstrap is invalid");
525
621
  }
526
- if (
527
- (marker as { schemaVersion: number }).schemaVersion ===
528
- DEFAULT_PACKAGES_BOOTSTRAP_VERSION
529
- ) {
530
- return this.readSnapshot(transaction);
531
- }
532
- const stored = await transaction.get<unknown>(STATE_KEY);
533
- const current =
622
+ const markerVersion = (marker as { schemaVersion: number })
623
+ .schemaVersion;
624
+
625
+ // Marker v1 predates default-disabled model Packages and their
626
+ // dependency closure. Seed that closure before the catalog-relative
627
+ // fixed point sees it, then validate the complete graph. A later
628
+ // marker gets only the platform-owned repair on this read: retirement
629
+ // and dependency validation are one-shot migration steps, never a
630
+ // read-time rule.
631
+ const rawMigrated =
534
632
  stored === undefined
535
633
  ? initialState()
536
- : decodeUserSettingsViewV1(
537
- migrateStoredUserSettingsV1(
538
- stored,
539
- this.storedSettingsPackages,
540
- ),
634
+ : migrateStoredUserSettingsV1(
635
+ stored,
636
+ markerVersion === 1 ? undefined : this.storedSettingsPackages,
637
+ "repair",
541
638
  );
542
- const installedPackageIds = new Set(
543
- current.packages.map((pkg) => pkg.packageId),
544
- );
545
- const additions = this.enablementRolloutPackages.filter(
546
- (pkg) => !installedPackageIds.has(pkg.packageId),
547
- );
548
- const next = {
549
- ...current,
550
- revision: current.revision + 1,
551
- packages: [
552
- ...current.packages,
553
- ...additions.map((pkg) => structuredClone(pkg)),
554
- ],
555
- } satisfies UserSettingsViewV1;
639
+ let settingsChanged = stored !== undefined && rawMigrated !== stored;
640
+ let migrated = decodeUserSettingsViewV1(rawMigrated);
641
+ if (markerVersion === 1) {
642
+ const seeded = this.applyEnablementRolloutDefaults(
643
+ this.addMissingPackages(migrated, this.enablementRolloutPackages),
644
+ );
645
+ const validated = migrateStoredUserSettingsV1(
646
+ seeded,
647
+ this.storedSettingsPackages,
648
+ );
649
+ settingsChanged ||= seeded !== migrated || validated !== seeded;
650
+ migrated = decodeUserSettingsViewV1(validated);
651
+ }
652
+ if (markerVersion === 2) {
653
+ const repaired = this.repairEnablementRolloutV2(migrated);
654
+ settingsChanged ||= repaired !== migrated;
655
+ migrated = repaired;
656
+ }
657
+
658
+ if (
659
+ markerVersion === DEFAULT_PACKAGES_BOOTSTRAP_VERSION &&
660
+ !settingsChanged
661
+ ) {
662
+ return structuredClone(migrated);
663
+ }
664
+ const next = settingsChanged
665
+ ? { ...migrated, revision: migrated.revision + 1 }
666
+ : migrated;
556
667
  await transaction.put({
557
- [STATE_KEY]: next,
668
+ ...(settingsChanged ? { [STATE_KEY]: next } : {}),
558
669
  [DEFAULT_PACKAGES_BOOTSTRAP_KEY]: {
559
670
  schemaVersion: DEFAULT_PACKAGES_BOOTSTRAP_VERSION,
560
671
  },
561
672
  });
562
673
  return structuredClone(next);
563
674
  }
564
- const current = await this.readSnapshot(transaction);
565
- const installedPackageIds = new Set(
566
- current.packages.map((pkg) => pkg.packageId),
567
- );
568
- const additions = this.defaultPackages.filter(
569
- (pkg) => !installedPackageIds.has(pkg.packageId),
570
- );
675
+ const current =
676
+ stored === undefined
677
+ ? initialState()
678
+ : decodeUserSettingsViewV1(
679
+ migrateStoredUserSettingsV1(stored, this.storedSettingsPackages),
680
+ );
681
+ const seeded = this.addMissingPackages(current, this.defaultPackages);
571
682
  const next = {
572
- ...current,
573
- revision: current.revision + 1,
574
- packages: [
575
- ...current.packages,
576
- ...additions.map((pkg) => structuredClone(pkg)),
577
- ],
683
+ ...seeded,
684
+ revision: seeded.revision + 1,
578
685
  } satisfies UserSettingsViewV1;
579
686
  await transaction.put({
580
687
  [STATE_KEY]: next,
@@ -625,6 +732,10 @@ export class UserSettingsBackendContribution {
625
732
 
626
733
  async readConfiguration(input: unknown): Promise<UserSettingsViewV1> {
627
734
  const request = decodeUserConfigurationReadRpcV1(input);
735
+ // Settings owns the stored-record migration and platform-row repair. Run
736
+ // it before Package bootstraps so every bootstrap observes the repaired
737
+ // current Catalog in the same configuration read.
738
+ await this.read(request.userId);
628
739
  for (const bootstrap of this.readBootstraps.values()) {
629
740
  await bootstrap.bootstrap(request.userId);
630
741
  }
@@ -844,6 +955,21 @@ export class UserSettingsBackendContribution {
844
955
  if (command.expectedRevision !== current.revision) {
845
956
  throw new ConfigurationConflictError(current.revision);
846
957
  }
958
+ if (
959
+ (command.type === "user/uninstall-package" ||
960
+ (command.type === "user/set-package-enabled" && !command.enabled)) &&
961
+ this.platformOwnedPackageIds.has(command.packageId)
962
+ ) {
963
+ const receipt: OperationReceiptV1 = {
964
+ schemaVersion: 1,
965
+ commandId: command.commandId,
966
+ revision: current.revision,
967
+ status: "rejected",
968
+ failure: `Platform-owned Package "${command.packageId}" cannot be disabled or uninstalled`,
969
+ };
970
+ await storage.put(receiptKey, { commandFingerprint, receipt });
971
+ return receipt;
972
+ }
847
973
  let dependencyFailure: string | undefined;
848
974
  if (command.type === "user/install-package" && command.enabled !== false) {
849
975
  dependencyFailure = this.packageDependencyFailure(