@tutti-os/app-release-tools 0.0.78 → 0.0.80

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
@@ -6,25 +6,43 @@ Command-line tools for publishing Tutti workspace apps into App Center release m
6
6
 
7
7
  ```sh
8
8
  build-tutti-app-release --app-id vibe-design --package-dir dist/tutti-app/vibe-design --base-url https://cdn.example.test/tutti-app-releases
9
- build-tutti-app-catalog --release-file ./apps/vibe-design/latest.json --output ./catalog.json
10
- build-tutti-app-catalog --existing-catalog ./catalog.json --release-file ./apps/vibe-design/latest.json --output ./catalog.json
9
+ build-tutti-app-versions --release-file ./apps/vibe-design/0.2.0/release.json --min-tutti-version 0.12.0 --output ./apps/vibe-design/versions.json
10
+ build-tutti-app-catalog --versions-file ./apps/vibe-design/versions.json --output ./catalog.json
11
+ build-tutti-app-catalog --existing-catalog ./catalog.json --versions-file ./apps/vibe-design/versions.json --output ./catalog.json
11
12
  verify-tutti-app-release-artifacts --release-file ./apps/vibe-design/latest.json
12
- verify-tutti-app-release-artifacts --catalog-file ./catalog.json --release-file ./apps/vibe-design/latest.json
13
+ verify-tutti-app-release-artifacts --catalog-file ./catalog.json --versions-file ./apps/vibe-design/versions.json
13
14
  ```
14
15
 
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
+ The release command validates a complete Tutti app package, creates a
17
+ reproducible zip with stable entry ordering and timestamps, writes immutable
18
+ `release.json`, and writes mutable `latest.json`. Rebuilding the same package
19
+ version produces the same artifact SHA-256, so an interrupted release can
20
+ safely verify and reuse its immutable upload. When the manifest declares
21
+ `localizationInfo`, the release metadata includes the referenced manifest
22
+ locale files so App Center can localize uninstalled remote apps without
23
+ downloading the package.
20
24
 
21
- The catalog command merges one or more release files into `tutti.app.catalog.v1`.
22
- Pass `--existing-catalog` to preserve existing catalog apps and update only the
23
- apps represented by the release files. With `--existing-catalog`, release files
24
- are optional, which allows rewriting an existing catalog without changing its app
25
- set.
25
+ The versions command maintains one mutable `tutti.app.versions.v1` index per
26
+ app. Every catalog-eligible release has an explicit minimum Tutti version and
27
+ an `active` or `withdrawn` status. Reusing an immutable app version with changed
28
+ release or compatibility metadata is rejected.
29
+
30
+ The catalog command merges version indexes into `tutti.app.catalog.v1`.
31
+ `apps[]` contains the highest active app version whose minimum Tutti version is
32
+ `0.0.0`, so old Tutti clients remain safe. `compatibility.apps` contains only
33
+ the compact compatibility frontier needed by new Tutti clients, not every
34
+ historical release. Catalog output is rejected when it exceeds the legacy 1 MiB
35
+ reader limit. The older `--release-file` form remains available for legacy
36
+ tooling but does not create compatibility metadata.
26
37
 
27
38
  The verify command checks release and catalog metadata against the referenced
28
- artifact downloads. When both `--catalog-file` and `--release-file` are passed,
29
- catalog entries for those apps must exactly match the latest release metadata
30
- before artifact SHA-256 and size checks run.
39
+ artifact downloads. When `--catalog-file` is combined with `--release-file` or
40
+ `--versions-file`, catalog entries for those apps must exactly match the
41
+ corresponding release metadata before artifact SHA-256 and size checks run.
42
+
43
+ `publish-tutti-app-metadata` and `publish-tutti-app-catalog` update mutable S3
44
+ JSON objects with ETag conditional writes and retry on concurrent changes. They
45
+ bootstrap a missing versions index from the exact immutable release currently
46
+ present in the legacy catalog; they never infer legacy compatibility from a
47
+ newer `latest.json` object. Catalog publication validates all metadata and
48
+ downloads referenced artifacts before attempting the conditional S3 write.
@@ -7,85 +7,108 @@ import {
7
7
  releaseToCatalogApp,
8
8
  validateRelease
9
9
  } from "./build-tutti-app-release.mjs";
10
+ import { validateVersionsDocument } from "./build-tutti-app-versions.mjs";
11
+ import {
12
+ compareReleaseVersions,
13
+ compareSemver
14
+ } from "./tutti-app-versioning.mjs";
10
15
 
11
- const catalogSchemaVersion = "tutti.app.catalog.v1";
16
+ export const catalogSchemaVersion = "tutti.app.catalog.v1";
17
+ export const maxCatalogBytes = 1024 * 1024;
12
18
 
13
19
  export async function buildTuttiAppCatalog(options) {
14
20
  const existingCatalogPath = options.existingCatalogPath
15
21
  ? path.resolve(String(options.existingCatalogPath))
16
22
  : null;
17
- const releaseFiles = normalizeReleaseFiles(options.releaseFiles);
23
+ const releaseFiles = normalizeFiles(options.releaseFiles);
24
+ const versionsFiles = normalizeFiles(options.versionsFiles);
18
25
  const outputPath = path.resolve(
19
26
  options.outputPath
20
27
  ? String(options.outputPath)
21
28
  : "dist/tutti-app-catalog/catalog.json"
22
29
  );
23
30
 
24
- const seenAppIDs = new Set();
25
- const seenReleaseAppIDs = new Set();
26
31
  const appsByID = new Map();
27
-
32
+ const compatibilityByAppID = new Map();
28
33
  if (existingCatalogPath) {
29
34
  const existingCatalog = JSON.parse(
30
35
  await readFile(existingCatalogPath, "utf8")
31
36
  );
32
37
  validateCatalog(existingCatalog);
33
38
  for (const app of existingCatalog.apps) {
34
- const appID = app.manifest.appId;
35
- if (seenAppIDs.has(appID)) {
36
- throw new Error(`duplicate catalog appId ${appID}`);
37
- }
38
- seenAppIDs.add(appID);
39
- appsByID.set(appID, app);
39
+ appsByID.set(app.manifest.appId, app);
40
+ }
41
+ for (const [appId, entries] of Object.entries(
42
+ existingCatalog.compatibility?.apps ?? {}
43
+ )) {
44
+ compatibilityByAppID.set(appId, entries);
40
45
  }
41
46
  }
42
47
 
48
+ const seenVersionsAppIDs = new Set();
49
+ for (const versionsFile of versionsFiles) {
50
+ const versions = JSON.parse(await readFile(versionsFile, "utf8"));
51
+ validateVersionsDocument(versions);
52
+ if (seenVersionsAppIDs.has(versions.appId)) {
53
+ throw new Error(`duplicate versions appId ${versions.appId}`);
54
+ }
55
+ seenVersionsAppIDs.add(versions.appId);
56
+ applyVersionsDocument(appsByID, compatibilityByAppID, versions);
57
+ }
58
+
59
+ const seenReleaseAppIDs = new Set();
43
60
  for (const releaseFile of releaseFiles) {
44
61
  const release = JSON.parse(await readFile(releaseFile, "utf8"));
45
62
  validateRelease(release);
46
63
  if (seenReleaseAppIDs.has(release.appId)) {
47
64
  throw new Error(`duplicate release appId ${release.appId}`);
48
65
  }
49
- seenReleaseAppIDs.add(release.appId);
50
- if (!seenAppIDs.has(release.appId)) {
51
- seenAppIDs.add(release.appId);
66
+ if (seenVersionsAppIDs.has(release.appId)) {
67
+ throw new Error(
68
+ `appId ${release.appId} cannot be supplied as both release and versions metadata`
69
+ );
52
70
  }
71
+ seenReleaseAppIDs.add(release.appId);
53
72
  appsByID.set(release.appId, releaseToCatalogApp(release));
54
73
  }
55
74
 
56
- if (appsByID.size === 0) {
75
+ if (appsByID.size === 0 && compatibilityByAppID.size === 0) {
57
76
  throw new Error(
58
- "at least one release file or existing catalog app is required"
77
+ "at least one versions file, release file, or existing catalog app is required"
59
78
  );
60
79
  }
61
80
 
62
- const apps = [...appsByID.values()];
63
- apps.sort((left, right) =>
81
+ const apps = [...appsByID.values()].sort((left, right) =>
64
82
  left.manifest.appId.localeCompare(right.manifest.appId)
65
83
  );
66
-
84
+ const compatibilityApps = Object.fromEntries(
85
+ [...compatibilityByAppID.entries()]
86
+ .filter(([, entries]) => entries.length > 0)
87
+ .sort(([left], [right]) => left.localeCompare(right))
88
+ );
67
89
  const catalog = {
68
90
  schemaVersion: catalogSchemaVersion,
69
- apps
91
+ apps,
92
+ ...(Object.keys(compatibilityApps).length > 0
93
+ ? { compatibility: { apps: compatibilityApps } }
94
+ : {})
70
95
  };
96
+ validateCatalog(catalog);
97
+
98
+ const encoded = `${JSON.stringify(catalog, null, 2)}\n`;
99
+ if (Buffer.byteLength(encoded) > maxCatalogBytes) {
100
+ throw new Error(
101
+ `catalog exceeds legacy ${maxCatalogBytes}-byte response limit`
102
+ );
103
+ }
71
104
 
72
105
  await mkdir(path.dirname(outputPath), { recursive: true });
73
- await writeFile(outputPath, `${JSON.stringify(catalog, null, 2)}\n`);
106
+ await writeFile(outputPath, encoded);
74
107
  return { outputPath, catalog };
75
108
  }
76
109
 
77
- function normalizeReleaseFiles(value) {
78
- const files = Array.isArray(value)
79
- ? value
80
- : String(value ?? "")
81
- .split(/[\n,]/)
82
- .map((file) => file.trim())
83
- .filter(Boolean);
84
- return files.map((file) => path.resolve(file));
85
- }
86
-
87
- function validateCatalog(catalog) {
88
- if (!catalog || typeof catalog !== "object") {
110
+ export function validateCatalog(catalog) {
111
+ if (!catalog || typeof catalog !== "object" || Array.isArray(catalog)) {
89
112
  throw new Error("catalog must be an object");
90
113
  }
91
114
  if (catalog.schemaVersion !== catalogSchemaVersion) {
@@ -94,51 +117,188 @@ function validateCatalog(catalog) {
94
117
  if (!Array.isArray(catalog.apps)) {
95
118
  throw new Error("catalog apps must be an array");
96
119
  }
120
+
121
+ const seenLegacyAppIDs = new Set();
97
122
  for (const [index, app] of catalog.apps.entries()) {
98
- if (!app || typeof app !== "object") {
99
- throw new Error(`catalog apps[${index}] must be an object`);
123
+ const appId = validateCatalogApp(app, `catalog apps[${index}]`);
124
+ if (seenLegacyAppIDs.has(appId)) {
125
+ throw new Error(`duplicate catalog appId ${appId}`);
100
126
  }
127
+ seenLegacyAppIDs.add(appId);
128
+ }
129
+
130
+ const compatibility = catalog.compatibility;
131
+ if (compatibility === undefined) {
132
+ return catalog;
133
+ }
134
+ if (
135
+ !compatibility ||
136
+ typeof compatibility !== "object" ||
137
+ Array.isArray(compatibility) ||
138
+ !compatibility.apps ||
139
+ typeof compatibility.apps !== "object" ||
140
+ Array.isArray(compatibility.apps)
141
+ ) {
142
+ throw new Error("catalog compatibility.apps must be an object");
143
+ }
144
+ for (const [appId, entries] of Object.entries(compatibility.apps)) {
145
+ if (!Array.isArray(entries) || entries.length === 0) {
146
+ throw new Error(`catalog compatibility app ${appId} must be non-empty`);
147
+ }
148
+ const seenVersions = new Set();
149
+ const seenMinimums = new Set();
150
+ for (const [index, entry] of entries.entries()) {
151
+ const label = `catalog compatibility app ${appId}[${index}]`;
152
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
153
+ throw new Error(`${label} must be an object`);
154
+ }
155
+ const minTuttiVersion = String(entry.minTuttiVersion ?? "").trim();
156
+ compareSemver(minTuttiVersion, minTuttiVersion);
157
+ if (seenMinimums.has(minTuttiVersion)) {
158
+ throw new Error(`${label} has duplicate minTuttiVersion`);
159
+ }
160
+ seenMinimums.add(minTuttiVersion);
161
+ const entryAppId = validateCatalogApp(entry.app, `${label}.app`);
162
+ if (entryAppId !== appId) {
163
+ throw new Error(`${label}.app manifest.appId must match map key`);
164
+ }
165
+ const version = entry.app.manifest.version;
166
+ if (seenVersions.has(version)) {
167
+ throw new Error(`${label} has duplicate app version ${version}`);
168
+ }
169
+ seenVersions.add(version);
170
+ }
171
+ }
172
+ return catalog;
173
+ }
174
+
175
+ export function validateCatalogApp(app, label) {
176
+ if (!app || typeof app !== "object" || Array.isArray(app)) {
177
+ throw new Error(`${label} must be an object`);
178
+ }
179
+ const appId = String(app.manifest?.appId ?? "").trim();
180
+ const version = String(app.manifest?.version ?? "").trim();
181
+ if (!appId) {
182
+ throw new Error(`${label}.manifest.appId is required`);
183
+ }
184
+ compareSemver(version, version);
185
+ const distribution = app.distribution;
186
+ if (!distribution || distribution.kind !== "remote") {
187
+ throw new Error(`${label}.distribution.kind must be remote`);
188
+ }
189
+ for (const key of ["artifactUrl", "artifactSha256", "iconUrl"]) {
101
190
  if (
102
- !app.manifest ||
103
- typeof app.manifest !== "object" ||
104
- typeof app.manifest.appId !== "string" ||
105
- app.manifest.appId.trim() === ""
191
+ typeof distribution[key] !== "string" ||
192
+ distribution[key].trim() === ""
106
193
  ) {
107
- throw new Error(`catalog apps[${index}].manifest.appId is required`);
194
+ throw new Error(`${label}.distribution.${key} is required`);
108
195
  }
109
196
  }
197
+ if (!/^[a-f0-9]{64}$/iu.test(distribution.artifactSha256)) {
198
+ throw new Error(`${label}.distribution.artifactSha256 must be sha256`);
199
+ }
200
+ return appId;
201
+ }
202
+
203
+ function applyVersionsDocument(appsByID, compatibilityByAppID, versions) {
204
+ const activeRecords = versions.versions.filter(
205
+ (record) => record.status === "active"
206
+ );
207
+ const legacyRecords = activeRecords.filter(
208
+ (record) => record.minTuttiVersion === "0.0.0"
209
+ );
210
+ const legacy = highestReleaseRecord(legacyRecords);
211
+ if (legacy) {
212
+ appsByID.set(versions.appId, releaseToCatalogApp(legacy.release));
213
+ } else {
214
+ appsByID.delete(versions.appId);
215
+ }
216
+
217
+ const entries = compatibilityFrontier(activeRecords).map((record) => ({
218
+ minTuttiVersion: record.minTuttiVersion,
219
+ app: releaseToCatalogApp(record.release)
220
+ }));
221
+ if (entries.length > 0) {
222
+ compatibilityByAppID.set(versions.appId, entries);
223
+ } else {
224
+ compatibilityByAppID.delete(versions.appId);
225
+ }
226
+ }
227
+
228
+ export function compatibilityFrontier(records) {
229
+ const highestByMinimum = new Map();
230
+ for (const record of records) {
231
+ const existing = highestByMinimum.get(record.minTuttiVersion);
232
+ if (
233
+ !existing ||
234
+ compareReleaseVersions(record.release, existing.release) > 0
235
+ ) {
236
+ highestByMinimum.set(record.minTuttiVersion, record);
237
+ }
238
+ }
239
+
240
+ const candidates = [...highestByMinimum.values()].sort((left, right) => {
241
+ const minimum = compareSemver(left.minTuttiVersion, right.minTuttiVersion);
242
+ if (minimum !== 0) {
243
+ return minimum;
244
+ }
245
+ return compareReleaseVersions(left.release, right.release);
246
+ });
247
+ const frontier = [];
248
+ let highest = null;
249
+ for (const candidate of candidates) {
250
+ if (
251
+ !highest ||
252
+ compareReleaseVersions(candidate.release, highest.release) > 0
253
+ ) {
254
+ frontier.push(candidate);
255
+ highest = candidate;
256
+ }
257
+ }
258
+ return frontier;
259
+ }
260
+
261
+ function highestReleaseRecord(records) {
262
+ return [...records]
263
+ .sort((left, right) => compareReleaseVersions(left.release, right.release))
264
+ .at(-1);
265
+ }
266
+
267
+ function normalizeFiles(value) {
268
+ const files = Array.isArray(value)
269
+ ? value
270
+ : String(value ?? "")
271
+ .split(/[\n,]/u)
272
+ .map((file) => file.trim())
273
+ .filter(Boolean);
274
+ return files.map((file) => path.resolve(file));
110
275
  }
111
276
 
112
277
  function parseArgs(argv) {
113
- const result = {
114
- releaseFiles: []
115
- };
278
+ const result = { releaseFiles: [], versionsFiles: [] };
116
279
  for (let index = 0; index < argv.length; index += 1) {
117
280
  const arg = argv[index];
118
- if (arg === "--release-file") {
119
- const value = argv[index + 1];
120
- if (!value || value.startsWith("--")) {
121
- throw new Error("missing value for --release-file");
122
- }
123
- result.releaseFiles.push(value);
124
- index += 1;
125
- continue;
126
- }
127
- if (arg === "--existing-catalog") {
128
- const value = argv[index + 1];
281
+ const value = argv[index + 1];
282
+ if (
283
+ [
284
+ "--release-file",
285
+ "--versions-file",
286
+ "--existing-catalog",
287
+ "--output"
288
+ ].includes(arg)
289
+ ) {
129
290
  if (!value || value.startsWith("--")) {
130
- throw new Error("missing value for --existing-catalog");
291
+ throw new Error(`missing value for ${arg}`);
131
292
  }
132
- result.existingCatalogPath = value;
133
- index += 1;
134
- continue;
135
- }
136
- if (arg === "--output") {
137
- const value = argv[index + 1];
138
- if (!value || value.startsWith("--")) {
139
- throw new Error("missing value for --output");
293
+ if (arg === "--release-file") {
294
+ result.releaseFiles.push(value);
295
+ } else if (arg === "--versions-file") {
296
+ result.versionsFiles.push(value);
297
+ } else if (arg === "--existing-catalog") {
298
+ result.existingCatalogPath = value;
299
+ } else {
300
+ result.outputPath = value;
140
301
  }
141
- result.outputPath = value;
142
302
  index += 1;
143
303
  continue;
144
304
  }
@@ -2,9 +2,22 @@
2
2
  import { spawnSync } from "node:child_process";
3
3
  import { createHash } from "node:crypto";
4
4
  import { pathToFileURL } from "node:url";
5
- import { cp, mkdir, readFile, stat, writeFile } from "node:fs/promises";
5
+ import {
6
+ cp,
7
+ mkdir,
8
+ mkdtemp,
9
+ readFile,
10
+ readdir,
11
+ rm,
12
+ stat,
13
+ utimes,
14
+ writeFile
15
+ } from "node:fs/promises";
16
+ import { tmpdir } from "node:os";
6
17
  import path from "node:path";
7
18
 
19
+ import { requireSemver } from "./tutti-app-versioning.mjs";
20
+
8
21
  const manifestSchemaVersion = "tutti.app.manifest.v1";
9
22
  const cliManifestSchemaVersion = "tutti.app.cli.v1";
10
23
  const releaseSchemaVersion = "tutti.app.release.v1";
@@ -37,6 +50,7 @@ export async function buildTuttiAppRelease(options) {
37
50
  "version"
38
51
  );
39
52
  requireSafePathSegment(version, "version");
53
+ requireSemver(version, "version");
40
54
  manifest.version = version;
41
55
  validateManifest(manifest, manifestPath);
42
56
  await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
@@ -72,7 +86,7 @@ export async function buildTuttiAppRelease(options) {
72
86
 
73
87
  const artifactName = `${appId}-${version}.zip`;
74
88
  const artifactPath = path.join(releaseDir, artifactName);
75
- createZip(packageDir, artifactPath);
89
+ await createZip(packageDir, artifactPath);
76
90
  const artifact = await fileDigestAndSize(artifactPath);
77
91
 
78
92
  const iconName = path.basename(sourceIconPath);
@@ -496,6 +510,7 @@ export function validateRelease(release) {
496
510
  ]) {
497
511
  requireManifestString(release, key, "release");
498
512
  }
513
+ requireSemver(release.version, "release version");
499
514
  validateManifest(release.manifest, "release.manifest");
500
515
  if (release.manifest.appId !== release.appId) {
501
516
  throw new Error("release manifest.appId must match release appId");
@@ -619,17 +634,71 @@ function validateReleaseLocalizations(localizations, sourceLabel) {
619
634
  }
620
635
  }
621
636
 
622
- function createZip(packageDir, artifactPath) {
623
- const result = spawnSync("zip", ["-qr", artifactPath, "."], {
624
- cwd: packageDir,
625
- encoding: "utf8"
626
- });
627
- if (result.error) {
628
- throw result.error;
637
+ async function createZip(packageDir, artifactPath) {
638
+ const stagingDir = await mkdtemp(
639
+ path.join(tmpdir(), "tutti-app-release-archive-")
640
+ );
641
+ const archiveRoot = path.join(stagingDir, "package");
642
+ try {
643
+ await cp(packageDir, archiveRoot, {
644
+ recursive: true,
645
+ dereference: true
646
+ });
647
+ const entries = await normalizeArchiveEntries(archiveRoot);
648
+ await rm(artifactPath, { force: true });
649
+ const result = spawnSync("zip", ["-X", "-q", artifactPath, "-@"], {
650
+ cwd: archiveRoot,
651
+ encoding: "utf8",
652
+ env: {
653
+ ...process.env,
654
+ LC_ALL: "C",
655
+ TZ: "UTC"
656
+ },
657
+ input: `${entries.join("\n")}\n`
658
+ });
659
+ if (result.error) {
660
+ throw result.error;
661
+ }
662
+ if (result.status !== 0) {
663
+ throw new Error(
664
+ result.stderr || `zip exited with status ${result.status}`
665
+ );
666
+ }
667
+ } finally {
668
+ await rm(stagingDir, { recursive: true, force: true });
629
669
  }
630
- if (result.status !== 0) {
631
- throw new Error(result.stderr || `zip exited with status ${result.status}`);
670
+ }
671
+
672
+ async function normalizeArchiveEntries(rootDir, relativeDir = "") {
673
+ const fixedTimestamp = new Date("1980-01-01T00:00:00.000Z");
674
+ const directoryEntries = await readdir(path.join(rootDir, relativeDir), {
675
+ withFileTypes: true
676
+ });
677
+ directoryEntries.sort((left, right) =>
678
+ left.name < right.name ? -1 : left.name > right.name ? 1 : 0
679
+ );
680
+
681
+ const result = [];
682
+ for (const entry of directoryEntries) {
683
+ if (entry.name.includes("\n") || entry.name.includes("\r")) {
684
+ throw new Error(
685
+ `package path contains an unsupported newline: ${path.join(relativeDir, entry.name)}`
686
+ );
687
+ }
688
+ const relativePath = path.join(relativeDir, entry.name);
689
+ const absolutePath = path.join(rootDir, relativePath);
690
+ await utimes(absolutePath, fixedTimestamp, fixedTimestamp);
691
+ if (entry.isDirectory()) {
692
+ result.push(`${relativePath}/`);
693
+ result.push(...(await normalizeArchiveEntries(rootDir, relativePath)));
694
+ continue;
695
+ }
696
+ if (!entry.isFile()) {
697
+ throw new Error(`unsupported package entry type: ${relativePath}`);
698
+ }
699
+ result.push(relativePath);
632
700
  }
701
+ return result;
633
702
  }
634
703
 
635
704
  async function fileDigestAndSize(filePath) {