@moewolf/moe-icons-cli 0.0.11 → 0.0.15

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.
Files changed (53) hide show
  1. package/README.md +27 -19
  2. package/dist/cli.js +124 -146
  3. package/dist/config-package/generated/SOURCE.json +3 -3
  4. package/dist/config-package/generated/moeicons-config.schema.json +1 -1
  5. package/dist/core/bootstrap.js +5 -3
  6. package/dist/core/candidate-version-pointer.d.ts +13 -0
  7. package/dist/core/candidate-version-pointer.js +37 -0
  8. package/dist/core/config-package-plan.d.ts +25 -0
  9. package/dist/core/config-package-plan.js +156 -0
  10. package/dist/core/doctor.js +4 -0
  11. package/dist/core/free-download.js +34 -4
  12. package/dist/core/free-small-package-download.d.ts +67 -0
  13. package/dist/core/free-small-package-download.js +344 -0
  14. package/dist/core/generate.js +49 -33
  15. package/dist/core/install.d.ts +8 -2
  16. package/dist/core/install.js +70 -0
  17. package/dist/core/library-update.d.ts +6 -1
  18. package/dist/core/library-update.js +54 -0
  19. package/dist/core/manage.d.ts +4 -0
  20. package/dist/core/manage.js +4 -0
  21. package/dist/core/metadata-sync.js +119 -1
  22. package/dist/core/pro-download.js +19 -2
  23. package/dist/core/pro-install.d.ts +10 -4
  24. package/dist/core/pro-install.js +47 -0
  25. package/dist/core/pro-resources.d.ts +23 -0
  26. package/dist/core/pro-resources.js +51 -0
  27. package/dist/core/pro-selected-download.js +12 -2
  28. package/dist/core/pro-small-package-download.d.ts +61 -0
  29. package/dist/core/pro-small-package-download.js +301 -0
  30. package/dist/core/release-descriptor.js +6 -2
  31. package/dist/core/small-pack/PIN.json +11 -0
  32. package/dist/core/small-pack/candidate-set-v1.schema.json +187 -0
  33. package/dist/core/small-pack/candidate-set.cjs +286 -0
  34. package/dist/core/small-pack/candidate-set.d.cts +12 -0
  35. package/dist/core/small-pack/tar-read.cjs +220 -0
  36. package/dist/core/small-pack/tar-read.d.cts +2 -0
  37. package/dist/core/small-package-install.d.ts +40 -0
  38. package/dist/core/small-package-install.js +252 -0
  39. package/dist/core/small-package-selection.d.ts +27 -0
  40. package/dist/core/small-package-selection.js +114 -0
  41. package/dist/core/update-policy.d.ts +1 -0
  42. package/dist/core/update-policy.js +10 -3
  43. package/dist/mcp/services.js +1 -1
  44. package/dist/project/config.js +1 -1
  45. package/dist/project/install-metadata.d.ts +2 -1
  46. package/dist/project/install-metadata.js +6 -3
  47. package/dist/project/install.d.ts +11 -2
  48. package/dist/project/install.js +104 -12
  49. package/dist/project/package-install-metadata.d.ts +31 -0
  50. package/dist/project/package-install-metadata.js +99 -0
  51. package/dist/project/tar-gz.d.ts +4 -8
  52. package/dist/project/tar-gz.js +6 -10
  53. package/package.json +11 -11
@@ -13,6 +13,7 @@ import { artifactCachePath } from "./free-download.js";
13
13
  import { resolveThemes } from "../generator/theme-resolve.js";
14
14
  import { loadPinnedBitmapShardAssets } from "./bitmap-shard-resolver.js";
15
15
  import { parseInstallMetadata, serializeInstallMetadata, sha256Bytes, } from "../project/install-metadata.js";
16
+ import { parsePackageInstallMetadata, serializePackageInstallMetadata } from "../project/package-install-metadata.js";
16
17
  import { withProjectLockSync } from "../project/project-lock.js";
17
18
  import { allowLocalTestFromEnv } from "./local-test-env.js";
18
19
  import { resolveIconTheme, themeHasIcon } from "./icon-selection.js";
@@ -68,7 +69,7 @@ export function loadInstalledCatalogState(projectRoot, fs_, opts = {}) {
68
69
  const rawMetadata = fs_.readFileSync(metadataPath, "utf8");
69
70
  if (typeof rawMetadata !== "string")
70
71
  throw new Error("metadata is not text");
71
- metadata = parseInstallMetadata(rawMetadata, opts);
72
+ metadata = parseInstallMetadata(rawMetadata, opts) ?? parsePackageInstallMetadata(rawMetadata);
72
73
  }
73
74
  catch (error) {
74
75
  return {
@@ -124,24 +125,19 @@ function missingArchiveMessage(config) {
124
125
  }
125
126
  return "bitmap themes require a downloaded free artifact; run `moeicons install` or set MOEICONS_BITMAP_ARCHIVE";
126
127
  }
127
- function readInstallMetadata(projectRoot, readFileSync, existsSync) {
128
+ function readInstallMetadata(projectRoot, readFileSync, existsSync, opts = {}) {
128
129
  const path = join(projectRoot, ".moeicons", "install-metadata.json");
129
130
  if (!existsSync(path))
130
131
  return undefined;
131
132
  try {
132
- const parsed = JSON.parse(readFileSync(path, "utf8"));
133
- return {
134
- ...(typeof parsed.artifactVersion === "string"
135
- ? { artifactVersion: parsed.artifactVersion }
136
- : {}),
137
- ...(typeof parsed.artifactSha256 === "string"
138
- ? { artifactSha256: parsed.artifactSha256 }
139
- : {}),
140
- ...(typeof parsed.target === "string" &&
141
- ["react", "vue", "vanilla", "assets"].includes(parsed.target)
142
- ? { target: parsed.target }
143
- : {}),
144
- };
133
+ const raw = readFileSync(path, "utf8");
134
+ const parsed = parseInstallMetadata(raw, opts);
135
+ if (parsed)
136
+ return { artifactVersion: parsed.artifactVersion, artifactSha256: parsed.artifactSha256, target: parsed.target };
137
+ const packageMetadata = parsePackageInstallMetadata(raw);
138
+ if (packageMetadata)
139
+ return { artifactVersion: packageMetadata.candidate.fullVersion, target: packageMetadata.target };
140
+ return undefined;
145
141
  }
146
142
  catch {
147
143
  return undefined;
@@ -189,10 +185,17 @@ function mergePinnedBitmapShardAssets(archiveFiles, effectiveConfig, sourceCatal
189
185
  errors: ["bitmap shards require an installed pro artifact; run `moeicons install` first"],
190
186
  };
191
187
  }
192
- const metadata = parseInstallMetadata(fs_.readFileSync(metadataPath, "utf8"), {
188
+ const rawMetadata = fs_.readFileSync(metadataPath, "utf8");
189
+ const metadata = parseInstallMetadata(rawMetadata, {
193
190
  allowLocalTest: allowLocalTestFromEnv(env),
194
191
  });
195
192
  if (!metadata) {
193
+ if (parsePackageInstallMetadata(rawMetadata)) {
194
+ return {
195
+ ok: false,
196
+ errors: ["selected bitmap packages are missing for this configuration; run `moeicons install` to download the configured icons, format and imageSize"],
197
+ };
198
+ }
196
199
  return {
197
200
  ok: false,
198
201
  errors: ["install metadata is invalid; run 'moeicons install' to repair or reinstall"],
@@ -259,10 +262,10 @@ export async function loadArchiveFiles(projectRoot, env, fs_, injected, signal)
259
262
  }
260
263
  const selectionMetadataPath = join(projectRoot, ".moeicons", "install-metadata.json");
261
264
  if (fs_.existsSync(selectionMetadataPath)) {
262
- const pinned = parseInstallMetadata(fs_.readFileSync(selectionMetadataPath, "utf8"), {
263
- allowLocalTest: allowLocalTestFromEnv(env),
264
- });
265
- if (pinned?.delivery?.mode === "icons") {
265
+ const raw = fs_.readFileSync(selectionMetadataPath, "utf8");
266
+ const parseOptions = { allowLocalTest: allowLocalTestFromEnv(env) };
267
+ const pinned = parseInstallMetadata(raw, parseOptions) ?? parsePackageInstallMetadata(raw);
268
+ if (pinned?.schemaVersion === 1 && pinned.delivery?.mode === "icons") {
266
269
  const files = {};
267
270
  for (const [name, hash] of Object.entries(pinned.managedFiles)) {
268
271
  if (!name.startsWith(".moeicons/artifact/"))
@@ -280,8 +283,23 @@ export async function loadArchiveFiles(projectRoot, env, fs_, injected, signal)
280
283
  }
281
284
  return { ok: true, files };
282
285
  }
286
+ if (pinned?.schemaVersion === 2) {
287
+ const files = {};
288
+ for (const [name, hash] of Object.entries(pinned.managedFiles)) {
289
+ if (!name.startsWith(".moeicons/artifact/"))
290
+ continue;
291
+ const target = safeManagedPath(projectRoot, name).target;
292
+ if (!fs_.existsSync(target))
293
+ return { ok: false, reason: `selected resource missing: ${name}; run moeicons install` };
294
+ const bytes = readBinaryFile(fs_, target);
295
+ if (sha256Bytes(bytes) !== hash)
296
+ return { ok: false, reason: `selected resource modified: ${name}; preserve your edit before reinstalling` };
297
+ files[name.slice(".moeicons/artifact/".length)] = bytes;
298
+ }
299
+ return { ok: true, files };
300
+ }
283
301
  }
284
- const meta = readInstallMetadata(projectRoot, fs_.readFileSync, fs_.existsSync);
302
+ const meta = readInstallMetadata(projectRoot, fs_.readFileSync, fs_.existsSync, { allowLocalTest: allowLocalTestFromEnv(env) });
285
303
  if (meta?.target) {
286
304
  const subtreeRoot = join(projectRoot, ".moeicons", "artifact", meta.target);
287
305
  if (fs_.existsSync(subtreeRoot)) {
@@ -356,7 +374,7 @@ export async function runGenerateUseCase(context, fs_, options = {}) {
356
374
  const plannedMetadataText = fs_.existsSync(plannedMetadataPath)
357
375
  ? fs_.readFileSync(plannedMetadataPath, "utf8")
358
376
  : undefined;
359
- const installedMeta = readInstallMetadata(project.root, fs_.readFileSync, fs_.existsSync);
377
+ const installedMeta = readInstallMetadata(project.root, fs_.readFileSync, fs_.existsSync, { allowLocalTest });
360
378
  if (!installedMeta) {
361
379
  return {
362
380
  ok: false,
@@ -450,12 +468,12 @@ export async function runGenerateUseCase(context, fs_, options = {}) {
450
468
  ok: false,
451
469
  reason: "managed install metadata is missing; run 'moeicons install' to repair or reinstall",
452
470
  };
453
- const metadata = parseInstallMetadata(fs_.readFileSync(metadataPath, "utf8"), {
454
- allowLocalTest,
455
- });
456
- if (fs_.readFileSync(metadataPath, "utf8") !== plannedMetadataText)
471
+ const currentMetadataText = fs_.readFileSync(metadataPath, "utf8");
472
+ const metadata = parseInstallMetadata(currentMetadataText, { allowLocalTest }) ?? parsePackageInstallMetadata(currentMetadataText);
473
+ if (currentMetadataText !== plannedMetadataText)
457
474
  return { ok: false, reason: "installation changed while preparing generate; retry" };
458
- if (!metadata || metadata.tier !== loaded.config.tier)
475
+ const metadataTier = metadata?.schemaVersion === 1 ? metadata.tier : metadata?.candidate.tier;
476
+ if (!metadata || metadataTier !== loaded.config.tier)
459
477
  return {
460
478
  ok: false,
461
479
  reason: "managed install metadata is invalid or does not match config tier; run 'moeicons install' to repair or reinstall",
@@ -521,11 +539,9 @@ export async function runGenerateUseCase(context, fs_, options = {}) {
521
539
  nextManaged[managedPath] = hash;
522
540
  for (const [path, content] of Object.entries(generated))
523
541
  nextManaged[path] = sha256Bytes(content);
524
- const nextMetadata = {
525
- ...metadata,
526
- managedFiles: nextManaged,
527
- generatedOutputDir: loaded.config.outputDir,
528
- };
542
+ const nextMetadataText = metadata.schemaVersion === 1
543
+ ? serializeInstallMetadata({ ...metadata, managedFiles: nextManaged, generatedOutputDir: loaded.config.outputDir })
544
+ : serializePackageInstallMetadata({ ...metadata, managedFiles: nextManaged, generatedOutputDir: loaded.config.outputDir });
529
545
  const writes = { ...generated };
530
546
  for (const file of sideFiles) {
531
547
  const absolute = resolve(file.path);
@@ -534,7 +550,7 @@ export async function runGenerateUseCase(context, fs_, options = {}) {
534
550
  return { ok: false, reason: `side file escapes project: ${file.path}` };
535
551
  writes[rel] = file.content;
536
552
  }
537
- writes[".moeicons/install-metadata.json"] = serializeInstallMetadata(nextMetadata);
553
+ writes[".moeicons/install-metadata.json"] = nextMetadataText;
538
554
  const stale = Object.keys(metadata.managedFiles).filter((path) => (path.startsWith(outputPrefix) || path.startsWith(previousPrefix)) &&
539
555
  !(path in generated));
540
556
  const expectedSha256 = {
@@ -13,14 +13,20 @@ export type InstallResult = {
13
13
  readonly planItems: number;
14
14
  readonly config: string;
15
15
  readonly artifactVersion: string;
16
- readonly descriptorSha256: string;
16
+ readonly descriptorSha256?: string;
17
+ readonly candidateRootSha256?: string;
18
+ readonly packageSetSha256?: string;
19
+ readonly packageCount?: number;
17
20
  readonly catalogSha256: string;
18
21
  readonly metadataSha256: string;
19
22
  readonly cacheHit: boolean;
20
- readonly downloadMode?: "icons" | "full";
23
+ readonly downloadMode?: "icons" | "full" | "packages";
21
24
  readonly downloadNotes?: readonly string[];
22
25
  readonly networkBytes?: number;
23
26
  readonly selectedFiles?: number;
27
+ readonly cacheHits?: number;
28
+ readonly metadataCacheHits?: number;
29
+ readonly transferRetryCount?: number;
24
30
  } | {
25
31
  readonly ok: false;
26
32
  readonly reason: "no-project";
@@ -9,6 +9,7 @@ import { withProjectLock } from "../project/project-lock.js";
9
9
  import { configuredComponentFiles, selectTargetSubtreeAsync, selectTargetSubtreeFromFiles, computeSubtreeHash, } from "./target-subtree.js";
10
10
  import { parseCatalog } from "../catalog/catalog.js";
11
11
  import { posix } from "node:path";
12
+ import { runSmallPackageInstall } from "./small-package-install.js";
12
13
  /**
13
14
  * P0-3: the project `types.ts` must re-export a type that actually exists in the
14
15
  * installed `moe-icons` package. Assets have no component type, so it is empty.
@@ -90,6 +91,75 @@ export async function runInstallUseCase(context, deps, options) {
90
91
  message: `config target is "${config.config.target}" but install target is "${target}"; set target to "${target}" in moeicons.config before installing`,
91
92
  };
92
93
  }
94
+ // Production Free installs use the immutable package-set pointer and exact
95
+ // configured dependency closure. Aggregate archives remain available only to
96
+ // the isolated local archive-fixture harness while old fixtures are retired.
97
+ if (!deps.download.fixtureDir) {
98
+ if (config.kind !== "ok") {
99
+ return { ok: false, reason: "validation", message: "run 'moeicons init' and select icons/themes before installing the package set" };
100
+ }
101
+ if (config.config.downloadMode === "full") {
102
+ return { ok: false, reason: "validation", message: "full archive installs are disabled; set downloadMode to icons and select the required resources" };
103
+ }
104
+ if (options.expectedDescriptorSha256) {
105
+ return { ok: false, reason: "validation", message: "legacy descriptor pins cannot identify a package-set candidate; retry through the package-set update command" };
106
+ }
107
+ try {
108
+ const result = await runSmallPackageInstall({
109
+ projectRoot: project.root,
110
+ ...(options.sourceVersion ? { fullVersion: options.sourceVersion } : {}),
111
+ ...(context.env.MOEICONS_API_BASE_URL ? { apiBaseUrl: context.env.MOEICONS_API_BASE_URL } : {}),
112
+ config: config.config,
113
+ plannedConfigDocument,
114
+ fs: deps.fs,
115
+ cacheDir: deps.download.cacheDir,
116
+ now: context.now,
117
+ ...(context.signal ? { signal: context.signal } : {}),
118
+ ...(deps.download.fetchFn ? { fetchFn: deps.download.fetchFn } : {}),
119
+ ...(deps.download.fixtureBaseUrl ? { fixtureOrigin: deps.download.fixtureBaseUrl } : {}),
120
+ });
121
+ return {
122
+ ok: true,
123
+ projectRoot: project.root,
124
+ packageManager: project.packageManager,
125
+ group: "free",
126
+ target,
127
+ artifactBytes: result.artifactBytes,
128
+ planItems: result.planItems,
129
+ config: config.kind,
130
+ artifactVersion: result.artifactVersion,
131
+ candidateRootSha256: result.candidateRootSha256,
132
+ packageSetSha256: result.packageSetSha256,
133
+ packageCount: result.packageCount,
134
+ catalogSha256: result.catalogSha256,
135
+ metadataSha256: result.metadataSha256,
136
+ cacheHit: result.cacheHit,
137
+ downloadMode: "packages",
138
+ downloadNotes: result.fallbacks.map((fallback) => `Theme fallback: ${fallback}`),
139
+ networkBytes: result.networkBytes,
140
+ selectedFiles: result.selectedFiles,
141
+ cacheHits: result.cacheHits,
142
+ metadataCacheHits: result.metadataCacheHits,
143
+ transferRetryCount: result.transferRetryCount,
144
+ };
145
+ }
146
+ catch (error) {
147
+ const message = error instanceof Error ? error.message : String(error);
148
+ if (context.signal?.aborted || /CANCELLED|cancelled/i.test(message))
149
+ return { ok: false, reason: "cancelled", message };
150
+ if (/ENOSPC|no space left/i.test(message))
151
+ return { ok: false, reason: "disk-full", message };
152
+ if (/HTTP_ERROR[^\n]*\b404\b/i.test(message))
153
+ return { ok: false, reason: "not-found", message };
154
+ if (/HTTP_ERROR[^\n]*\b5\d\d\b|NETWORK_ERROR|network|fetch failed|timed out/i.test(message))
155
+ return { ok: false, reason: "network", message };
156
+ if (/checksum|SHA-256|digest mismatch|differs from immutable version pointer/i.test(message))
157
+ return { ok: false, reason: "checksum-mismatch", message };
158
+ if (/config changed|unsafe|invalid|requires downloadMode|candidate-set:|config package plan:|small package install currently/i.test(message))
159
+ return { ok: false, reason: "validation", message };
160
+ return { ok: false, reason: "write-failed", message };
161
+ }
162
+ }
93
163
  const downloaded = await downloadFreeRelease({ ...deps.download, signal: context.signal }, options.sourceVersion ?? bundledSourceVersion(), config.kind === "ok"
94
164
  ? {
95
165
  config: config.config,
@@ -17,12 +17,17 @@ export declare function runLibraryUpdateUseCase(context: CommandContext, deps: L
17
17
  readonly tier: "free" | "pro";
18
18
  readonly version: string;
19
19
  readonly descriptorSha256: string;
20
+ readonly packageSetSha256?: string;
20
21
  }): Promise<{
21
22
  readonly projectRoot: string;
22
23
  readonly artifactVersion: string;
23
24
  readonly files: readonly string[];
24
- readonly downloadMode: "icons" | "full";
25
+ readonly downloadMode: "icons" | "full" | "packages";
25
26
  readonly downloadNotes: readonly string[];
26
27
  readonly networkBytes?: number;
27
28
  readonly selectedFiles?: number;
29
+ readonly packageCount?: number;
30
+ readonly cacheHits?: number;
31
+ readonly metadataCacheHits?: number;
32
+ readonly transferRetryCount?: number;
28
33
  }>;
@@ -8,9 +8,11 @@ import { readMoeiconsConfig, loadConfigDocument, validateConfigDocument, } from
8
8
  import { detectProject } from "../project/detect.js";
9
9
  import { executeManagedReconcile } from "../project/install.js";
10
10
  import { parseInstallMetadata, serializeInstallMetadata, sha256Bytes, } from "../project/install-metadata.js";
11
+ import { parsePackageInstallMetadata } from "../project/package-install-metadata.js";
11
12
  import { withProjectLock } from "../project/project-lock.js";
12
13
  import { ensureClassMergeDependencies, planTailwindIntegration } from "../project/tailwind.js";
13
14
  import { extractTarGzAsync, ICON_ARCHIVE_MAX_ENTRIES, ICON_ARCHIVE_MAX_EXPANDED_BYTES, } from "../project/tar-gz.js";
15
+ import { runSmallPackageInstall } from "./small-package-install.js";
14
16
  import { artifactCachePath, downloadFreeRelease, metadataCachePath, } from "./free-download.js";
15
17
  import { downloadProArtifact } from "./pro-download.js";
16
18
  import { PRO_DOWNLOAD_HOSTS, resolveProDescriptorEndpoint } from "./pro-download.js";
@@ -22,6 +24,18 @@ export async function runLibraryUpdateUseCase(context, deps, expected) {
22
24
  const project = detectProject(context.cwd);
23
25
  if (!project)
24
26
  throw new CliError("VALIDATION_ERROR", "no package.json found in the current directory or parents");
27
+ const installedMetadataPath = join(project.root, ".moeicons", "install-metadata.json");
28
+ let packageSetInstalled = false;
29
+ if (deps.fs.existsSync(installedMetadataPath)) {
30
+ let schemaVersion;
31
+ try {
32
+ const parsed = JSON.parse(deps.fs.readFileSync(installedMetadataPath, "utf8"));
33
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
34
+ schemaVersion = parsed.schemaVersion;
35
+ }
36
+ catch { /* validated by the normal install path below */ }
37
+ packageSetInstalled = schemaVersion === 2;
38
+ }
25
39
  const document = loadConfigDocument(project.root);
26
40
  const bootstrap = validateConfigDocument(document, undefined, { lenientCatalog: true });
27
41
  if (bootstrap.kind !== "ok" || bootstrap.config.tier !== expected.tier)
@@ -29,6 +43,46 @@ export async function runLibraryUpdateUseCase(context, deps, expected) {
29
43
  ? bootstrap.message
30
44
  : `config.tier must match ${expected.tier}; edit config before updating`);
31
45
  assertDownloadSelection(bootstrap.config);
46
+ if (packageSetInstalled) {
47
+ const installed = parsePackageInstallMetadata(deps.fs.readFileSync(installedMetadataPath, "utf8"));
48
+ if (!installed || installed.candidate.tier !== expected.tier) {
49
+ const tierLabel = expected.tier === "free" ? "Free" : "Pro";
50
+ throw new CliError("VALIDATION_ERROR", `package-set update requires a valid installed ${tierLabel} candidate`);
51
+ }
52
+ if ((expected.tier === "free" && !deps.free) || !expected.packageSetSha256 || !/^[a-f0-9]{64}$/.test(expected.packageSetSha256))
53
+ throw new CliError("VALIDATION_ERROR", "package-set update requires an exact Worker package-set identity");
54
+ if (installed.candidate.fullVersion === expected.version && installed.candidate.packageSetSha256 !== expected.packageSetSha256)
55
+ throw new CliError("VALIDATION_ERROR", "published package-set identity changed for the installed version; refusing replacement");
56
+ const proAccessToken = expected.tier === "pro" ? await runAccessTokenUseCase(context, deps.auth) : undefined;
57
+ const result = await runSmallPackageInstall({
58
+ projectRoot: project.root,
59
+ fullVersion: expected.version,
60
+ expectedPackageSetSha256: expected.packageSetSha256,
61
+ ...(context.env.MOEICONS_API_BASE_URL ? { apiBaseUrl: context.env.MOEICONS_API_BASE_URL } : {}),
62
+ config: bootstrap.config,
63
+ plannedConfigDocument: JSON.stringify(document),
64
+ fs: deps.fs,
65
+ cacheDir: deps.free?.cacheDir ?? join(homedir(), ".moeicons", "cache"),
66
+ now: context.now,
67
+ ...(proAccessToken ? { proAccessToken } : {}),
68
+ ...(expected.tier === "free" && deps.free?.fixtureBaseUrl ? { fixtureOrigin: deps.free.fixtureBaseUrl } : {}),
69
+ ...(context.signal ? { signal: context.signal } : {}),
70
+ ...(deps.fetch ? { fetchFn: deps.fetch } : {}),
71
+ });
72
+ return {
73
+ projectRoot: project.root,
74
+ artifactVersion: result.artifactVersion,
75
+ files: result.files,
76
+ downloadMode: "packages",
77
+ downloadNotes: result.fallbacks.map((fallback) => `Theme fallback: ${fallback}`),
78
+ networkBytes: result.networkBytes,
79
+ selectedFiles: result.selectedFiles,
80
+ packageCount: result.packageCount,
81
+ cacheHits: result.cacheHits,
82
+ metadataCacheHits: result.metadataCacheHits,
83
+ transferRetryCount: result.transferRetryCount,
84
+ };
85
+ }
32
86
  let selected;
33
87
  let catalogJson;
34
88
  let fullArchiveFiles;
@@ -1,4 +1,5 @@
1
1
  import { readInstalledResourceState, type InstallMetadata } from "../project/install-metadata.js";
2
+ import type { PackageInstallMetadata } from "../project/package-install-metadata.js";
2
3
  import { type PublicLibraryVersions } from "./version-service.js";
3
4
  export type LibraryVersionStatus = {
4
5
  readonly kind: "installation-invalid";
@@ -11,6 +12,9 @@ export type LibraryVersionStatus = {
11
12
  readonly kind: "unavailable";
12
13
  readonly metadata: InstallMetadata;
13
14
  readonly message: string;
15
+ } | {
16
+ readonly kind: "package-set-update-check-required";
17
+ readonly metadata: PackageInstallMetadata;
14
18
  } | {
15
19
  readonly kind: "current" | "update" | "channel-mismatch" | "remote-older";
16
20
  readonly metadata: InstallMetadata;
@@ -5,6 +5,8 @@ export async function getLibraryVersionStatus(projectRoot, configTier, deps = {}
5
5
  const installed = (deps.readState ?? readInstalledResourceState)(projectRoot, configTier);
6
6
  if (installed.kind !== "ok")
7
7
  return { kind: "installation-invalid", message: installed.message };
8
+ if (installed.metadata.schemaVersion === 2)
9
+ return { kind: "package-set-update-check-required", metadata: installed.metadata };
8
10
  let versions;
9
11
  try {
10
12
  versions = await (deps.fetchVersions ?? (() => fetchLibraryVersions()))();
@@ -31,6 +33,8 @@ export async function getLibraryVersionStatus(projectRoot, configTier, deps = {}
31
33
  export function formatLibraryVersionStatus(status) {
32
34
  if (status.kind === "installation-invalid")
33
35
  return `Current: invalid / Latest: unavailable / Status: installation invalid (${status.message})`;
36
+ if (status.kind === "package-set-update-check-required")
37
+ return `Current: ${status.metadata.candidate.fullVersion} / Latest: unavailable / Status: package-set update check required`;
34
38
  if (status.kind === "check-failed" || status.kind === "unavailable")
35
39
  return `Current: ${status.metadata.artifactVersion} / Latest: unavailable / Status: ${status.message}`;
36
40
  const label = status.kind === "current" ? "up to date" : status.kind === "update" ? "update available" : status.kind === "channel-mismatch" ? "prerelease channel differs" : "remote version is older";
@@ -2,13 +2,20 @@ import { join } from "node:path";
2
2
  import { homedir } from "node:os";
3
3
  import { CliError } from "../errors/index.js";
4
4
  import { detectProject } from "../project/detect.js";
5
+ import { loadConfigDocument, validateConfigDocument } from "../project/config.js";
5
6
  import { parseInstallMetadata, serializeInstallMetadata, sha256Bytes, } from "../project/install-metadata.js";
7
+ import { parsePackageInstallMetadata, serializePackageInstallMetadata } from "../project/package-install-metadata.js";
6
8
  import { executeManagedReconcile } from "../project/install.js";
7
9
  import { withProjectLock } from "../project/project-lock.js";
8
10
  import { cacheArtifact } from "./cache.js";
9
11
  import { downloadMetadataArchive, fetchFreeDescriptor, metadataCachePath, } from "./free-download.js";
10
12
  import { downloadSignedArtifact } from "./signed-artifact.js";
11
13
  import { extractProMetadata, fetchProDescriptor, proSignedDownloadOptions } from "./pro-download.js";
14
+ import { downloadFreeVersionPackages } from "./free-small-package-download.js";
15
+ import { downloadProVersionPackages } from "./pro-small-package-download.js";
16
+ import { runAccessTokenUseCase } from "./auth.js";
17
+ import { packageTargetFiles, planConfigPackagesFromMetadata } from "./config-package-plan.js";
18
+ import { canonicalBytes, sha256 } from "./small-pack/candidate-set.cjs";
12
19
  function toCliError(result) {
13
20
  return new CliError(result.reason === "cancelled" ? "CANCELLED" : "VALIDATION_ERROR", result.message);
14
21
  }
@@ -25,7 +32,18 @@ export async function runMetadataSyncUseCase(context, deps, options = {}) {
25
32
  const metadataPath = join(project.root, ".moeicons", "install-metadata.json");
26
33
  if (!deps.fs.existsSync(metadataPath))
27
34
  throw new CliError("VALIDATION_ERROR", "managed install metadata is missing; run install first");
28
- const old = parseInstallMetadata(deps.fs.readFileSync(metadataPath, "utf8"));
35
+ const rawMetadata = deps.fs.readFileSync(metadataPath, "utf8");
36
+ let schemaVersion;
37
+ try {
38
+ const parsed = JSON.parse(rawMetadata);
39
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
40
+ schemaVersion = parsed.schemaVersion;
41
+ }
42
+ catch { /* reported as invalid metadata below */ }
43
+ if (schemaVersion === 2) {
44
+ return runPackageSetMetadataSync(context, deps, project.root, rawMetadata, options);
45
+ }
46
+ const old = parseInstallMetadata(rawMetadata);
29
47
  if (!old)
30
48
  throw new CliError("VALIDATION_ERROR", "managed install metadata is invalid; run install first");
31
49
  const tier = options.tier ?? old.tier;
@@ -95,3 +113,103 @@ export async function runMetadataSyncUseCase(context, deps, options = {}) {
95
113
  files: [".moeicons/MANUAL.md", ".moeicons/catalog.json", ".moeicons/manifest.json"],
96
114
  };
97
115
  }
116
+ async function runPackageSetMetadataSync(context, deps, projectRoot, rawMetadata, options) {
117
+ const installed = parsePackageInstallMetadata(rawMetadata);
118
+ if (!installed)
119
+ throw new CliError("VALIDATION_ERROR", "package-set install metadata is invalid; run install first");
120
+ const tier = installed.candidate.tier;
121
+ if (options.tier && options.tier !== tier)
122
+ throw new CliError("VALIDATION_ERROR", "metadata refresh tier differs from the installed package-set");
123
+ const document = loadConfigDocument(projectRoot);
124
+ const bootstrap = validateConfigDocument(document, undefined, { lenientCatalog: true });
125
+ if (bootstrap.kind !== "ok")
126
+ throw new CliError("VALIDATION_ERROR", bootstrap.kind === "invalid" ? bootstrap.message : "project config is missing");
127
+ if (bootstrap.config.tier !== tier || bootstrap.config.target !== installed.target)
128
+ throw new CliError("VALIDATION_ERROR", "metadata refresh config differs from the installed package-set; run update");
129
+ const plannedConfigDocument = JSON.stringify(document);
130
+ const expectedPins = installed.packages
131
+ .map(({ resourceKey, packageSha256, dependencies }) => ({ resourceKey, packageSha256, dependencies: [...dependencies] }))
132
+ .sort((a, b) => a.resourceKey.localeCompare(b.resourceKey, "en"));
133
+ let packagePlan;
134
+ const writes = Object.create(null);
135
+ const resolveResourceKeys = async (candidate, loadVerifiedPackage) => {
136
+ const identity = candidate.root;
137
+ if (identity.fullVersion !== installed.candidate.fullVersion ||
138
+ identity.tier !== tier ||
139
+ identity.packageSetSha256 !== installed.candidate.packageSetSha256 ||
140
+ identity.sourceCommit !== installed.candidate.sourceCommit ||
141
+ identity.generatorCommit !== installed.candidate.generatorCommit ||
142
+ sha256(canonicalBytes(identity)) !== installed.candidate.candidateRootSha256)
143
+ throw new CliError("VALIDATION_ERROR", "published package-set identity differs from the installed pin; refusing metadata refresh");
144
+ packagePlan = await planConfigPackagesFromMetadata(bootstrap.config, candidate, loadVerifiedPackage);
145
+ const actualPins = packagePlan.packages
146
+ .map(({ resourceKey, packageSha256, dependencies }) => ({ resourceKey, packageSha256, dependencies: [...dependencies] }))
147
+ .sort((a, b) => a.resourceKey.localeCompare(b.resourceKey, "en"));
148
+ if (JSON.stringify(actualPins) !== JSON.stringify(expectedPins))
149
+ throw new CliError("VALIDATION_ERROR", "configured package selection differs from the installed candidate; run update before metadata refresh");
150
+ return packagePlan.resourceKeys.filter((key) => key.startsWith(`metadata/${tier}/`));
151
+ };
152
+ const consume = (row, files) => {
153
+ if (row.kind !== "metadata")
154
+ throw new CliError("VALIDATION_ERROR", "metadata refresh selected a non-metadata package");
155
+ const mapped = packageTargetFiles(row, files, bootstrap.config.target);
156
+ for (const [path, bytes] of Object.entries(mapped.files)) {
157
+ const installPath = `.moeicons/package-metadata/${path}`;
158
+ if (Object.hasOwn(writes, installPath))
159
+ throw new CliError("VALIDATION_ERROR", "metadata package paths overlap");
160
+ writes[installPath] = bytes;
161
+ }
162
+ return Promise.resolve();
163
+ };
164
+ const cacheDir = deps.free.cacheDir ?? join(homedir(), ".moeicons", "cache");
165
+ const downloadOptions = {
166
+ cacheDir,
167
+ fullVersion: installed.candidate.fullVersion,
168
+ expectedPackageSetSha256: installed.candidate.packageSetSha256,
169
+ resolveResourceKeys,
170
+ consume,
171
+ ...(context.env.MOEICONS_API_BASE_URL ? { apiBaseUrl: context.env.MOEICONS_API_BASE_URL } : {}),
172
+ ...(deps.fetch ? { fetchFn: deps.fetch } : {}),
173
+ ...(context.signal ? { signal: context.signal } : {}),
174
+ };
175
+ const report = tier === "free"
176
+ ? await downloadFreeVersionPackages({
177
+ ...downloadOptions,
178
+ ...(deps.free.fixtureBaseUrl ? { fixtureOrigin: deps.free.fixtureBaseUrl } : {}),
179
+ })
180
+ : await downloadProVersionPackages({
181
+ ...downloadOptions,
182
+ accessToken: await runAccessTokenUseCase(context, deps.auth),
183
+ });
184
+ if (!packagePlan)
185
+ throw new CliError("VALIDATION_ERROR", "metadata refresh did not produce a verified package plan");
186
+ const catalogJson = `${JSON.stringify(packagePlan.catalog, null, 2)}\n`;
187
+ const catalogSha256 = sha256Bytes(catalogJson);
188
+ writes[".moeicons/catalog.json"] = catalogJson;
189
+ const managedFiles = {
190
+ ...installed.managedFiles,
191
+ ...Object.fromEntries(Object.entries(writes).map(([path, content]) => [path, sha256Bytes(content)])),
192
+ };
193
+ const nextMetadata = {
194
+ ...installed,
195
+ catalogSha256,
196
+ installedAt: context.now().toISOString(),
197
+ managedFiles,
198
+ };
199
+ writes[".moeicons/install-metadata.json"] = serializePackageInstallMetadata(nextMetadata);
200
+ await withProjectLock(projectRoot, "update", () => {
201
+ if (deps.fs.readFileSync(join(projectRoot, ".moeicons", "install-metadata.json"), "utf8") !== rawMetadata)
202
+ throw new CliError("VALIDATION_ERROR", "installed package-set changed during metadata refresh; retry");
203
+ if (JSON.stringify(loadConfigDocument(projectRoot)) !== plannedConfigDocument)
204
+ throw new CliError("VALIDATION_ERROR", "config changed during metadata refresh; retry");
205
+ executeManagedReconcile(projectRoot, writes, [], deps.fs);
206
+ });
207
+ return {
208
+ projectRoot,
209
+ tier,
210
+ artifactVersion: installed.candidate.fullVersion,
211
+ metadataSha256: catalogSha256,
212
+ files: Object.keys(writes).sort((a, b) => a.localeCompare(b, "en")),
213
+ networkBytes: report.networkBytes + report.metadataNetworkBytes,
214
+ };
215
+ }
@@ -264,10 +264,24 @@ export function proSignedDownloadOptions(context, deps = {}) {
264
264
  export async function downloadProArtifact(context, auth, expected, deps = {}) {
265
265
  const fetchFn = deps.fetch ?? fetch;
266
266
  const descriptor = await fetchProDescriptor(context, auth, expected, { fetch: fetchFn });
267
+ const localFixture = descriptor.channel === "local-test" || resolveProDescriptorEndpoint(context.env).allowLoopback;
268
+ // CI FLOW RULE (S05): tier-wide resource bundles are historical schema only.
269
+ // Do not fall through to `descriptor.url` when selected packages are absent;
270
+ // that URL points at the full Pro code archive. New installs must resolve the
271
+ // small package directory and fetch only the configured group/tuple set.
272
+ if (!localFixture &&
273
+ descriptor.resources?.bundle) {
274
+ throw new CliError("VALIDATION_ERROR", "this release uses the retired full-library package format; install requires the small-package release protocol");
275
+ }
276
+ if (deps.selection?.config.downloadMode === "full" && !localFixture) {
277
+ throw new CliError("VALIDATION_ERROR", 'downloadMode="full" is disabled for published releases; resolve bounded style-group/target packages instead');
278
+ }
279
+ const localRangeFixture = descriptor.channel === "local-test" &&
280
+ resolveProDescriptorEndpoint(context.env).allowLoopback;
267
281
  if (deps.selection &&
268
282
  deps.selection.config.downloadMode !== "full" &&
269
283
  descriptor.resources &&
270
- descriptor.channel !== "local-test") {
284
+ localRangeFixture) {
271
285
  const selected = await downloadProSelected(context, auth, descriptor, deps.selection, deps);
272
286
  if (selected)
273
287
  return {
@@ -278,7 +292,10 @@ export async function downloadProArtifact(context, auth, expected, deps = {}) {
278
292
  };
279
293
  }
280
294
  if (deps.selection?.config.downloadMode === "icons")
281
- throw new CliError("VALIDATION_ERROR", "this release/backend does not support selected resources; set downloadMode=full or use a newer release");
295
+ throw new CliError("VALIDATION_ERROR", "this release/backend does not support bounded selected packages; refusing a full-library fallback");
296
+ if (!localFixture) {
297
+ throw new CliError("VALIDATION_ERROR", "published Pro release has no supported bounded package directory; refusing the full-library fallback");
298
+ }
282
299
  context.ui.note(deps.selection?.config.downloadMode === "full"
283
300
  ? "Download mode: full (explicitly configured)."
284
301
  : "Release does not advertise selected downloads; auto uses the verified full archive. Set downloadMode=icons to require selected downloads.", context.signal);
@@ -13,20 +13,26 @@ export declare function runProInstallUseCase(context: CommandContext, deps: {
13
13
  readonly totalBytes?: number;
14
14
  }) => void;
15
15
  }, expected: {
16
- readonly version: string;
17
- readonly descriptorSha256: string;
16
+ readonly version?: string;
17
+ readonly descriptorSha256?: string;
18
18
  readonly target?: Target;
19
19
  /** A-1b: allow a declared local-test candidate (never true for production). */
20
20
  readonly allowLocalTest?: boolean;
21
21
  }): Promise<{
22
22
  readonly projectRoot: string;
23
23
  readonly artifactVersion: string;
24
- readonly descriptorSha256: string;
24
+ readonly descriptorSha256?: string;
25
+ readonly candidateRootSha256?: string;
26
+ readonly packageSetSha256?: string;
25
27
  readonly catalogSha256: string;
26
28
  readonly artifactBytes: number;
27
- readonly downloadMode: "icons" | "full";
29
+ readonly downloadMode: "icons" | "full" | "packages";
28
30
  readonly downloadNotes: readonly string[];
29
31
  readonly networkBytes?: number;
30
32
  readonly selectedFiles?: number;
33
+ readonly packageCount?: number;
34
+ readonly cacheHits?: number;
35
+ readonly metadataCacheHits?: number;
36
+ readonly transferRetryCount?: number;
31
37
  }>;
32
38
  export {};