@tutti-os/app-release-tools 0.0.92 → 0.0.93

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.
@@ -4,6 +4,7 @@ import { pathToFileURL } from "node:url";
4
4
  import path from "node:path";
5
5
 
6
6
  import {
7
+ normalizeRequiredTuttiCapabilities,
7
8
  releaseToCatalogApp,
8
9
  validateRelease
9
10
  } from "./build-tutti-app-release.mjs";
@@ -30,6 +31,7 @@ export async function buildTuttiAppCatalog(options) {
30
31
 
31
32
  const appsByID = new Map();
32
33
  const compatibilityByAppID = new Map();
34
+ const capabilityCompatibilityByAppID = new Map();
33
35
  if (existingCatalogPath) {
34
36
  const existingCatalog = JSON.parse(
35
37
  await readFile(existingCatalogPath, "utf8")
@@ -43,6 +45,11 @@ export async function buildTuttiAppCatalog(options) {
43
45
  )) {
44
46
  compatibilityByAppID.set(appId, entries);
45
47
  }
48
+ for (const [appId, entries] of Object.entries(
49
+ existingCatalog.compatibility?.capabilityApps ?? {}
50
+ )) {
51
+ capabilityCompatibilityByAppID.set(appId, entries);
52
+ }
46
53
  }
47
54
 
48
55
  const seenVersionsAppIDs = new Set();
@@ -53,7 +60,12 @@ export async function buildTuttiAppCatalog(options) {
53
60
  throw new Error(`duplicate versions appId ${versions.appId}`);
54
61
  }
55
62
  seenVersionsAppIDs.add(versions.appId);
56
- applyVersionsDocument(appsByID, compatibilityByAppID, versions);
63
+ applyVersionsDocument(
64
+ appsByID,
65
+ compatibilityByAppID,
66
+ capabilityCompatibilityByAppID,
67
+ versions
68
+ );
57
69
  }
58
70
 
59
71
  const seenReleaseAppIDs = new Set();
@@ -72,7 +84,11 @@ export async function buildTuttiAppCatalog(options) {
72
84
  appsByID.set(release.appId, releaseToCatalogApp(release));
73
85
  }
74
86
 
75
- if (appsByID.size === 0 && compatibilityByAppID.size === 0) {
87
+ if (
88
+ appsByID.size === 0 &&
89
+ compatibilityByAppID.size === 0 &&
90
+ capabilityCompatibilityByAppID.size === 0
91
+ ) {
76
92
  throw new Error(
77
93
  "at least one versions file, release file, or existing catalog app is required"
78
94
  );
@@ -86,11 +102,24 @@ export async function buildTuttiAppCatalog(options) {
86
102
  .filter(([, entries]) => entries.length > 0)
87
103
  .sort(([left], [right]) => left.localeCompare(right))
88
104
  );
105
+ const capabilityApps = Object.fromEntries(
106
+ [...capabilityCompatibilityByAppID.entries()]
107
+ .filter(([, entries]) => entries.length > 0)
108
+ .sort(([left], [right]) => left.localeCompare(right))
109
+ );
89
110
  const catalog = {
90
111
  schemaVersion: catalogSchemaVersion,
91
112
  apps,
92
- ...(Object.keys(compatibilityApps).length > 0
93
- ? { compatibility: { apps: compatibilityApps } }
113
+ ...(Object.keys(compatibilityApps).length > 0 ||
114
+ Object.keys(capabilityApps).length > 0
115
+ ? {
116
+ compatibility: {
117
+ apps: compatibilityApps,
118
+ ...(Object.keys(capabilityApps).length > 0
119
+ ? { capabilityApps }
120
+ : {})
121
+ }
122
+ }
94
123
  : {})
95
124
  };
96
125
  validateCatalog(catalog);
@@ -141,7 +170,18 @@ export function validateCatalog(catalog) {
141
170
  ) {
142
171
  throw new Error("catalog compatibility.apps must be an object");
143
172
  }
144
- for (const [appId, entries] of Object.entries(compatibility.apps)) {
173
+ validateCatalogCompatibilityApps(compatibility.apps);
174
+ if (compatibility.capabilityApps !== undefined) {
175
+ validateCatalogCapabilityApps(
176
+ compatibility.capabilityApps,
177
+ seenLegacyAppIDs
178
+ );
179
+ }
180
+ return catalog;
181
+ }
182
+
183
+ function validateCatalogCompatibilityApps(compatibilityApps) {
184
+ for (const [appId, entries] of Object.entries(compatibilityApps)) {
145
185
  if (!Array.isArray(entries) || entries.length === 0) {
146
186
  throw new Error(`catalog compatibility app ${appId} must be non-empty`);
147
187
  }
@@ -169,7 +209,55 @@ export function validateCatalog(catalog) {
169
209
  seenVersions.add(version);
170
210
  }
171
211
  }
172
- return catalog;
212
+ }
213
+
214
+ function validateCatalogCapabilityApps(capabilityApps, legacyAppIDs) {
215
+ if (
216
+ !capabilityApps ||
217
+ typeof capabilityApps !== "object" ||
218
+ Array.isArray(capabilityApps)
219
+ ) {
220
+ throw new Error("catalog compatibility.capabilityApps must be an object");
221
+ }
222
+ for (const [rawAppID, entries] of Object.entries(capabilityApps)) {
223
+ const appId = String(rawAppID).trim();
224
+ if (appId === "" || !Array.isArray(entries) || entries.length === 0) {
225
+ throw new Error(
226
+ `catalog capability compatibility app ${rawAppID} must be non-empty`
227
+ );
228
+ }
229
+ if (!legacyAppIDs.has(appId)) {
230
+ throw new Error(
231
+ `catalog capability compatibility app ${appId} requires a legacy fallback app`
232
+ );
233
+ }
234
+ const seenCapabilitySets = new Set();
235
+ const seenVersions = new Set();
236
+ for (const [index, entry] of entries.entries()) {
237
+ const label = `catalog capability compatibility app ${appId}[${index}]`;
238
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
239
+ throw new Error(`${label} must be an object`);
240
+ }
241
+ const requiredTuttiCapabilities = normalizeRequiredTuttiCapabilities(
242
+ entry.requiredTuttiCapabilities,
243
+ `${label}.requiredTuttiCapabilities`
244
+ );
245
+ const capabilityKey = requiredTuttiCapabilities.join("\u0000");
246
+ if (seenCapabilitySets.has(capabilityKey)) {
247
+ throw new Error(`${label} has duplicate requiredTuttiCapabilities`);
248
+ }
249
+ seenCapabilitySets.add(capabilityKey);
250
+ const entryAppID = validateCatalogApp(entry.app, `${label}.app`);
251
+ if (entryAppID !== appId) {
252
+ throw new Error(`${label}.app manifest.appId must match map key`);
253
+ }
254
+ const version = entry.app.manifest.version;
255
+ if (seenVersions.has(version)) {
256
+ throw new Error(`${label} has duplicate app version ${version}`);
257
+ }
258
+ seenVersions.add(version);
259
+ }
260
+ }
173
261
  }
174
262
 
175
263
  export function validateCatalogApp(app, label) {
@@ -200,7 +288,12 @@ export function validateCatalogApp(app, label) {
200
288
  return appId;
201
289
  }
202
290
 
203
- function applyVersionsDocument(appsByID, compatibilityByAppID, versions) {
291
+ function applyVersionsDocument(
292
+ appsByID,
293
+ compatibilityByAppID,
294
+ capabilityCompatibilityByAppID,
295
+ versions
296
+ ) {
204
297
  const activeRecords = versions.versions.filter(
205
298
  (record) => record.status === "active"
206
299
  );
@@ -214,7 +307,9 @@ function applyVersionsDocument(appsByID, compatibilityByAppID, versions) {
214
307
  appsByID.delete(versions.appId);
215
308
  }
216
309
 
217
- const entries = compatibilityFrontier(activeRecords).map((record) => ({
310
+ const entries = compatibilityFrontier(
311
+ activeRecords.filter((record) => record.minTuttiVersion !== undefined)
312
+ ).map((record) => ({
218
313
  minTuttiVersion: record.minTuttiVersion,
219
314
  app: releaseToCatalogApp(record.release)
220
315
  }));
@@ -223,6 +318,20 @@ function applyVersionsDocument(appsByID, compatibilityByAppID, versions) {
223
318
  } else {
224
319
  compatibilityByAppID.delete(versions.appId);
225
320
  }
321
+
322
+ const capabilityEntries = capabilityCompatibilityFrontier(
323
+ activeRecords.filter(
324
+ (record) => record.requiredTuttiCapabilities !== undefined
325
+ )
326
+ ).map((record) => ({
327
+ requiredTuttiCapabilities: record.requiredTuttiCapabilities,
328
+ app: releaseToCatalogApp(record.release)
329
+ }));
330
+ if (capabilityEntries.length > 0) {
331
+ capabilityCompatibilityByAppID.set(versions.appId, capabilityEntries);
332
+ } else {
333
+ capabilityCompatibilityByAppID.delete(versions.appId);
334
+ }
226
335
  }
227
336
 
228
337
  export function compatibilityFrontier(records) {
@@ -258,6 +367,27 @@ export function compatibilityFrontier(records) {
258
367
  return frontier;
259
368
  }
260
369
 
370
+ export function capabilityCompatibilityFrontier(records) {
371
+ const highestByCapabilitySet = new Map();
372
+ for (const record of records) {
373
+ const capabilityKey = record.requiredTuttiCapabilities.join("\u0000");
374
+ const existing = highestByCapabilitySet.get(capabilityKey);
375
+ if (
376
+ !existing ||
377
+ compareReleaseVersions(record.release, existing.release) > 0
378
+ ) {
379
+ highestByCapabilitySet.set(capabilityKey, record);
380
+ }
381
+ }
382
+ return [...highestByCapabilitySet.values()].sort((left, right) => {
383
+ const leftKey = left.requiredTuttiCapabilities.join("\u0000");
384
+ const rightKey = right.requiredTuttiCapabilities.join("\u0000");
385
+ const keyComparison = leftKey.localeCompare(rightKey);
386
+ if (keyComparison !== 0) return keyComparison;
387
+ return compareReleaseVersions(left.release, right.release);
388
+ });
389
+ }
390
+
261
391
  function highestReleaseRecord(records) {
262
392
  return [...records]
263
393
  .sort((left, right) => compareReleaseVersions(left.release, right.release))
@@ -167,9 +167,63 @@ export function validateManifest(manifest, sourceLabel = "manifest") {
167
167
  }
168
168
  validateManifestCLI(manifest.cli, sourceLabel);
169
169
  validateManifestReferences(manifest.references, sourceLabel);
170
+ requiredTuttiCapabilitiesForManifest(manifest, sourceLabel);
170
171
  validateLocalizationInfo(manifest.localizationInfo, sourceLabel);
171
172
  }
172
173
 
174
+ export function requiredTuttiCapabilitiesForManifest(
175
+ manifest,
176
+ sourceLabel = "manifest"
177
+ ) {
178
+ const compatibility = manifest.hostCompatibility;
179
+ if (compatibility === undefined) return [];
180
+ if (
181
+ !compatibility ||
182
+ typeof compatibility !== "object" ||
183
+ Array.isArray(compatibility)
184
+ ) {
185
+ throw new Error(`${sourceLabel}.hostCompatibility must be an object`);
186
+ }
187
+ const unsupportedKey = Object.keys(compatibility).find(
188
+ (key) => key !== "requiredTuttiCapabilities"
189
+ );
190
+ if (unsupportedKey) {
191
+ throw new Error(
192
+ `${sourceLabel}.hostCompatibility.${unsupportedKey} is unsupported`
193
+ );
194
+ }
195
+ return normalizeRequiredTuttiCapabilities(
196
+ compatibility.requiredTuttiCapabilities,
197
+ `${sourceLabel}.hostCompatibility.requiredTuttiCapabilities`
198
+ );
199
+ }
200
+
201
+ export function normalizeRequiredTuttiCapabilities(value, label) {
202
+ if (!Array.isArray(value) || value.length === 0) {
203
+ throw new Error(`${label} must be a non-empty array`);
204
+ }
205
+ if (value.length > 32) {
206
+ throw new Error(`${label} must contain at most 32 capabilities`);
207
+ }
208
+ const capabilities = value.map((capability, index) => {
209
+ if (typeof capability !== "string" || capability.trim() !== capability) {
210
+ throw new Error(`${label}[${index}] must be a normalized string`);
211
+ }
212
+ if (!/^[a-z][a-z0-9-]{0,63}$/u.test(capability)) {
213
+ throw new Error(`${label}[${index}] is invalid`);
214
+ }
215
+ return capability;
216
+ });
217
+ const normalized = [...new Set(capabilities)].sort();
218
+ if (
219
+ normalized.length !== capabilities.length ||
220
+ normalized.some((capability, index) => capability !== capabilities[index])
221
+ ) {
222
+ throw new Error(`${label} must be sorted and unique`);
223
+ }
224
+ return normalized;
225
+ }
226
+
173
227
  function validateManifestCLI(cli, sourceLabel) {
174
228
  if (cli === undefined) {
175
229
  return;
@@ -3,7 +3,11 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
3
  import { pathToFileURL } from "node:url";
4
4
  import path from "node:path";
5
5
 
6
- import { validateRelease } from "./build-tutti-app-release.mjs";
6
+ import {
7
+ normalizeRequiredTuttiCapabilities,
8
+ requiredTuttiCapabilitiesForManifest,
9
+ validateRelease
10
+ } from "./build-tutti-app-release.mjs";
7
11
  import {
8
12
  compareReleaseVersions,
9
13
  requireSemver
@@ -40,16 +44,32 @@ export async function buildTuttiAppVersions(options) {
40
44
  }
41
45
 
42
46
  if (releaseFiles.length > 0) {
43
- const minTuttiVersion = requireSemver(
44
- options.minTuttiVersion,
45
- "minTuttiVersion"
46
- );
47
+ const minTuttiVersion = options.minTuttiVersion
48
+ ? requireSemver(options.minTuttiVersion, "minTuttiVersion")
49
+ : null;
47
50
  const status = normalizeStatus(options.status ?? "active");
48
51
  for (const releaseFile of releaseFiles) {
49
52
  const release = JSON.parse(await readFile(releaseFile, "utf8"));
50
53
  validateRelease(release);
54
+ const requiredTuttiCapabilities = requiredTuttiCapabilitiesForManifest(
55
+ release.manifest,
56
+ "release.manifest"
57
+ );
58
+ if (minTuttiVersion && requiredTuttiCapabilities.length > 0) {
59
+ throw new Error(
60
+ "a release cannot declare both minTuttiVersion and requiredTuttiCapabilities"
61
+ );
62
+ }
63
+ if (!minTuttiVersion && requiredTuttiCapabilities.length === 0) {
64
+ throw new Error(
65
+ "minTuttiVersion or release.manifest.hostCompatibility.requiredTuttiCapabilities is required"
66
+ );
67
+ }
51
68
  document = addVersionRecord(document, {
52
- minTuttiVersion,
69
+ ...(minTuttiVersion ? { minTuttiVersion } : {}),
70
+ ...(requiredTuttiCapabilities.length > 0
71
+ ? { requiredTuttiCapabilities }
72
+ : {}),
53
73
  status,
54
74
  release
55
75
  });
@@ -104,10 +124,31 @@ export function validateVersionsDocument(document) {
104
124
  if (!record || typeof record !== "object" || Array.isArray(record)) {
105
125
  throw new Error(`${label} must be an object`);
106
126
  }
107
- record.minTuttiVersion = requireSemver(
108
- record.minTuttiVersion,
109
- `${label}.minTuttiVersion`
110
- );
127
+ const hasMinTuttiVersion = record.minTuttiVersion !== undefined;
128
+ const hasRequiredTuttiCapabilities =
129
+ record.requiredTuttiCapabilities !== undefined;
130
+ if (hasMinTuttiVersion && hasRequiredTuttiCapabilities) {
131
+ throw new Error(
132
+ `${label} cannot declare both minTuttiVersion and requiredTuttiCapabilities`
133
+ );
134
+ }
135
+ if (!hasMinTuttiVersion && !hasRequiredTuttiCapabilities) {
136
+ throw new Error(
137
+ `${label} must declare minTuttiVersion or requiredTuttiCapabilities`
138
+ );
139
+ }
140
+ if (hasMinTuttiVersion) {
141
+ record.minTuttiVersion = requireSemver(
142
+ record.minTuttiVersion,
143
+ `${label}.minTuttiVersion`
144
+ );
145
+ }
146
+ if (hasRequiredTuttiCapabilities) {
147
+ record.requiredTuttiCapabilities = normalizeRequiredTuttiCapabilities(
148
+ record.requiredTuttiCapabilities,
149
+ `${label}.requiredTuttiCapabilities`
150
+ );
151
+ }
111
152
  record.status = normalizeStatus(record.status);
112
153
  validateRelease(record.release);
113
154
  if (record.release.appId !== appId) {
@@ -7,6 +7,7 @@ import path from "node:path";
7
7
 
8
8
  import { buildTuttiAppCatalog } from "./build-tutti-app-catalog.mjs";
9
9
  import {
10
+ requiredTuttiCapabilitiesForManifest,
10
11
  releaseToCatalogApp,
11
12
  validateRelease
12
13
  } from "./build-tutti-app-release.mjs";
@@ -37,10 +38,6 @@ export async function publishTuttiAppMetadata(options) {
37
38
  if (!catalogOnly && !releasePath) {
38
39
  throw new Error("releasePath is required unless catalogOnly is true");
39
40
  }
40
- if (!catalogOnly && !minTuttiVersion) {
41
- throw new Error("minTuttiVersion is required for app releases");
42
- }
43
-
44
41
  await mkdir(outputDir, { recursive: true });
45
42
  const release = releasePath
46
43
  ? JSON.parse(await readFile(releasePath, "utf8"))
@@ -51,6 +48,23 @@ export async function publishTuttiAppMetadata(options) {
51
48
  throw new Error("release appId must match input appId");
52
49
  }
53
50
  }
51
+ const requiredTuttiCapabilities = release
52
+ ? requiredTuttiCapabilitiesForManifest(release.manifest, "release.manifest")
53
+ : [];
54
+ if (
55
+ !catalogOnly &&
56
+ !minTuttiVersion &&
57
+ requiredTuttiCapabilities.length === 0
58
+ ) {
59
+ throw new Error(
60
+ "minTuttiVersion or release.manifest.hostCompatibility.requiredTuttiCapabilities is required for app releases"
61
+ );
62
+ }
63
+ if (minTuttiVersion && requiredTuttiCapabilities.length > 0) {
64
+ throw new Error(
65
+ "an app release cannot declare both minTuttiVersion and requiredTuttiCapabilities"
66
+ );
67
+ }
54
68
 
55
69
  const key = (suffix) => (prefix ? `${prefix}/${suffix}` : suffix);
56
70
  const versionsKey = key(`apps/${appId}/versions.json`);
@@ -78,7 +92,7 @@ export async function publishTuttiAppMetadata(options) {
78
92
 
79
93
  if (releasePath) {
80
94
  releaseFiles.push(releasePath);
81
- } else if (!existingPath && minTuttiVersion) {
95
+ } else if (!existingPath) {
82
96
  const latest = await getObject({
83
97
  bucket,
84
98
  key: latestKey,
@@ -100,7 +114,9 @@ export async function publishTuttiAppMetadata(options) {
100
114
  existingVersionsPath: existingPath,
101
115
  baselineReleaseFiles,
102
116
  releaseFiles,
103
- ...(releaseFiles.length > 0 ? { minTuttiVersion } : {}),
117
+ ...(releaseFiles.length > 0 && minTuttiVersion
118
+ ? { minTuttiVersion }
119
+ : {}),
104
120
  outputPath
105
121
  });
106
122
  return outputPath;
@@ -173,7 +189,10 @@ async function downloadLegacyBaseline(input) {
173
189
  });
174
190
  if (!catalog) return null;
175
191
  const document = JSON.parse(await readFile(catalog.path, "utf8"));
176
- if ((document.compatibility?.apps?.[input.appId] ?? []).length > 0) {
192
+ if (
193
+ (document.compatibility?.apps?.[input.appId] ?? []).length > 0 ||
194
+ (document.compatibility?.capabilityApps?.[input.appId] ?? []).length > 0
195
+ ) {
177
196
  throw new Error(
178
197
  `versions index for ${input.appId} is missing but catalog compatibility history exists; restore versions.json before publishing`
179
198
  );
@@ -70,6 +70,18 @@ export async function verifyTuttiAppReleaseArtifacts(options) {
70
70
  );
71
71
  }
72
72
  }
73
+ for (const [appId, entries] of Object.entries(
74
+ catalog.compatibility?.capabilityApps ?? {}
75
+ )) {
76
+ for (const entry of entries) {
77
+ addCatalogAppCheck(
78
+ checks,
79
+ releasesByKey,
80
+ entry.app,
81
+ `catalog capability compatibility app ${appId}`
82
+ );
83
+ }
84
+ }
73
85
  }
74
86
 
75
87
  if (verifyArtifacts) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tutti-os/app-release-tools",
3
- "version": "0.0.92",
3
+ "version": "0.0.93",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {