@tutti-os/app-release-tools 0.0.16 → 0.0.18

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/README.md CHANGED
@@ -12,7 +12,11 @@ verify-tutti-app-release-artifacts --release-file ./apps/vibe-design/latest.json
12
12
  verify-tutti-app-release-artifacts --catalog-file ./catalog.json --release-file ./apps/vibe-design/latest.json
13
13
  ```
14
14
 
15
- The release command validates a complete Tutti app package, creates a zip, writes immutable `release.json`, and writes mutable `latest.json`.
15
+ The release command validates a complete Tutti app package, creates a zip,
16
+ writes immutable `release.json`, and writes mutable `latest.json`. When the
17
+ manifest declares `localizationInfo`, the release metadata includes the
18
+ referenced manifest locale files so App Center can localize uninstalled remote
19
+ apps without downloading the package.
16
20
 
17
21
  The catalog command merges one or more release files into `tutti.app.catalog.v1`.
18
22
  Pass `--existing-catalog` to preserve existing catalog apps and update only the
@@ -51,6 +51,7 @@ export async function buildTuttiAppRelease(options) {
51
51
  for (const locale of manifest.localizationInfo?.additionalLocales ?? []) {
52
52
  await requireNonEmptyTextFile(path.join(packageDir, locale.file));
53
53
  }
54
+ const localizations = await readManifestLocalizations(packageDir, manifest);
54
55
  if (manifest.cli?.manifest) {
55
56
  const cliManifestPath = path.join(packageDir, manifest.cli.manifest);
56
57
  await requireFile(cliManifestPath);
@@ -85,6 +86,7 @@ export async function buildTuttiAppRelease(options) {
85
86
  name: manifest.name,
86
87
  description: manifest.description,
87
88
  manifest,
89
+ ...(localizations.length > 0 ? { localizations } : {}),
88
90
  artifactUrl: `${baseUrl}/${releaseURLPrefix}/${encodeURLPathSegment(artifactName)}`,
89
91
  artifactSha256: artifact.sha256,
90
92
  artifactSizeBytes: artifact.size,
@@ -410,6 +412,9 @@ function validateLocalizationInfo(localizationInfo, sourceLabel) {
410
412
  export function releaseToCatalogApp(release) {
411
413
  validateRelease(release);
412
414
  return {
415
+ ...(Array.isArray(release.localizations) && release.localizations.length > 0
416
+ ? { localizations: release.localizations }
417
+ : {}),
413
418
  manifest: release.manifest,
414
419
  distribution: {
415
420
  kind: "remote",
@@ -443,6 +448,7 @@ export function validateRelease(release) {
443
448
  if (release.manifest.version !== release.version) {
444
449
  throw new Error("release manifest.version must match release version");
445
450
  }
451
+ validateReleaseLocalizations(release.localizations, "release.localizations");
446
452
  if (!/^[a-f0-9]{64}$/i.test(release.artifactSha256)) {
447
453
  throw new Error("release artifactSha256 must be a sha256 hex digest");
448
454
  }
@@ -454,6 +460,110 @@ export function validateRelease(release) {
454
460
  }
455
461
  }
456
462
 
463
+ async function readManifestLocalizations(packageDir, manifest) {
464
+ const localizations = [];
465
+ for (const entry of manifest.localizationInfo?.additionalLocales ?? []) {
466
+ const locale = requireNonEmpty(
467
+ entry.locale,
468
+ "manifest localization locale"
469
+ );
470
+ const filePath = path.join(packageDir, entry.file);
471
+ const document = await readLocalizationDocument(filePath);
472
+ const localization = normalizeLocalization(document, locale, filePath);
473
+ if (localization) {
474
+ localizations.push(localization);
475
+ }
476
+ }
477
+ return localizations;
478
+ }
479
+
480
+ function normalizeLocalization(document, locale, sourceLabel) {
481
+ if (!document || typeof document !== "object" || Array.isArray(document)) {
482
+ throw new Error(`${sourceLabel} must be a localization object`);
483
+ }
484
+ const localization = {
485
+ locale
486
+ };
487
+ if (document.name !== undefined) {
488
+ const name = requireOptionalString(document.name, `${sourceLabel}.name`);
489
+ if (name) {
490
+ localization.name = name;
491
+ }
492
+ }
493
+ if (document.description !== undefined) {
494
+ const description = requireOptionalString(
495
+ document.description,
496
+ `${sourceLabel}.description`
497
+ );
498
+ if (description) {
499
+ localization.description = description;
500
+ }
501
+ }
502
+ if (document.tags !== undefined) {
503
+ if (!Array.isArray(document.tags)) {
504
+ throw new Error(`${sourceLabel}.tags must be an array`);
505
+ }
506
+ const tags = document.tags
507
+ .map((tag, index) =>
508
+ requireOptionalString(tag, `${sourceLabel}.tags[${index}]`)
509
+ )
510
+ .filter(Boolean);
511
+ if (tags.length > 0) {
512
+ localization.tags = [...new Set(tags)];
513
+ }
514
+ }
515
+ if (!localization.name && !localization.description && !localization.tags) {
516
+ return null;
517
+ }
518
+ return localization;
519
+ }
520
+
521
+ async function readLocalizationDocument(filePath) {
522
+ try {
523
+ return JSON.parse(await readFile(filePath, "utf8"));
524
+ } catch (error) {
525
+ throw new Error(
526
+ `parse manifest localization file ${filePath}: ${error.message}`
527
+ );
528
+ }
529
+ }
530
+
531
+ function validateReleaseLocalizations(localizations, sourceLabel) {
532
+ if (localizations === undefined) {
533
+ return;
534
+ }
535
+ if (!Array.isArray(localizations)) {
536
+ throw new Error(`${sourceLabel} must be an array`);
537
+ }
538
+ const seenLocales = new Set();
539
+ for (const [index, localization] of localizations.entries()) {
540
+ const label = `${sourceLabel}[${index}]`;
541
+ if (!localization || typeof localization !== "object") {
542
+ throw new Error(`${label} must be an object`);
543
+ }
544
+ const locale = requireNonEmpty(localization.locale, `${label}.locale`);
545
+ const localeKey = locale.toLowerCase();
546
+ if (seenLocales.has(localeKey)) {
547
+ throw new Error(`${label}.locale must be unique`);
548
+ }
549
+ seenLocales.add(localeKey);
550
+ if (localization.name !== undefined) {
551
+ requireOptionalString(localization.name, `${label}.name`);
552
+ }
553
+ if (localization.description !== undefined) {
554
+ requireOptionalString(localization.description, `${label}.description`);
555
+ }
556
+ if (localization.tags !== undefined) {
557
+ if (!Array.isArray(localization.tags)) {
558
+ throw new Error(`${label}.tags must be an array`);
559
+ }
560
+ for (const [tagIndex, tag] of localization.tags.entries()) {
561
+ requireOptionalString(tag, `${label}.tags[${tagIndex}]`);
562
+ }
563
+ }
564
+ }
565
+ }
566
+
457
567
  function createZip(packageDir, artifactPath) {
458
568
  const result = spawnSync("zip", ["-qr", artifactPath, "."], {
459
569
  cwd: packageDir,
@@ -521,6 +631,13 @@ function requireNonEmpty(value, label) {
521
631
  return text;
522
632
  }
523
633
 
634
+ function requireOptionalString(value, label) {
635
+ if (typeof value !== "string") {
636
+ throw new Error(`${label} must be a string`);
637
+ }
638
+ return value.trim();
639
+ }
640
+
524
641
  function requireSafePathSegment(value, label) {
525
642
  if (!/^[A-Za-z0-9._+-]+$/.test(value)) {
526
643
  throw new Error(
@@ -147,6 +147,14 @@ function assertCatalogMatchesRelease(app, release) {
147
147
  `catalog app ${appId} iconUrl must match latest release metadata`
148
148
  );
149
149
  }
150
+ if (
151
+ JSON.stringify(app.localizations ?? []) !==
152
+ JSON.stringify(release.localizations ?? [])
153
+ ) {
154
+ throw new Error(
155
+ `catalog app ${appId} localizations must match latest release metadata`
156
+ );
157
+ }
150
158
  }
151
159
 
152
160
  function validateCatalog(catalog) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tutti-os/app-release-tools",
3
- "version": "0.0.16",
3
+ "version": "0.0.18",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {