@tiangong-lca/cli 0.0.28 → 0.0.30
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 +90 -2
- package/dist/src/cli.js +553 -4
- package/dist/src/cli.js.map +1 -1
- package/dist/src/lib/dataset-command.js +11 -0
- package/dist/src/lib/dataset-command.js.map +1 -1
- package/dist/src/lib/dataset-maintenance-contract.js.map +1 -1
- package/dist/src/lib/dataset-maintenance-flow-identity-approval-claim.js +175 -0
- package/dist/src/lib/dataset-maintenance-flow-identity-approval-claim.js.map +1 -0
- package/dist/src/lib/dataset-maintenance-flow-identity-capture.js +511 -0
- package/dist/src/lib/dataset-maintenance-flow-identity-capture.js.map +1 -0
- package/dist/src/lib/dataset-maintenance-flow-identity-command.js +26 -0
- package/dist/src/lib/dataset-maintenance-flow-identity-command.js.map +1 -0
- package/dist/src/lib/dataset-maintenance-flow-identity-contract.js +784 -0
- package/dist/src/lib/dataset-maintenance-flow-identity-contract.js.map +1 -0
- package/dist/src/lib/dataset-maintenance-flow-identity-execution-contract.js +1317 -0
- package/dist/src/lib/dataset-maintenance-flow-identity-execution-contract.js.map +1 -0
- package/dist/src/lib/dataset-maintenance-flow-identity-freeze.js +342 -0
- package/dist/src/lib/dataset-maintenance-flow-identity-freeze.js.map +1 -0
- package/dist/src/lib/dataset-maintenance-flow-identity-plan.js +900 -0
- package/dist/src/lib/dataset-maintenance-flow-identity-plan.js.map +1 -0
- package/dist/src/lib/dataset-maintenance-flow-identity-recovery.js +688 -0
- package/dist/src/lib/dataset-maintenance-flow-identity-recovery.js.map +1 -0
- package/dist/src/lib/dataset-maintenance-flow-identity-run.js +1369 -0
- package/dist/src/lib/dataset-maintenance-flow-identity-run.js.map +1 -0
- package/dist/src/lib/dataset-maintenance-flow-identity-seal.js +144 -0
- package/dist/src/lib/dataset-maintenance-flow-identity-seal.js.map +1 -0
- package/dist/src/lib/dataset-maintenance-flow-identity-verify.js +377 -0
- package/dist/src/lib/dataset-maintenance-flow-identity-verify.js.map +1 -0
- package/dist/src/lib/dataset-maintenance-flow-identity-wire.js +178 -0
- package/dist/src/lib/dataset-maintenance-flow-identity-wire.js.map +1 -0
- package/dist/src/lib/dataset-maintenance-remote.js +156 -10
- package/dist/src/lib/dataset-maintenance-remote.js.map +1 -1
- package/dist/src/lib/dataset-save-draft-run.js +667 -0
- package/dist/src/lib/dataset-save-draft-run.js.map +1 -1
- package/dist/src/lib/http.js.map +1 -1
- package/dist/src/lib/lca-release.js +683 -0
- package/dist/src/lib/lca-release.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,683 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { createClient } from '@supabase/supabase-js';
|
|
5
|
+
import { CliError } from './errors.js';
|
|
6
|
+
import { readJsonInput } from './io.js';
|
|
7
|
+
import { buildSupabaseAuthHeaders, createSupabaseFetch, deriveSupabaseFunctionsBaseUrl, requireSupabaseRestRuntime, } from './supabase-client.js';
|
|
8
|
+
import { resolveSupabaseUserSession } from './supabase-session.js';
|
|
9
|
+
import { fingerprintUserApiKey } from './user-api-key.js';
|
|
10
|
+
export const LCA_RELEASE_ACTIONS = [
|
|
11
|
+
'prepare',
|
|
12
|
+
'upload',
|
|
13
|
+
'finalize',
|
|
14
|
+
'approve',
|
|
15
|
+
'publish',
|
|
16
|
+
'readback-verify',
|
|
17
|
+
'unpublish',
|
|
18
|
+
'status',
|
|
19
|
+
'current',
|
|
20
|
+
'calculation-bundle',
|
|
21
|
+
'calculation-artifact',
|
|
22
|
+
'artifact-download',
|
|
23
|
+
];
|
|
24
|
+
const COMMAND_ENDPOINT = 'app_lca_release_commands';
|
|
25
|
+
const SHA256_PATTERN = /^[0-9a-f]{64}$/u;
|
|
26
|
+
const REQUIRED_UPLOAD_PAIRS = [
|
|
27
|
+
'unit-process-full-closure.v1:tidas',
|
|
28
|
+
'unit-process-full-closure.v1:ilcd',
|
|
29
|
+
'standalone-lifecyclemodel-result-full-closure.v1:tidas',
|
|
30
|
+
'standalone-lifecyclemodel-result-full-closure.v1:ilcd',
|
|
31
|
+
];
|
|
32
|
+
function isRecord(value) {
|
|
33
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
34
|
+
}
|
|
35
|
+
function requiredString(value, field) {
|
|
36
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
37
|
+
throw new CliError(`Missing required ${field}.`, {
|
|
38
|
+
code: 'LCA_RELEASE_FIELD_REQUIRED',
|
|
39
|
+
exitCode: 2,
|
|
40
|
+
details: { field },
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
return value.trim();
|
|
44
|
+
}
|
|
45
|
+
function requiredId(value, flag) {
|
|
46
|
+
if (!value) {
|
|
47
|
+
throw new CliError(`Missing required ${flag} value.`, {
|
|
48
|
+
code: 'LCA_RELEASE_ID_REQUIRED',
|
|
49
|
+
exitCode: 2,
|
|
50
|
+
details: { flag },
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
function requiredPath(value, flag) {
|
|
56
|
+
if (!value) {
|
|
57
|
+
throw new CliError(`Missing required ${flag} value.`, {
|
|
58
|
+
code: 'LCA_RELEASE_PATH_REQUIRED',
|
|
59
|
+
exitCode: 2,
|
|
60
|
+
details: { flag },
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return path.resolve(value);
|
|
64
|
+
}
|
|
65
|
+
function readObjectInput(inputPath) {
|
|
66
|
+
const resolved = requiredPath(inputPath, '--input');
|
|
67
|
+
const value = readJsonInput(resolved);
|
|
68
|
+
if (!isRecord(value)) {
|
|
69
|
+
throw new CliError(`LCA release input must be a JSON object: ${resolved}`, {
|
|
70
|
+
code: 'LCA_RELEASE_INPUT_OBJECT_REQUIRED',
|
|
71
|
+
exitCode: 2,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
return { inputPath: resolved, value };
|
|
75
|
+
}
|
|
76
|
+
function sha256Bytes(bytes) {
|
|
77
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
78
|
+
}
|
|
79
|
+
function outputRef(outputPath, bytes, mediaType) {
|
|
80
|
+
return {
|
|
81
|
+
path: outputPath,
|
|
82
|
+
sha256: sha256Bytes(bytes),
|
|
83
|
+
byteSize: bytes.byteLength,
|
|
84
|
+
mediaType,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
function writeOutput(outputPath, bytes, mediaType, force) {
|
|
88
|
+
if (existsSync(outputPath) && !force) {
|
|
89
|
+
throw new CliError(`Output already exists: ${outputPath}. Use --force to replace it.`, {
|
|
90
|
+
code: 'LCA_RELEASE_OUTPUT_EXISTS',
|
|
91
|
+
exitCode: 2,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
const tempPath = `${outputPath}.${process.pid}.${Date.now()}.tmp`;
|
|
95
|
+
try {
|
|
96
|
+
mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
97
|
+
writeFileSync(tempPath, bytes, { mode: 0o600 });
|
|
98
|
+
renameSync(tempPath, outputPath);
|
|
99
|
+
chmodSync(outputPath, 0o600);
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
try {
|
|
103
|
+
rmSync(tempPath, { force: true });
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
// Preserve the actionable write failure when the temporary path itself is unreachable.
|
|
107
|
+
}
|
|
108
|
+
throw new CliError(`Failed to write LCA release output: ${outputPath}`, {
|
|
109
|
+
code: 'LCA_RELEASE_OUTPUT_WRITE_FAILED',
|
|
110
|
+
exitCode: 1,
|
|
111
|
+
details: String(error),
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
return outputRef(outputPath, bytes, mediaType);
|
|
115
|
+
}
|
|
116
|
+
function writeJsonOutput(outputPath, value, force) {
|
|
117
|
+
const bytes = Buffer.from(`${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
|
118
|
+
return writeOutput(outputPath, bytes, 'application/json', force);
|
|
119
|
+
}
|
|
120
|
+
function commandAction(action) {
|
|
121
|
+
const mapping = {
|
|
122
|
+
prepare: 'prepare',
|
|
123
|
+
finalize: 'finalize_artifacts',
|
|
124
|
+
approve: 'approve',
|
|
125
|
+
publish: 'publish',
|
|
126
|
+
'readback-verify': 'readback_verify',
|
|
127
|
+
unpublish: 'unpublish',
|
|
128
|
+
status: 'get_release',
|
|
129
|
+
current: 'get_current',
|
|
130
|
+
'calculation-bundle': 'get_calculation_bundle',
|
|
131
|
+
'calculation-artifact': 'get_calculation_bundle',
|
|
132
|
+
'artifact-download': 'create_artifact_download',
|
|
133
|
+
};
|
|
134
|
+
const result = mapping[action];
|
|
135
|
+
if (!result) {
|
|
136
|
+
throw new CliError(`Action ${action} does not map to a direct release command.`, {
|
|
137
|
+
code: 'LCA_RELEASE_ACTION_NOT_DIRECT',
|
|
138
|
+
exitCode: 2,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
return result;
|
|
142
|
+
}
|
|
143
|
+
function buildCommandPayload(options, userApiKey) {
|
|
144
|
+
if (options.action === 'prepare' ||
|
|
145
|
+
options.action === 'finalize' ||
|
|
146
|
+
options.action === 'approve' ||
|
|
147
|
+
options.action === 'publish' ||
|
|
148
|
+
options.action === 'readback-verify' ||
|
|
149
|
+
options.action === 'unpublish') {
|
|
150
|
+
const input = readObjectInput(options.inputPath);
|
|
151
|
+
const body = { ...input.value, action: commandAction(options.action) };
|
|
152
|
+
if (options.action === 'publish') {
|
|
153
|
+
body.credentialFingerprint = fingerprintUserApiKey(userApiKey).replace(/^sha256:/u, '');
|
|
154
|
+
}
|
|
155
|
+
return { body, inputPath: input.inputPath };
|
|
156
|
+
}
|
|
157
|
+
if (options.action === 'status') {
|
|
158
|
+
return {
|
|
159
|
+
body: {
|
|
160
|
+
action: commandAction(options.action),
|
|
161
|
+
releaseRunId: requiredId(options.releaseRunId, '--release-run-id'),
|
|
162
|
+
},
|
|
163
|
+
inputPath: null,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
if (options.action === 'current') {
|
|
167
|
+
return { body: { action: commandAction(options.action) }, inputPath: null };
|
|
168
|
+
}
|
|
169
|
+
if (options.action === 'calculation-bundle' || options.action === 'calculation-artifact') {
|
|
170
|
+
return {
|
|
171
|
+
body: {
|
|
172
|
+
action: commandAction(options.action),
|
|
173
|
+
packageId: requiredId(options.packageId, '--package-id'),
|
|
174
|
+
},
|
|
175
|
+
inputPath: null,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
return {
|
|
179
|
+
body: {
|
|
180
|
+
action: commandAction(options.action),
|
|
181
|
+
artifactId: requiredId(options.artifactId, '--artifact-id'),
|
|
182
|
+
},
|
|
183
|
+
inputPath: null,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
function edgeUrl(apiBaseUrl) {
|
|
187
|
+
return `${deriveSupabaseFunctionsBaseUrl(apiBaseUrl)}/${COMMAND_ENDPOINT}`;
|
|
188
|
+
}
|
|
189
|
+
async function readJsonResponse(response, url) {
|
|
190
|
+
const text = await response.text();
|
|
191
|
+
let payload;
|
|
192
|
+
try {
|
|
193
|
+
payload = JSON.parse(text);
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
throw new CliError(`Remote response was not valid JSON for ${url}`, {
|
|
197
|
+
code: 'LCA_RELEASE_REMOTE_INVALID_JSON',
|
|
198
|
+
exitCode: 1,
|
|
199
|
+
details: String(error),
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
if (!isRecord(payload)) {
|
|
203
|
+
throw new CliError(`Remote response was not a JSON object for ${url}`, {
|
|
204
|
+
code: 'LCA_RELEASE_REMOTE_OBJECT_REQUIRED',
|
|
205
|
+
exitCode: 1,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
if (!response.ok || payload.ok === false) {
|
|
209
|
+
const code = typeof payload.code === 'string' ? payload.code : 'LCA_RELEASE_REMOTE_FAILED';
|
|
210
|
+
const message = typeof payload.message === 'string'
|
|
211
|
+
? payload.message
|
|
212
|
+
: `HTTP ${response.status} returned from ${url}`;
|
|
213
|
+
throw new CliError(message, {
|
|
214
|
+
code,
|
|
215
|
+
exitCode: response.status === 401 || response.status === 403 ? 3 : 1,
|
|
216
|
+
details: payload.details ?? payload,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
if (payload.ok !== true || !('data' in payload)) {
|
|
220
|
+
throw new CliError(`Remote release response was missing ok:true and data for ${url}`, {
|
|
221
|
+
code: 'LCA_RELEASE_REMOTE_ENVELOPE_INVALID',
|
|
222
|
+
exitCode: 1,
|
|
223
|
+
details: payload,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
return payload;
|
|
227
|
+
}
|
|
228
|
+
async function invokeCommand(options) {
|
|
229
|
+
const response = await options.fetchImpl(options.url, {
|
|
230
|
+
method: 'POST',
|
|
231
|
+
headers: {
|
|
232
|
+
...buildSupabaseAuthHeaders(options.publishableKey, options.accessToken),
|
|
233
|
+
'Content-Type': 'application/json',
|
|
234
|
+
},
|
|
235
|
+
body: JSON.stringify(options.body),
|
|
236
|
+
signal: AbortSignal.timeout(options.timeoutMs),
|
|
237
|
+
});
|
|
238
|
+
return readJsonResponse(response, options.url);
|
|
239
|
+
}
|
|
240
|
+
function parseLocalUploadArtifacts(value, inputPath) {
|
|
241
|
+
if (!Array.isArray(value.artifacts)) {
|
|
242
|
+
throw new CliError('Release upload input must contain artifacts[].', {
|
|
243
|
+
code: 'LCA_RELEASE_UPLOAD_ARTIFACTS_REQUIRED',
|
|
244
|
+
exitCode: 2,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
const baseDir = path.dirname(inputPath);
|
|
248
|
+
const artifacts = value.artifacts.map((candidate, index) => {
|
|
249
|
+
if (!isRecord(candidate)) {
|
|
250
|
+
throw new CliError(`Release upload artifact ${index} must be a JSON object.`, {
|
|
251
|
+
code: 'LCA_RELEASE_UPLOAD_ARTIFACT_INVALID',
|
|
252
|
+
exitCode: 2,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
const profileId = requiredString(candidate.profileId, `artifacts[${index}].profileId`);
|
|
256
|
+
const format = requiredString(candidate.format, `artifacts[${index}].format`);
|
|
257
|
+
const sha256 = requiredString(candidate.sha256, `artifacts[${index}].sha256`);
|
|
258
|
+
const mediaType = requiredString(candidate.mediaType, `artifacts[${index}].mediaType`);
|
|
259
|
+
const localPath = requiredString(candidate.path, `artifacts[${index}].path`);
|
|
260
|
+
if (!SHA256_PATTERN.test(sha256)) {
|
|
261
|
+
throw new CliError(`Release upload artifact ${index} has an invalid SHA-256.`, {
|
|
262
|
+
code: 'LCA_RELEASE_UPLOAD_HASH_INVALID',
|
|
263
|
+
exitCode: 2,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
if (!Number.isSafeInteger(candidate.byteSize) || Number(candidate.byteSize) <= 0) {
|
|
267
|
+
throw new CliError(`Release upload artifact ${index} has an invalid byteSize.`, {
|
|
268
|
+
code: 'LCA_RELEASE_UPLOAD_SIZE_INVALID',
|
|
269
|
+
exitCode: 2,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
if (mediaType !== 'application/zip') {
|
|
273
|
+
throw new CliError(`Release upload artifact ${index} must use application/zip.`, {
|
|
274
|
+
code: 'LCA_RELEASE_UPLOAD_MEDIA_TYPE_INVALID',
|
|
275
|
+
exitCode: 2,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
const filePath = path.isAbsolute(localPath) ? localPath : path.resolve(baseDir, localPath);
|
|
279
|
+
if (!existsSync(filePath) || !statSync(filePath).isFile()) {
|
|
280
|
+
throw new CliError(`Release upload artifact file not found: ${filePath}`, {
|
|
281
|
+
code: 'LCA_RELEASE_UPLOAD_FILE_NOT_FOUND',
|
|
282
|
+
exitCode: 2,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
const bytes = readFileSync(filePath);
|
|
286
|
+
if (bytes.byteLength !== Number(candidate.byteSize)) {
|
|
287
|
+
throw new CliError(`Release upload artifact byte size mismatch: ${filePath}`, {
|
|
288
|
+
code: 'LCA_RELEASE_UPLOAD_SIZE_MISMATCH',
|
|
289
|
+
exitCode: 2,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
if (sha256Bytes(bytes) !== sha256) {
|
|
293
|
+
throw new CliError(`Release upload artifact SHA-256 mismatch: ${filePath}`, {
|
|
294
|
+
code: 'LCA_RELEASE_UPLOAD_HASH_MISMATCH',
|
|
295
|
+
exitCode: 2,
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
return {
|
|
299
|
+
profileId,
|
|
300
|
+
format,
|
|
301
|
+
sha256,
|
|
302
|
+
byteSize: bytes.byteLength,
|
|
303
|
+
mediaType,
|
|
304
|
+
filePath,
|
|
305
|
+
};
|
|
306
|
+
});
|
|
307
|
+
const pairs = artifacts.map((artifact) => `${artifact.profileId}:${artifact.format}`);
|
|
308
|
+
if (artifacts.length !== REQUIRED_UPLOAD_PAIRS.length ||
|
|
309
|
+
new Set(pairs).size !== REQUIRED_UPLOAD_PAIRS.length ||
|
|
310
|
+
REQUIRED_UPLOAD_PAIRS.some((pair) => !pairs.includes(pair))) {
|
|
311
|
+
throw new CliError('Release upload requires each TIDAS/ILCD profile pair exactly once.', {
|
|
312
|
+
code: 'LCA_RELEASE_UPLOAD_SET_INVALID',
|
|
313
|
+
exitCode: 2,
|
|
314
|
+
details: { expected: REQUIRED_UPLOAD_PAIRS, actual: pairs },
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
return artifacts.sort((left, right) => REQUIRED_UPLOAD_PAIRS.indexOf(`${left.profileId}:${left.format}`) -
|
|
318
|
+
REQUIRED_UPLOAD_PAIRS.indexOf(`${right.profileId}:${right.format}`));
|
|
319
|
+
}
|
|
320
|
+
function parseUploadResponse(payload, local) {
|
|
321
|
+
const responseArtifacts = payload.data;
|
|
322
|
+
if (!Array.isArray(responseArtifacts) || responseArtifacts.length !== local.length) {
|
|
323
|
+
throw new CliError('Release upload URL response did not contain all four artifacts.', {
|
|
324
|
+
code: 'LCA_RELEASE_UPLOAD_URLS_INVALID',
|
|
325
|
+
exitCode: 1,
|
|
326
|
+
details: payload,
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
return local.map((artifact) => {
|
|
330
|
+
const match = responseArtifacts.find((candidate) => isRecord(candidate) &&
|
|
331
|
+
candidate.profileId === artifact.profileId &&
|
|
332
|
+
candidate.format === artifact.format);
|
|
333
|
+
if (!isRecord(match)) {
|
|
334
|
+
throw new CliError(`Release upload URL is missing ${artifact.profileId}:${artifact.format}.`, {
|
|
335
|
+
code: 'LCA_RELEASE_UPLOAD_URL_MISSING',
|
|
336
|
+
exitCode: 1,
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
if (match.sha256 !== artifact.sha256 ||
|
|
340
|
+
match.byteSize !== artifact.byteSize ||
|
|
341
|
+
match.mediaType !== artifact.mediaType) {
|
|
342
|
+
throw new CliError(`Release upload URL metadata drifted for ${artifact.filePath}.`, {
|
|
343
|
+
code: 'LCA_RELEASE_UPLOAD_URL_METADATA_MISMATCH',
|
|
344
|
+
exitCode: 1,
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
return {
|
|
348
|
+
local: artifact,
|
|
349
|
+
storageBucket: requiredString(match.storageBucket, 'storageBucket'),
|
|
350
|
+
objectKey: requiredString(match.objectKey, 'objectKey'),
|
|
351
|
+
token: requiredString(match.token, 'token'),
|
|
352
|
+
};
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
async function runUpload(options, runtime, url) {
|
|
356
|
+
const input = readObjectInput(options.inputPath);
|
|
357
|
+
const outputPath = requiredPath(options.outputPath, '--output');
|
|
358
|
+
const releaseRunId = requiredString(input.value.releaseRunId, 'releaseRunId');
|
|
359
|
+
const publishPlanHash = requiredString(input.value.publishPlanHash, 'publishPlanHash');
|
|
360
|
+
const artifacts = parseLocalUploadArtifacts(input.value, input.inputPath);
|
|
361
|
+
const requestBody = {
|
|
362
|
+
action: 'create_artifact_uploads',
|
|
363
|
+
releaseRunId,
|
|
364
|
+
publishPlanHash,
|
|
365
|
+
artifacts: artifacts.map((artifact) => ({
|
|
366
|
+
profileId: artifact.profileId,
|
|
367
|
+
format: artifact.format,
|
|
368
|
+
sha256: artifact.sha256,
|
|
369
|
+
byteSize: artifact.byteSize,
|
|
370
|
+
mediaType: artifact.mediaType,
|
|
371
|
+
})),
|
|
372
|
+
};
|
|
373
|
+
if (options.dryRun) {
|
|
374
|
+
return {
|
|
375
|
+
schemaVersion: 'tiangong.cli.lca-release.v1',
|
|
376
|
+
action: 'upload',
|
|
377
|
+
status: 'planned',
|
|
378
|
+
complete: false,
|
|
379
|
+
summary: { releaseRunId, artifactCount: artifacts.length, outputPath },
|
|
380
|
+
request: {
|
|
381
|
+
method: 'POST',
|
|
382
|
+
url,
|
|
383
|
+
headers: { Authorization: 'Bearer ****', apikey: '****' },
|
|
384
|
+
body: requestBody,
|
|
385
|
+
plannedUploads: artifacts.map((artifact) => artifact.filePath),
|
|
386
|
+
},
|
|
387
|
+
warnings: [],
|
|
388
|
+
nextCommands: [
|
|
389
|
+
`tiangong-lca release upload --input ${input.inputPath} --output ${outputPath}`,
|
|
390
|
+
],
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
const session = await resolveSupabaseUserSession({
|
|
394
|
+
runtime,
|
|
395
|
+
fetchImpl: options.fetchImpl,
|
|
396
|
+
timeoutMs: options.timeoutMs,
|
|
397
|
+
});
|
|
398
|
+
const signed = await invokeCommand({
|
|
399
|
+
url,
|
|
400
|
+
publishableKey: runtime.publishableKey,
|
|
401
|
+
accessToken: session.accessToken,
|
|
402
|
+
body: requestBody,
|
|
403
|
+
timeoutMs: options.timeoutMs,
|
|
404
|
+
fetchImpl: options.fetchImpl,
|
|
405
|
+
});
|
|
406
|
+
const uploads = parseUploadResponse(signed, artifacts);
|
|
407
|
+
const storage = createClient(session.projectBaseUrl, runtime.publishableKey, {
|
|
408
|
+
auth: { autoRefreshToken: false, persistSession: false, detectSessionInUrl: false },
|
|
409
|
+
global: { fetch: createSupabaseFetch(options.fetchImpl, options.timeoutMs) },
|
|
410
|
+
});
|
|
411
|
+
for (const upload of uploads) {
|
|
412
|
+
const result = await storage.storage
|
|
413
|
+
.from(upload.storageBucket)
|
|
414
|
+
.uploadToSignedUrl(upload.objectKey, upload.token, readFileSync(upload.local.filePath), {
|
|
415
|
+
contentType: upload.local.mediaType,
|
|
416
|
+
cacheControl: '31536000',
|
|
417
|
+
});
|
|
418
|
+
if (result.error) {
|
|
419
|
+
throw new CliError(`Failed to upload release artifact: ${upload.local.filePath}`, {
|
|
420
|
+
code: 'LCA_RELEASE_ARTIFACT_UPLOAD_FAILED',
|
|
421
|
+
exitCode: 1,
|
|
422
|
+
details: result.error.message,
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
const receipt = {
|
|
427
|
+
schemaVersion: 'tiangong.release-upload-receipt.v1',
|
|
428
|
+
releaseRunId,
|
|
429
|
+
publishPlanHash,
|
|
430
|
+
artifacts: uploads.map((upload) => ({
|
|
431
|
+
profileId: upload.local.profileId,
|
|
432
|
+
format: upload.local.format,
|
|
433
|
+
storageBucket: upload.storageBucket,
|
|
434
|
+
objectKey: upload.objectKey,
|
|
435
|
+
sha256: upload.local.sha256,
|
|
436
|
+
byteSize: upload.local.byteSize,
|
|
437
|
+
mediaType: upload.local.mediaType,
|
|
438
|
+
})),
|
|
439
|
+
};
|
|
440
|
+
const output = writeJsonOutput(outputPath, receipt, options.force);
|
|
441
|
+
return {
|
|
442
|
+
schemaVersion: 'tiangong.cli.lca-release.v1',
|
|
443
|
+
action: 'upload',
|
|
444
|
+
status: 'completed',
|
|
445
|
+
complete: true,
|
|
446
|
+
summary: { releaseRunId, artifactCount: uploads.length },
|
|
447
|
+
data: receipt,
|
|
448
|
+
output,
|
|
449
|
+
warnings: [],
|
|
450
|
+
nextCommands: ['tiangong-lca release finalize --input ./release-finalize.json --json'],
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
async function downloadBytes(signedUrl, timeoutMs, fetchImpl) {
|
|
454
|
+
const response = await fetchImpl(signedUrl, {
|
|
455
|
+
method: 'GET',
|
|
456
|
+
headers: { Accept: 'application/octet-stream' },
|
|
457
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
458
|
+
});
|
|
459
|
+
if (!response.ok) {
|
|
460
|
+
throw new CliError(`HTTP ${response.status} returned while downloading ${signedUrl}`, {
|
|
461
|
+
code: 'LCA_RELEASE_DOWNLOAD_FAILED',
|
|
462
|
+
exitCode: 1,
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
if (!response.arrayBuffer) {
|
|
466
|
+
throw new CliError('Download transport did not provide binary response support.', {
|
|
467
|
+
code: 'LCA_RELEASE_DOWNLOAD_BINARY_UNAVAILABLE',
|
|
468
|
+
exitCode: 1,
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
472
|
+
}
|
|
473
|
+
function selectDownload(action, data, artifactPath) {
|
|
474
|
+
const root = isRecord(data) ? data : {};
|
|
475
|
+
if (action === 'artifact-download')
|
|
476
|
+
return root;
|
|
477
|
+
const bundle = isRecord(root.calculationBundle) ? root.calculationBundle : {};
|
|
478
|
+
const artifacts = Array.isArray(bundle.artifacts) ? bundle.artifacts : [];
|
|
479
|
+
const requested = requiredId(artifactPath, '--artifact-path');
|
|
480
|
+
const selected = artifacts.find((artifact) => isRecord(artifact) && artifact.path === requested);
|
|
481
|
+
if (!isRecord(selected)) {
|
|
482
|
+
const candidates = artifacts
|
|
483
|
+
.filter(isRecord)
|
|
484
|
+
.map((artifact) => artifact.path)
|
|
485
|
+
.filter((value) => typeof value === 'string')
|
|
486
|
+
.slice(0, 20);
|
|
487
|
+
throw new CliError(`Calculation Bundle artifact not found: ${requested}`, {
|
|
488
|
+
code: 'LCA_RELEASE_CALCULATION_ARTIFACT_NOT_FOUND',
|
|
489
|
+
exitCode: 2,
|
|
490
|
+
details: { candidates, total: artifacts.length },
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
return selected;
|
|
494
|
+
}
|
|
495
|
+
async function writeDownload(options, data) {
|
|
496
|
+
const outputPath = requiredPath(options.outputPath, '--output');
|
|
497
|
+
const selected = selectDownload(options.action, data, options.artifactPath);
|
|
498
|
+
const signedUrl = requiredString(selected.signedDownloadUrl, 'signedDownloadUrl');
|
|
499
|
+
const expectedSha256 = requiredString(selected.sha256, 'sha256');
|
|
500
|
+
const expectedByteSize = selected.byteSize;
|
|
501
|
+
if (!Number.isSafeInteger(expectedByteSize) || Number(expectedByteSize) < 0) {
|
|
502
|
+
throw new CliError('Download metadata contains an invalid byteSize.', {
|
|
503
|
+
code: 'LCA_RELEASE_DOWNLOAD_SIZE_INVALID',
|
|
504
|
+
exitCode: 1,
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
const bytes = await downloadBytes(signedUrl, options.timeoutMs, options.fetchImpl);
|
|
508
|
+
if (bytes.byteLength !== Number(expectedByteSize)) {
|
|
509
|
+
throw new CliError('Downloaded artifact byte size differs from the durable reference.', {
|
|
510
|
+
code: 'LCA_RELEASE_DOWNLOAD_SIZE_MISMATCH',
|
|
511
|
+
exitCode: 1,
|
|
512
|
+
details: { expected: expectedByteSize, actual: bytes.byteLength },
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
const observedSha256 = sha256Bytes(bytes);
|
|
516
|
+
if (observedSha256 !== expectedSha256) {
|
|
517
|
+
throw new CliError('Downloaded artifact SHA-256 differs from the durable reference.', {
|
|
518
|
+
code: 'LCA_RELEASE_DOWNLOAD_HASH_MISMATCH',
|
|
519
|
+
exitCode: 1,
|
|
520
|
+
details: { expected: expectedSha256, actual: observedSha256 },
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
const mediaType = typeof selected.mediaType === 'string' ? selected.mediaType : 'application/octet-stream';
|
|
524
|
+
return {
|
|
525
|
+
output: writeOutput(outputPath, bytes, mediaType, options.force),
|
|
526
|
+
selected,
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
function nextCommands(action, data) {
|
|
530
|
+
const record = isRecord(data) ? data : {};
|
|
531
|
+
const releaseRunId = typeof record.releaseRunId === 'string' ? record.releaseRunId : '<run-id>';
|
|
532
|
+
const mapping = {
|
|
533
|
+
prepare: [
|
|
534
|
+
'tiangong-lca release upload --input ./release-upload.json --output ./upload-receipt.json',
|
|
535
|
+
],
|
|
536
|
+
upload: ['tiangong-lca release finalize --input ./release-finalize.json --json'],
|
|
537
|
+
finalize: ['tiangong-lca release approve --input ./release-approval.json --json'],
|
|
538
|
+
approve: ['tiangong-lca release publish --input ./release-publish.json --json'],
|
|
539
|
+
publish: ['tiangong-lca release readback-verify --input ./release-readback.json --json'],
|
|
540
|
+
'readback-verify': [`tiangong-lca release status --release-run-id ${releaseRunId} --json`],
|
|
541
|
+
unpublish: ['tiangong-lca release current --json'],
|
|
542
|
+
status: [],
|
|
543
|
+
current: [],
|
|
544
|
+
'calculation-bundle': [
|
|
545
|
+
'tiangong-lca release calculation-artifact --package-id <package-id> --artifact-path <path> --output ./result.jsonl.gz',
|
|
546
|
+
],
|
|
547
|
+
'calculation-artifact': [],
|
|
548
|
+
'artifact-download': [],
|
|
549
|
+
};
|
|
550
|
+
return mapping[action] ?? [];
|
|
551
|
+
}
|
|
552
|
+
function summarize(action, data) {
|
|
553
|
+
const record = isRecord(data) ? data : {};
|
|
554
|
+
if (action === 'calculation-bundle') {
|
|
555
|
+
const bundle = isRecord(record.calculationBundle) ? record.calculationBundle : {};
|
|
556
|
+
const manifest = isRecord(bundle.manifest) ? bundle.manifest : {};
|
|
557
|
+
const artifacts = Array.isArray(bundle.artifacts) ? bundle.artifacts : [];
|
|
558
|
+
const scope = isRecord(manifest.scope) ? manifest.scope : {};
|
|
559
|
+
return {
|
|
560
|
+
packageId: record.packageId ?? null,
|
|
561
|
+
calculationId: bundle.calculationId ?? null,
|
|
562
|
+
bundleContentHash: bundle.bundleContentHash ?? null,
|
|
563
|
+
processCount: scope.processCount ?? null,
|
|
564
|
+
artifactCount: artifacts.length,
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
return {
|
|
568
|
+
releaseRunId: record.releaseRunId ?? null,
|
|
569
|
+
releaseVersion: record.releaseVersion ?? null,
|
|
570
|
+
status: record.status ?? 'completed',
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
export async function runLcaRelease(options) {
|
|
574
|
+
const runtime = requireSupabaseRestRuntime(options.env);
|
|
575
|
+
const url = edgeUrl(runtime.apiBaseUrl);
|
|
576
|
+
if (options.action === 'upload')
|
|
577
|
+
return runUpload(options, runtime, url);
|
|
578
|
+
const command = buildCommandPayload(options, runtime.userApiKey);
|
|
579
|
+
if (options.dryRun) {
|
|
580
|
+
return {
|
|
581
|
+
schemaVersion: 'tiangong.cli.lca-release.v1',
|
|
582
|
+
action: options.action,
|
|
583
|
+
status: 'planned',
|
|
584
|
+
complete: false,
|
|
585
|
+
summary: { inputPath: command.inputPath },
|
|
586
|
+
request: {
|
|
587
|
+
method: 'POST',
|
|
588
|
+
url,
|
|
589
|
+
headers: { Authorization: 'Bearer ****', apikey: '****' },
|
|
590
|
+
body: command.body,
|
|
591
|
+
},
|
|
592
|
+
warnings: [],
|
|
593
|
+
nextCommands: [],
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
const session = await resolveSupabaseUserSession({
|
|
597
|
+
runtime,
|
|
598
|
+
fetchImpl: options.fetchImpl,
|
|
599
|
+
timeoutMs: options.timeoutMs,
|
|
600
|
+
});
|
|
601
|
+
const envelope = await invokeCommand({
|
|
602
|
+
url,
|
|
603
|
+
publishableKey: runtime.publishableKey,
|
|
604
|
+
accessToken: session.accessToken,
|
|
605
|
+
body: command.body,
|
|
606
|
+
timeoutMs: options.timeoutMs,
|
|
607
|
+
fetchImpl: options.fetchImpl,
|
|
608
|
+
});
|
|
609
|
+
const data = envelope.data;
|
|
610
|
+
if (options.action === 'artifact-download' || options.action === 'calculation-artifact') {
|
|
611
|
+
const downloaded = await writeDownload(options, data);
|
|
612
|
+
return {
|
|
613
|
+
schemaVersion: 'tiangong.cli.lca-release.v1',
|
|
614
|
+
action: options.action,
|
|
615
|
+
status: 'completed',
|
|
616
|
+
complete: true,
|
|
617
|
+
summary: {
|
|
618
|
+
artifactId: downloaded.selected.artifactId ?? null,
|
|
619
|
+
artifactPath: downloaded.selected.path ?? null,
|
|
620
|
+
},
|
|
621
|
+
output: downloaded.output,
|
|
622
|
+
warnings: [],
|
|
623
|
+
nextCommands: [],
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
let output;
|
|
627
|
+
if (options.action === 'calculation-bundle') {
|
|
628
|
+
output = writeJsonOutput(requiredPath(options.outputPath, '--output'), data, options.force);
|
|
629
|
+
}
|
|
630
|
+
else if (options.outputPath) {
|
|
631
|
+
output = writeJsonOutput(path.resolve(options.outputPath), data, options.force);
|
|
632
|
+
}
|
|
633
|
+
return {
|
|
634
|
+
schemaVersion: 'tiangong.cli.lca-release.v1',
|
|
635
|
+
action: options.action,
|
|
636
|
+
status: 'completed',
|
|
637
|
+
complete: true,
|
|
638
|
+
summary: summarize(options.action, data),
|
|
639
|
+
...(output ? { output } : { data }),
|
|
640
|
+
warnings: [],
|
|
641
|
+
nextCommands: nextCommands(options.action, data),
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
export function renderLcaReleaseReport(report) {
|
|
645
|
+
const lines = [
|
|
646
|
+
`LCA release ${report.action}: ${report.status}`,
|
|
647
|
+
'',
|
|
648
|
+
'Summary:',
|
|
649
|
+
...Object.entries(report.summary).map(([key, value]) => `- ${key}: ${typeof value === 'string' ? value : JSON.stringify(value)}`),
|
|
650
|
+
`- complete: ${report.complete}`,
|
|
651
|
+
];
|
|
652
|
+
if (report.output)
|
|
653
|
+
lines.push(`- output: ${report.output.path} (${report.output.sha256})`);
|
|
654
|
+
lines.push('', 'Next:');
|
|
655
|
+
if (report.nextCommands.length === 0)
|
|
656
|
+
lines.push('- none');
|
|
657
|
+
for (const command of report.nextCommands)
|
|
658
|
+
lines.push(`- ${command}`);
|
|
659
|
+
return `${lines.join('\n')}\n`;
|
|
660
|
+
}
|
|
661
|
+
export const __testInternals = {
|
|
662
|
+
buildCommandPayload,
|
|
663
|
+
commandAction,
|
|
664
|
+
downloadBytes,
|
|
665
|
+
edgeUrl,
|
|
666
|
+
invokeCommand,
|
|
667
|
+
isRecord,
|
|
668
|
+
nextCommands,
|
|
669
|
+
outputRef,
|
|
670
|
+
parseLocalUploadArtifacts,
|
|
671
|
+
parseUploadResponse,
|
|
672
|
+
readJsonResponse,
|
|
673
|
+
requiredId,
|
|
674
|
+
requiredPath,
|
|
675
|
+
requiredString,
|
|
676
|
+
runUpload,
|
|
677
|
+
selectDownload,
|
|
678
|
+
sha256Bytes,
|
|
679
|
+
summarize,
|
|
680
|
+
writeJsonOutput,
|
|
681
|
+
writeOutput,
|
|
682
|
+
};
|
|
683
|
+
//# sourceMappingURL=lca-release.js.map
|