@tutti-os/app-release-tools 0.0.78 → 0.0.79
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 +26 -11
- package/bin/build-tutti-app-catalog.mjs +224 -64
- package/bin/build-tutti-app-release.mjs +4 -0
- package/bin/build-tutti-app-versions.mjs +236 -0
- package/bin/merge-tutti-app-latest.mjs +70 -0
- package/bin/publish-tutti-app-catalog.mjs +199 -0
- package/bin/publish-tutti-app-metadata.mjs +365 -0
- package/bin/tutti-app-versioning.mjs +44 -0
- package/bin/verify-tutti-app-release-artifacts.mjs +96 -124
- package/package.json +12 -1
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
import { validateRelease } from "./build-tutti-app-release.mjs";
|
|
7
|
+
import { compareReleaseVersions } from "./tutti-app-versioning.mjs";
|
|
8
|
+
|
|
9
|
+
export async function mergeTuttiAppLatest(options) {
|
|
10
|
+
const releasePath = path.resolve(String(options.releasePath));
|
|
11
|
+
const outputPath = path.resolve(String(options.outputPath));
|
|
12
|
+
const release = JSON.parse(await readFile(releasePath, "utf8"));
|
|
13
|
+
validateRelease(release);
|
|
14
|
+
|
|
15
|
+
let latest = release;
|
|
16
|
+
if (options.existingLatestPath) {
|
|
17
|
+
const existing = JSON.parse(
|
|
18
|
+
await readFile(path.resolve(String(options.existingLatestPath)), "utf8")
|
|
19
|
+
);
|
|
20
|
+
validateRelease(existing);
|
|
21
|
+
if (existing.appId !== release.appId) {
|
|
22
|
+
throw new Error("existing latest appId must match release appId");
|
|
23
|
+
}
|
|
24
|
+
if (compareReleaseVersions(existing, release) > 0) {
|
|
25
|
+
latest = existing;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
await mkdir(path.dirname(outputPath), { recursive: true });
|
|
30
|
+
await writeFile(outputPath, `${JSON.stringify(latest, null, 2)}\n`);
|
|
31
|
+
return { outputPath, latest };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function parseArgs(argv) {
|
|
35
|
+
const result = {};
|
|
36
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
37
|
+
const arg = argv[index];
|
|
38
|
+
const value = argv[index + 1];
|
|
39
|
+
if (["--release-file", "--existing-latest", "--output"].includes(arg)) {
|
|
40
|
+
if (!value || value.startsWith("--")) {
|
|
41
|
+
throw new Error(`missing value for ${arg}`);
|
|
42
|
+
}
|
|
43
|
+
if (arg === "--release-file") result.releasePath = value;
|
|
44
|
+
if (arg === "--existing-latest") result.existingLatestPath = value;
|
|
45
|
+
if (arg === "--output") result.outputPath = value;
|
|
46
|
+
index += 1;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
throw new Error(`unexpected argument: ${arg}`);
|
|
50
|
+
}
|
|
51
|
+
if (!result.releasePath || !result.outputPath) {
|
|
52
|
+
throw new Error("--release-file and --output are required");
|
|
53
|
+
}
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function main() {
|
|
58
|
+
const result = await mergeTuttiAppLatest(parseArgs(process.argv.slice(2)));
|
|
59
|
+
process.stdout.write(`${JSON.stringify(result.latest, null, 2)}\n`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (
|
|
63
|
+
process.argv[1] &&
|
|
64
|
+
import.meta.url === pathToFileURL(process.argv[1]).href
|
|
65
|
+
) {
|
|
66
|
+
main().catch((error) => {
|
|
67
|
+
console.error(error.message);
|
|
68
|
+
process.exitCode = 1;
|
|
69
|
+
});
|
|
70
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { copyFile, mkdir } from "node:fs/promises";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
7
|
+
import { buildTuttiAppCatalog } from "./build-tutti-app-catalog.mjs";
|
|
8
|
+
import { publishTuttiAppMetadata } from "./publish-tutti-app-metadata.mjs";
|
|
9
|
+
import { verifyTuttiAppReleaseArtifacts } from "./verify-tutti-app-release-artifacts.mjs";
|
|
10
|
+
|
|
11
|
+
export async function publishTuttiAppCatalog(options) {
|
|
12
|
+
const appIds = [...new Set(options.appIds ?? [])]
|
|
13
|
+
.map((value) => String(value).trim())
|
|
14
|
+
.filter(Boolean)
|
|
15
|
+
.sort();
|
|
16
|
+
if (appIds.length === 0) throw new Error("at least one appId is required");
|
|
17
|
+
const bucket = requireNonEmpty(options.s3Bucket, "s3Bucket");
|
|
18
|
+
const prefix = String(options.s3Prefix ?? "")
|
|
19
|
+
.trim()
|
|
20
|
+
.replace(/^\/+|\/+$/gu, "");
|
|
21
|
+
const mode = options.mode ?? "merge";
|
|
22
|
+
if (mode !== "merge" && mode !== "replace") {
|
|
23
|
+
throw new Error("mode must be merge or replace");
|
|
24
|
+
}
|
|
25
|
+
const outputDir = path.resolve(
|
|
26
|
+
String(options.outputDir ?? "tutti-app-catalog")
|
|
27
|
+
);
|
|
28
|
+
await mkdir(outputDir, { recursive: true });
|
|
29
|
+
const key = (suffix) => (prefix ? `${prefix}/${suffix}` : suffix);
|
|
30
|
+
const catalogKey = key("catalog.json");
|
|
31
|
+
|
|
32
|
+
let lastError = null;
|
|
33
|
+
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
|
34
|
+
const attemptDir = path.join(outputDir, `attempt-${attempt}`);
|
|
35
|
+
await mkdir(attemptDir, { recursive: true });
|
|
36
|
+
const existing = await getObject({
|
|
37
|
+
bucket,
|
|
38
|
+
key: catalogKey,
|
|
39
|
+
outputPath: path.join(attemptDir, "existing-catalog.json"),
|
|
40
|
+
required: false
|
|
41
|
+
});
|
|
42
|
+
const versionsFiles = [];
|
|
43
|
+
for (const appId of appIds) {
|
|
44
|
+
let versions = await getObject({
|
|
45
|
+
bucket,
|
|
46
|
+
key: key(`apps/${appId}/versions.json`),
|
|
47
|
+
outputPath: path.join(attemptDir, `${appId}-versions.json`),
|
|
48
|
+
required: false
|
|
49
|
+
});
|
|
50
|
+
if (!versions) {
|
|
51
|
+
await publishTuttiAppMetadata({
|
|
52
|
+
appId,
|
|
53
|
+
s3Bucket: bucket,
|
|
54
|
+
s3Prefix: prefix,
|
|
55
|
+
catalogOnly: true,
|
|
56
|
+
publishCatalog: false,
|
|
57
|
+
outputDir: path.join(attemptDir, `${appId}-bootstrap`)
|
|
58
|
+
});
|
|
59
|
+
versions = await getObject({
|
|
60
|
+
bucket,
|
|
61
|
+
key: key(`apps/${appId}/versions.json`),
|
|
62
|
+
outputPath: path.join(attemptDir, `${appId}-versions.json`),
|
|
63
|
+
required: true
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
versionsFiles.push(versions.path);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const catalogPath = path.join(attemptDir, "catalog.json");
|
|
70
|
+
await buildTuttiAppCatalog({
|
|
71
|
+
existingCatalogPath: mode === "merge" ? existing?.path : null,
|
|
72
|
+
versionsFiles,
|
|
73
|
+
outputPath: catalogPath
|
|
74
|
+
});
|
|
75
|
+
await verifyTuttiAppReleaseArtifacts({
|
|
76
|
+
catalogFile: catalogPath,
|
|
77
|
+
versionsFiles,
|
|
78
|
+
verifyArtifacts: true
|
|
79
|
+
});
|
|
80
|
+
try {
|
|
81
|
+
putObject({
|
|
82
|
+
bucket,
|
|
83
|
+
key: catalogKey,
|
|
84
|
+
path: catalogPath,
|
|
85
|
+
etag: existing?.etag ?? null
|
|
86
|
+
});
|
|
87
|
+
const stablePath = path.join(outputDir, "catalog.json");
|
|
88
|
+
await copyFile(catalogPath, stablePath);
|
|
89
|
+
return { catalogPath: stablePath, appIds, mode };
|
|
90
|
+
} catch (error) {
|
|
91
|
+
if (!/(?:412|PreconditionFailed|precondition)/iu.test(error.message)) {
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
lastError = error;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
throw new Error(
|
|
98
|
+
`catalog changed concurrently: ${lastError?.message ?? "failed"}`
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function getObject({ bucket, key, outputPath, required }) {
|
|
103
|
+
const head = runAWS(
|
|
104
|
+
["s3api", "head-object", "--bucket", bucket, "--key", key],
|
|
105
|
+
{ allowMissing: !required }
|
|
106
|
+
);
|
|
107
|
+
if (head.missing) return null;
|
|
108
|
+
const metadata = JSON.parse(head.stdout);
|
|
109
|
+
runAWS(["s3api", "get-object", "--bucket", bucket, "--key", key, outputPath]);
|
|
110
|
+
return { path: outputPath, etag: metadata.ETag };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function putObject({ bucket, key, path: filePath, etag }) {
|
|
114
|
+
const args = [
|
|
115
|
+
"s3api",
|
|
116
|
+
"put-object",
|
|
117
|
+
"--bucket",
|
|
118
|
+
bucket,
|
|
119
|
+
"--key",
|
|
120
|
+
key,
|
|
121
|
+
"--body",
|
|
122
|
+
filePath,
|
|
123
|
+
"--content-type",
|
|
124
|
+
"application/json",
|
|
125
|
+
"--cache-control",
|
|
126
|
+
"public, max-age=60",
|
|
127
|
+
...(etag ? ["--if-match", etag] : ["--if-none-match", "*"])
|
|
128
|
+
];
|
|
129
|
+
runAWS(args);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function runAWS(args, options = {}) {
|
|
133
|
+
const result = spawnSync("aws", args, {
|
|
134
|
+
encoding: "utf8",
|
|
135
|
+
maxBuffer: 10 * 1024 * 1024
|
|
136
|
+
});
|
|
137
|
+
if (result.status === 0) {
|
|
138
|
+
return { stdout: result.stdout, missing: false };
|
|
139
|
+
}
|
|
140
|
+
const message = `${result.stderr ?? ""}\n${result.stdout ?? ""}`.trim();
|
|
141
|
+
if (
|
|
142
|
+
options.allowMissing &&
|
|
143
|
+
/(?:404|Not Found|NoSuchKey|NotFound)/iu.test(message)
|
|
144
|
+
) {
|
|
145
|
+
return { stdout: "", missing: true };
|
|
146
|
+
}
|
|
147
|
+
throw new Error(`aws ${args.slice(0, 2).join(" ")} failed: ${message}`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function requireNonEmpty(value, label) {
|
|
151
|
+
const text = String(value ?? "").trim();
|
|
152
|
+
if (!text) throw new Error(`${label} is required`);
|
|
153
|
+
return text;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function parseArgs(argv) {
|
|
157
|
+
const result = { appIds: [] };
|
|
158
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
159
|
+
const arg = argv[index];
|
|
160
|
+
const value = argv[index + 1];
|
|
161
|
+
if (
|
|
162
|
+
[
|
|
163
|
+
"--app-id",
|
|
164
|
+
"--s3-bucket",
|
|
165
|
+
"--s3-prefix",
|
|
166
|
+
"--mode",
|
|
167
|
+
"--output-dir"
|
|
168
|
+
].includes(arg)
|
|
169
|
+
) {
|
|
170
|
+
if (value === undefined || value.startsWith("--")) {
|
|
171
|
+
throw new Error(`missing value for ${arg}`);
|
|
172
|
+
}
|
|
173
|
+
if (arg === "--app-id") result.appIds.push(value);
|
|
174
|
+
if (arg === "--s3-bucket") result.s3Bucket = value;
|
|
175
|
+
if (arg === "--s3-prefix") result.s3Prefix = value;
|
|
176
|
+
if (arg === "--mode") result.mode = value;
|
|
177
|
+
if (arg === "--output-dir") result.outputDir = value;
|
|
178
|
+
index += 1;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
throw new Error(`unexpected argument: ${arg}`);
|
|
182
|
+
}
|
|
183
|
+
return result;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export async function main() {
|
|
187
|
+
const result = await publishTuttiAppCatalog(parseArgs(process.argv.slice(2)));
|
|
188
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (
|
|
192
|
+
process.argv[1] &&
|
|
193
|
+
import.meta.url === pathToFileURL(process.argv[1]).href
|
|
194
|
+
) {
|
|
195
|
+
main().catch((error) => {
|
|
196
|
+
console.error(error.message);
|
|
197
|
+
process.exitCode = 1;
|
|
198
|
+
});
|
|
199
|
+
}
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { copyFile, mkdir, readFile } from "node:fs/promises";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
import { isDeepStrictEqual } from "node:util";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
|
|
8
|
+
import { buildTuttiAppCatalog } from "./build-tutti-app-catalog.mjs";
|
|
9
|
+
import {
|
|
10
|
+
releaseToCatalogApp,
|
|
11
|
+
validateRelease
|
|
12
|
+
} from "./build-tutti-app-release.mjs";
|
|
13
|
+
import { buildTuttiAppVersions } from "./build-tutti-app-versions.mjs";
|
|
14
|
+
import { mergeTuttiAppLatest } from "./merge-tutti-app-latest.mjs";
|
|
15
|
+
import { requireSemver } from "./tutti-app-versioning.mjs";
|
|
16
|
+
|
|
17
|
+
const maxCASAttempts = 3;
|
|
18
|
+
|
|
19
|
+
export async function publishTuttiAppMetadata(options) {
|
|
20
|
+
const appId = requireNonEmpty(options.appId, "appId");
|
|
21
|
+
const bucket = requireNonEmpty(options.s3Bucket, "s3Bucket");
|
|
22
|
+
const prefix = String(options.s3Prefix ?? "")
|
|
23
|
+
.trim()
|
|
24
|
+
.replace(/^\/+|\/+$/gu, "");
|
|
25
|
+
const outputDir = path.resolve(
|
|
26
|
+
String(options.outputDir ?? "tutti-app-metadata")
|
|
27
|
+
);
|
|
28
|
+
const publishCatalog = options.publishCatalog === true;
|
|
29
|
+
const catalogOnly = options.catalogOnly === true;
|
|
30
|
+
const releasePath = options.releasePath
|
|
31
|
+
? path.resolve(String(options.releasePath))
|
|
32
|
+
: null;
|
|
33
|
+
const minTuttiVersion = options.minTuttiVersion
|
|
34
|
+
? requireSemver(options.minTuttiVersion, "minTuttiVersion")
|
|
35
|
+
: null;
|
|
36
|
+
|
|
37
|
+
if (!catalogOnly && !releasePath) {
|
|
38
|
+
throw new Error("releasePath is required unless catalogOnly is true");
|
|
39
|
+
}
|
|
40
|
+
if (!catalogOnly && !minTuttiVersion) {
|
|
41
|
+
throw new Error("minTuttiVersion is required for app releases");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
await mkdir(outputDir, { recursive: true });
|
|
45
|
+
const release = releasePath
|
|
46
|
+
? JSON.parse(await readFile(releasePath, "utf8"))
|
|
47
|
+
: null;
|
|
48
|
+
if (release) {
|
|
49
|
+
validateRelease(release);
|
|
50
|
+
if (release.appId !== appId) {
|
|
51
|
+
throw new Error("release appId must match input appId");
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const key = (suffix) => (prefix ? `${prefix}/${suffix}` : suffix);
|
|
56
|
+
const versionsKey = key(`apps/${appId}/versions.json`);
|
|
57
|
+
const latestKey = key(`apps/${appId}/latest.json`);
|
|
58
|
+
const catalogKey = key("catalog.json");
|
|
59
|
+
|
|
60
|
+
const versionsPath = await updateJSONWithCAS({
|
|
61
|
+
bucket,
|
|
62
|
+
key: versionsKey,
|
|
63
|
+
outputDir,
|
|
64
|
+
label: "versions",
|
|
65
|
+
build: async ({ existingPath, attemptDir }) => {
|
|
66
|
+
const baselineReleaseFiles = [];
|
|
67
|
+
const releaseFiles = [];
|
|
68
|
+
if (!existingPath) {
|
|
69
|
+
const baseline = await downloadLegacyBaseline({
|
|
70
|
+
appId,
|
|
71
|
+
bucket,
|
|
72
|
+
catalogKey,
|
|
73
|
+
key,
|
|
74
|
+
outputDir: attemptDir
|
|
75
|
+
});
|
|
76
|
+
if (baseline) baselineReleaseFiles.push(baseline);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (releasePath) {
|
|
80
|
+
releaseFiles.push(releasePath);
|
|
81
|
+
} else if (!existingPath && minTuttiVersion) {
|
|
82
|
+
const latest = await getObject({
|
|
83
|
+
bucket,
|
|
84
|
+
key: latestKey,
|
|
85
|
+
outputPath: path.join(attemptDir, "latest.json"),
|
|
86
|
+
required: true
|
|
87
|
+
});
|
|
88
|
+
const latestRelease = JSON.parse(await readFile(latest.path, "utf8"));
|
|
89
|
+
validateRelease(latestRelease);
|
|
90
|
+
const baselineVersion = baselineReleaseFiles.length
|
|
91
|
+
? JSON.parse(await readFile(baselineReleaseFiles[0], "utf8")).version
|
|
92
|
+
: null;
|
|
93
|
+
if (latestRelease.version !== baselineVersion) {
|
|
94
|
+
releaseFiles.push(latest.path);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const outputPath = path.join(attemptDir, "versions.json");
|
|
99
|
+
await buildTuttiAppVersions({
|
|
100
|
+
existingVersionsPath: existingPath,
|
|
101
|
+
baselineReleaseFiles,
|
|
102
|
+
releaseFiles,
|
|
103
|
+
...(releaseFiles.length > 0 ? { minTuttiVersion } : {}),
|
|
104
|
+
outputPath
|
|
105
|
+
});
|
|
106
|
+
return outputPath;
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
let latestPath = null;
|
|
111
|
+
if (releasePath) {
|
|
112
|
+
latestPath = await updateJSONWithCAS({
|
|
113
|
+
bucket,
|
|
114
|
+
key: latestKey,
|
|
115
|
+
outputDir,
|
|
116
|
+
label: "latest",
|
|
117
|
+
build: async ({ existingPath, attemptDir }) => {
|
|
118
|
+
const outputPath = path.join(attemptDir, "latest.json");
|
|
119
|
+
await mergeTuttiAppLatest({
|
|
120
|
+
releasePath,
|
|
121
|
+
existingLatestPath: existingPath,
|
|
122
|
+
outputPath
|
|
123
|
+
});
|
|
124
|
+
return outputPath;
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
let catalogPath = null;
|
|
130
|
+
if (publishCatalog) {
|
|
131
|
+
catalogPath = await updateJSONWithCAS({
|
|
132
|
+
bucket,
|
|
133
|
+
key: catalogKey,
|
|
134
|
+
outputDir,
|
|
135
|
+
label: "catalog",
|
|
136
|
+
build: async ({ existingPath, attemptDir }) => {
|
|
137
|
+
const outputPath = path.join(attemptDir, "catalog.json");
|
|
138
|
+
await buildTuttiAppCatalog({
|
|
139
|
+
existingCatalogPath: existingPath,
|
|
140
|
+
versionsFiles: [versionsPath],
|
|
141
|
+
outputPath
|
|
142
|
+
});
|
|
143
|
+
return outputPath;
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const stableVersionsPath = path.join(outputDir, "versions.json");
|
|
149
|
+
await copyFile(versionsPath, stableVersionsPath);
|
|
150
|
+
const stableLatestPath = latestPath
|
|
151
|
+
? path.join(outputDir, "latest.json")
|
|
152
|
+
: null;
|
|
153
|
+
if (latestPath) await copyFile(latestPath, stableLatestPath);
|
|
154
|
+
const stableCatalogPath = catalogPath
|
|
155
|
+
? path.join(outputDir, "catalog.json")
|
|
156
|
+
: null;
|
|
157
|
+
if (catalogPath) await copyFile(catalogPath, stableCatalogPath);
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
appId,
|
|
161
|
+
versionsPath: stableVersionsPath,
|
|
162
|
+
latestPath: stableLatestPath,
|
|
163
|
+
catalogPath: stableCatalogPath
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function downloadLegacyBaseline(input) {
|
|
168
|
+
const catalog = await getObject({
|
|
169
|
+
bucket: input.bucket,
|
|
170
|
+
key: input.catalogKey,
|
|
171
|
+
outputPath: path.join(input.outputDir, "existing-catalog.json"),
|
|
172
|
+
required: false
|
|
173
|
+
});
|
|
174
|
+
if (!catalog) return null;
|
|
175
|
+
const document = JSON.parse(await readFile(catalog.path, "utf8"));
|
|
176
|
+
if ((document.compatibility?.apps?.[input.appId] ?? []).length > 0) {
|
|
177
|
+
throw new Error(
|
|
178
|
+
`versions index for ${input.appId} is missing but catalog compatibility history exists; restore versions.json before publishing`
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
const app = document.apps?.find(
|
|
182
|
+
(candidate) => candidate?.manifest?.appId === input.appId
|
|
183
|
+
);
|
|
184
|
+
const version = app?.manifest?.version;
|
|
185
|
+
if (typeof version !== "string" || version.trim() === "") return null;
|
|
186
|
+
|
|
187
|
+
const baseline = await getObject({
|
|
188
|
+
bucket: input.bucket,
|
|
189
|
+
key: input.key(`apps/${input.appId}/${version}/release.json`),
|
|
190
|
+
outputPath: path.join(input.outputDir, "baseline-release.json"),
|
|
191
|
+
required: true
|
|
192
|
+
});
|
|
193
|
+
const release = JSON.parse(await readFile(baseline.path, "utf8"));
|
|
194
|
+
validateRelease(release);
|
|
195
|
+
if (release.appId !== input.appId || release.version !== version) {
|
|
196
|
+
throw new Error("legacy baseline release does not match catalog app");
|
|
197
|
+
}
|
|
198
|
+
if (!isDeepStrictEqual(releaseToCatalogApp(release), app)) {
|
|
199
|
+
throw new Error(
|
|
200
|
+
"legacy baseline release projection does not match catalog app"
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
return baseline.path;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function updateJSONWithCAS(input) {
|
|
207
|
+
let lastPreconditionError = null;
|
|
208
|
+
for (let attempt = 1; attempt <= maxCASAttempts; attempt += 1) {
|
|
209
|
+
const attemptDir = path.join(input.outputDir, `${input.label}-${attempt}`);
|
|
210
|
+
await mkdir(attemptDir, { recursive: true });
|
|
211
|
+
const existing = await getObject({
|
|
212
|
+
bucket: input.bucket,
|
|
213
|
+
key: input.key,
|
|
214
|
+
outputPath: path.join(attemptDir, "existing.json"),
|
|
215
|
+
required: false
|
|
216
|
+
});
|
|
217
|
+
const outputPath = await input.build({
|
|
218
|
+
existingPath: existing?.path ?? null,
|
|
219
|
+
attemptDir
|
|
220
|
+
});
|
|
221
|
+
try {
|
|
222
|
+
putObject({
|
|
223
|
+
bucket: input.bucket,
|
|
224
|
+
key: input.key,
|
|
225
|
+
path: outputPath,
|
|
226
|
+
etag: existing?.etag ?? null
|
|
227
|
+
});
|
|
228
|
+
return outputPath;
|
|
229
|
+
} catch (error) {
|
|
230
|
+
if (!isPreconditionFailure(error)) throw error;
|
|
231
|
+
lastPreconditionError = error;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
throw new Error(
|
|
235
|
+
`${input.label} metadata changed concurrently after ${maxCASAttempts} attempts: ${lastPreconditionError?.message ?? "precondition failed"}`
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function getObject({ bucket, key, outputPath, required }) {
|
|
240
|
+
const head = runAWS(
|
|
241
|
+
["s3api", "head-object", "--bucket", bucket, "--key", key],
|
|
242
|
+
{ allowMissing: !required }
|
|
243
|
+
);
|
|
244
|
+
if (head.missing) return null;
|
|
245
|
+
const metadata = JSON.parse(head.stdout);
|
|
246
|
+
runAWS(["s3api", "get-object", "--bucket", bucket, "--key", key, outputPath]);
|
|
247
|
+
return { path: outputPath, etag: metadata.ETag };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function putObject({ bucket, key, path: filePath, etag }) {
|
|
251
|
+
const args = [
|
|
252
|
+
"s3api",
|
|
253
|
+
"put-object",
|
|
254
|
+
"--bucket",
|
|
255
|
+
bucket,
|
|
256
|
+
"--key",
|
|
257
|
+
key,
|
|
258
|
+
"--body",
|
|
259
|
+
filePath,
|
|
260
|
+
"--content-type",
|
|
261
|
+
"application/json",
|
|
262
|
+
"--cache-control",
|
|
263
|
+
"public, max-age=60"
|
|
264
|
+
];
|
|
265
|
+
if (etag) {
|
|
266
|
+
args.push("--if-match", etag);
|
|
267
|
+
} else {
|
|
268
|
+
args.push("--if-none-match", "*");
|
|
269
|
+
}
|
|
270
|
+
runAWS(args);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function runAWS(args, options = {}) {
|
|
274
|
+
const result = spawnSync("aws", args, {
|
|
275
|
+
encoding: "utf8",
|
|
276
|
+
maxBuffer: 10 * 1024 * 1024
|
|
277
|
+
});
|
|
278
|
+
if (result.status === 0) {
|
|
279
|
+
return { stdout: result.stdout, missing: false };
|
|
280
|
+
}
|
|
281
|
+
const message = `${result.stderr ?? ""}\n${result.stdout ?? ""}`.trim();
|
|
282
|
+
if (
|
|
283
|
+
options.allowMissing &&
|
|
284
|
+
/(?:404|Not Found|NoSuchKey|NotFound)/iu.test(message)
|
|
285
|
+
) {
|
|
286
|
+
return { stdout: "", missing: true };
|
|
287
|
+
}
|
|
288
|
+
const error = new Error(
|
|
289
|
+
`aws ${args.slice(0, 2).join(" ")} failed: ${message}`
|
|
290
|
+
);
|
|
291
|
+
error.awsOutput = message;
|
|
292
|
+
throw error;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function isPreconditionFailure(error) {
|
|
296
|
+
return /(?:412|PreconditionFailed|precondition)/iu.test(
|
|
297
|
+
error?.awsOutput ?? error?.message ?? ""
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function requireNonEmpty(value, label) {
|
|
302
|
+
const text = String(value ?? "").trim();
|
|
303
|
+
if (!text) throw new Error(`${label} is required`);
|
|
304
|
+
return text;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function parseArgs(argv) {
|
|
308
|
+
const result = {};
|
|
309
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
310
|
+
const arg = argv[index];
|
|
311
|
+
if (arg === "--publish-catalog") {
|
|
312
|
+
result.publishCatalog = true;
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
if (arg === "--catalog-only") {
|
|
316
|
+
result.catalogOnly = true;
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
const value = argv[index + 1];
|
|
320
|
+
if (
|
|
321
|
+
[
|
|
322
|
+
"--app-id",
|
|
323
|
+
"--s3-bucket",
|
|
324
|
+
"--s3-prefix",
|
|
325
|
+
"--release-file",
|
|
326
|
+
"--min-tutti-version",
|
|
327
|
+
"--output-dir"
|
|
328
|
+
].includes(arg)
|
|
329
|
+
) {
|
|
330
|
+
if (value === undefined || value.startsWith("--")) {
|
|
331
|
+
throw new Error(`missing value for ${arg}`);
|
|
332
|
+
}
|
|
333
|
+
const key = {
|
|
334
|
+
"--app-id": "appId",
|
|
335
|
+
"--s3-bucket": "s3Bucket",
|
|
336
|
+
"--s3-prefix": "s3Prefix",
|
|
337
|
+
"--release-file": "releasePath",
|
|
338
|
+
"--min-tutti-version": "minTuttiVersion",
|
|
339
|
+
"--output-dir": "outputDir"
|
|
340
|
+
}[arg];
|
|
341
|
+
result[key] = value;
|
|
342
|
+
index += 1;
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
throw new Error(`unexpected argument: ${arg}`);
|
|
346
|
+
}
|
|
347
|
+
return result;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export async function main() {
|
|
351
|
+
const result = await publishTuttiAppMetadata(
|
|
352
|
+
parseArgs(process.argv.slice(2))
|
|
353
|
+
);
|
|
354
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (
|
|
358
|
+
process.argv[1] &&
|
|
359
|
+
import.meta.url === pathToFileURL(process.argv[1]).href
|
|
360
|
+
) {
|
|
361
|
+
main().catch((error) => {
|
|
362
|
+
console.error(error.message);
|
|
363
|
+
process.exitCode = 1;
|
|
364
|
+
});
|
|
365
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import semver from "semver";
|
|
2
|
+
|
|
3
|
+
export function requireSemver(value, label, options = {}) {
|
|
4
|
+
const text = String(value ?? "").trim();
|
|
5
|
+
const normalized = options.allowLeadingV ? text.replace(/^v/u, "") : text;
|
|
6
|
+
if (!normalized || semver.valid(normalized) === null) {
|
|
7
|
+
throw new Error(`${label} must be valid SemVer`);
|
|
8
|
+
}
|
|
9
|
+
return normalized;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function compareSemver(left, right) {
|
|
13
|
+
return semver.compare(
|
|
14
|
+
requireSemver(left, "left version", { allowLeadingV: true }),
|
|
15
|
+
requireSemver(right, "right version", { allowLeadingV: true })
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function compareReleaseVersions(left, right) {
|
|
20
|
+
const precedence = compareSemver(left.version, right.version);
|
|
21
|
+
if (precedence !== 0) {
|
|
22
|
+
return precedence;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const publishedAt = comparePublishedAt(left.publishedAt, right.publishedAt);
|
|
26
|
+
if (publishedAt !== 0) {
|
|
27
|
+
return publishedAt;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return left.version.localeCompare(right.version);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function isTuttiVersionCompatible(minTuttiVersion, tuttiVersion) {
|
|
34
|
+
return compareSemver(minTuttiVersion, tuttiVersion) <= 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function comparePublishedAt(left, right) {
|
|
38
|
+
const leftTime = Date.parse(String(left ?? ""));
|
|
39
|
+
const rightTime = Date.parse(String(right ?? ""));
|
|
40
|
+
if (Number.isNaN(leftTime) || Number.isNaN(rightTime)) {
|
|
41
|
+
return String(left ?? "").localeCompare(String(right ?? ""));
|
|
42
|
+
}
|
|
43
|
+
return leftTime - rightTime;
|
|
44
|
+
}
|