@chalksurf/cli 0.1.0 → 0.2.1

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.
@@ -1,6 +1,8 @@
1
1
  import { readFile, stat } from 'node:fs/promises';
2
2
  import { resolve } from 'node:path';
3
+ import { z } from 'zod';
3
4
  import { CliCommandError } from './cli-error.js';
5
+ import { translationLanguages } from './translation-languages.js';
4
6
  const isObject = (value) => {
5
7
  return typeof value === 'object' && value !== null;
6
8
  };
@@ -11,6 +13,88 @@ const normalizeOptionalString = (value) => {
11
13
  const normalizedValue = value.trim();
12
14
  return normalizedValue.length > 0 ? normalizedValue : undefined;
13
15
  };
16
+ const translationLanguageListSchema = z
17
+ .array(z.enum(translationLanguages))
18
+ .min(1, { message: 'translateTo must include at least one language.' })
19
+ .refine((languages) => new Set(languages).size === languages.length, {
20
+ message: 'translateTo languages must be unique.',
21
+ });
22
+ const parseTranslateTo = ({ value, label }) => {
23
+ if (value == null) {
24
+ return undefined;
25
+ }
26
+ const parsedLanguages = translationLanguageListSchema.safeParse(value);
27
+ if (!parsedLanguages.success) {
28
+ const issue = parsedLanguages.error.issues[0];
29
+ throw new CliCommandError(`${label} has invalid translateTo: ${issue?.message ?? 'Invalid languages.'}`, 2);
30
+ }
31
+ return parsedLanguages.data;
32
+ };
33
+ const parseManifestObject = (manifestText) => {
34
+ let parsedManifest;
35
+ try {
36
+ parsedManifest = JSON.parse(manifestText);
37
+ }
38
+ catch {
39
+ throw new CliCommandError('Manifest must be valid JSON.', 2);
40
+ }
41
+ if (!isObject(parsedManifest)) {
42
+ throw new CliCommandError('Manifest must be a JSON object.', 2);
43
+ }
44
+ return parsedManifest;
45
+ };
46
+ const parseSourceManifestObject = (manifestText) => {
47
+ const parsedManifest = parseManifestObject(manifestText);
48
+ if (!Array.isArray(parsedManifest.sources) || parsedManifest.sources.length === 0) {
49
+ throw new CliCommandError('Manifest must include a non-empty sources array.', 2);
50
+ }
51
+ return parsedManifest;
52
+ };
53
+ const isValidTargetFolderPath = (value) => {
54
+ const normalizedSegments = value
55
+ .replaceAll('\\', '/')
56
+ .split('/')
57
+ .filter((segment) => segment.length > 0);
58
+ return normalizedSegments.length > 0 && normalizedSegments.every((segment) => segment !== '.' && segment !== '..');
59
+ };
60
+ const parseTargetFolderPath = ({ value, label }) => {
61
+ if (value === null) {
62
+ return null;
63
+ }
64
+ const normalizedValue = normalizeOptionalString(value);
65
+ if (!normalizedValue) {
66
+ throw new CliCommandError(`${label} must include targetFolderPath as a string or null.`, 2);
67
+ }
68
+ if (!isValidTargetFolderPath(normalizedValue)) {
69
+ throw new CliCommandError(`${label} targetFolderPath must be a normalized folder path without "." or ".." segments.`, 2);
70
+ }
71
+ return normalizedValue;
72
+ };
73
+ const parseManifestMetadata = ({ parsedManifest, topLevelFields, }) => {
74
+ const wait = typeof parsedManifest.wait === 'boolean' ? parsedManifest.wait : undefined;
75
+ return {
76
+ organizationId: normalizeOptionalString(parsedManifest.organizationId),
77
+ wait,
78
+ ...(topLevelFields.includes('exerciseId')
79
+ ? { exerciseId: normalizeOptionalString(parsedManifest.exerciseId) }
80
+ : {}),
81
+ ...(topLevelFields.includes('exerciseSheetId')
82
+ ? { exerciseSheetId: normalizeOptionalString(parsedManifest.exerciseSheetId) }
83
+ : {}),
84
+ };
85
+ };
86
+ const assertUniqueSourceIds = (sources) => {
87
+ const seenSourceIds = new Set();
88
+ for (const source of sources) {
89
+ if (!source.sourceId) {
90
+ continue;
91
+ }
92
+ if (seenSourceIds.has(source.sourceId)) {
93
+ throw new CliCommandError(`Manifest sourceId "${source.sourceId}" must be unique.`, 2);
94
+ }
95
+ seenSourceIds.add(source.sourceId);
96
+ }
97
+ };
14
98
  const isInteractiveStdin = (stdin) => {
15
99
  return stdin.isTTY === true;
16
100
  };
@@ -21,11 +105,15 @@ const readTextFromStdin = async (stdin) => {
21
105
  }
22
106
  return text;
23
107
  };
24
- const parseManifestSource = (value, index) => {
108
+ const parseBaseManifestSource = (value, index) => {
25
109
  if (!isObject(value)) {
26
110
  throw new CliCommandError(`Manifest source at index ${index} must be an object.`, 2);
27
111
  }
28
112
  const kind = normalizeOptionalString(value.kind);
113
+ const sourceId = normalizeOptionalString(value.sourceId);
114
+ if ('title' in value || 'translateTo' in value) {
115
+ throw new CliCommandError(`Manifest source at index ${index} cannot include title or translateTo for this command.`, 2);
116
+ }
29
117
  if (kind === 'local') {
30
118
  const path = normalizeOptionalString(value.path);
31
119
  if (!path) {
@@ -35,6 +123,7 @@ const parseManifestSource = (value, index) => {
35
123
  kind: 'local',
36
124
  path,
37
125
  relativePath: normalizeOptionalString(value.relativePath),
126
+ sourceId,
38
127
  };
39
128
  }
40
129
  if (kind === 'directory') {
@@ -46,6 +135,7 @@ const parseManifestSource = (value, index) => {
46
135
  kind: 'directory',
47
136
  path,
48
137
  relativeRoot: normalizeOptionalString(value.relativeRoot),
138
+ sourceId,
49
139
  };
50
140
  }
51
141
  if (kind === 'url') {
@@ -57,35 +147,85 @@ const parseManifestSource = (value, index) => {
57
147
  kind: 'url',
58
148
  url,
59
149
  relativePath: normalizeOptionalString(value.relativePath),
150
+ sourceId,
60
151
  };
61
152
  }
62
153
  throw new CliCommandError(`Manifest source at index ${index} has unsupported kind "${String(value.kind)}".`, 2);
63
154
  };
64
- const parseManifest = (manifestText) => {
65
- let parsedManifest;
66
- try {
67
- parsedManifest = JSON.parse(manifestText);
68
- }
69
- catch {
70
- throw new CliCommandError('Manifest must be valid JSON.', 2);
71
- }
72
- if (!isObject(parsedManifest)) {
73
- throw new CliCommandError('Manifest must be a JSON object.', 2);
155
+ const parseExerciseImportManifest = (manifestText) => {
156
+ const parsedManifest = parseSourceManifestObject(manifestText);
157
+ const sources = parsedManifest.sources.map((source, index) => parseBaseManifestSource(source, index));
158
+ assertUniqueSourceIds(sources);
159
+ return {
160
+ ...parseManifestMetadata({
161
+ parsedManifest,
162
+ topLevelFields: ['exerciseSheetId'],
163
+ }),
164
+ sources,
165
+ };
166
+ };
167
+ const parseExerciseSolutionImportManifest = (manifestText) => {
168
+ const parsedManifest = parseSourceManifestObject(manifestText);
169
+ const sources = parsedManifest.sources.map((source, index) => parseBaseManifestSource(source, index));
170
+ assertUniqueSourceIds(sources);
171
+ return {
172
+ ...parseManifestMetadata({
173
+ parsedManifest,
174
+ topLevelFields: ['exerciseId'],
175
+ }),
176
+ sources,
177
+ };
178
+ };
179
+ const parseSheetImportManifest = (manifestText) => {
180
+ const parsedManifest = parseManifestObject(manifestText);
181
+ if ('sources' in parsedManifest) {
182
+ throw new CliCommandError('Sheet import manifests must use top-level sheets[]. Top-level sources[] is no longer supported.', 2);
74
183
  }
75
- if (!Array.isArray(parsedManifest.sources) || parsedManifest.sources.length === 0) {
76
- throw new CliCommandError('Manifest must include a non-empty sources array.', 2);
184
+ if (!Array.isArray(parsedManifest.sheets) || parsedManifest.sheets.length === 0) {
185
+ throw new CliCommandError('Manifest must include a non-empty sheets array.', 2);
77
186
  }
78
- const wait = typeof parsedManifest.wait === 'boolean' ? parsedManifest.wait : undefined;
187
+ const sources = [];
188
+ const sheetGroups = [];
189
+ parsedManifest.sheets.forEach((sheetValue, sheetIndex) => {
190
+ if (!isObject(sheetValue)) {
191
+ throw new CliCommandError(`Manifest sheet at index ${sheetIndex} must be an object.`, 2);
192
+ }
193
+ if (!Array.isArray(sheetValue.sources) || sheetValue.sources.length === 0) {
194
+ throw new CliCommandError(`Manifest sheet at index ${sheetIndex} must include a non-empty sources array.`, 2);
195
+ }
196
+ const sourceInputIndexes = sheetValue.sources.map((sourceValue) => {
197
+ const flattenedSourceIndex = sources.length;
198
+ const parsedSource = parseBaseManifestSource(sourceValue, flattenedSourceIndex);
199
+ sources.push(parsedSource);
200
+ return flattenedSourceIndex;
201
+ });
202
+ sheetGroups.push({
203
+ sourceInputIndexes,
204
+ targetFolderPath: parseTargetFolderPath({
205
+ value: sheetValue.targetFolderPath,
206
+ label: `Manifest sheet at index ${sheetIndex}`,
207
+ }),
208
+ title: normalizeOptionalString(sheetValue.title),
209
+ translateTo: parseTranslateTo({
210
+ value: sheetValue.translateTo,
211
+ label: `Manifest sheet at index ${sheetIndex}`,
212
+ }),
213
+ });
214
+ });
215
+ assertUniqueSourceIds(sources);
79
216
  return {
80
- organizationId: normalizeOptionalString(parsedManifest.organizationId),
81
- wait,
82
- sources: parsedManifest.sources.map((source, index) => parseManifestSource(source, index)),
217
+ ...parseManifestMetadata({
218
+ parsedManifest,
219
+ topLevelFields: [],
220
+ }),
221
+ sources,
222
+ sheetGroups,
83
223
  };
84
224
  };
85
- export const loadSheetImportManifest = async ({ cwd, manifestPath, stdin, }) => {
225
+ const loadManifest = async ({ cwd, manifestPath, parseManifest, stdin, }) => {
86
226
  if (manifestPath === '-') {
87
227
  if (isInteractiveStdin(stdin)) {
88
- throw new CliCommandError('No manifest was piped on stdin. Pipe JSON into "chalksurf sheet import --manifest -".', 2);
228
+ throw new CliCommandError('No manifest was piped on stdin. Pipe JSON into "--manifest -".', 2);
89
229
  }
90
230
  return parseManifest(await readTextFromStdin(stdin));
91
231
  }
@@ -105,3 +245,27 @@ export const loadSheetImportManifest = async ({ cwd, manifestPath, stdin, }) =>
105
245
  }
106
246
  return parseManifest(await readFile(resolvedManifestPath, 'utf8'));
107
247
  };
248
+ export const loadExerciseImportManifest = async ({ cwd, manifestPath, stdin, }) => {
249
+ return await loadManifest({
250
+ cwd,
251
+ manifestPath,
252
+ parseManifest: parseExerciseImportManifest,
253
+ stdin,
254
+ });
255
+ };
256
+ export const loadExerciseSolutionImportManifest = async ({ cwd, manifestPath, stdin, }) => {
257
+ return await loadManifest({
258
+ cwd,
259
+ manifestPath,
260
+ parseManifest: parseExerciseSolutionImportManifest,
261
+ stdin,
262
+ });
263
+ };
264
+ export const loadSheetImportManifest = async ({ cwd, manifestPath, stdin, }) => {
265
+ return await loadManifest({
266
+ cwd,
267
+ manifestPath,
268
+ parseManifest: parseSheetImportManifest,
269
+ stdin,
270
+ });
271
+ };
@@ -1,3 +1,4 @@
1
+ const cliJsonSchemaVersion = 'v1';
1
2
  const ensureTrailingNewline = (value) => {
2
3
  return value.endsWith('\n') ? value : `${value}\n`;
3
4
  };
@@ -5,16 +6,48 @@ const writeLine = (writer, value) => {
5
6
  writer.write(ensureTrailingNewline(value));
6
7
  };
7
8
  export const createOutput = ({ json, stdout, stderr, }) => {
9
+ let warnings = [];
10
+ const consumeWarnings = () => {
11
+ const nextWarnings = warnings;
12
+ warnings = [];
13
+ return nextWarnings;
14
+ };
8
15
  return {
9
- print: (value, humanFormatter) => {
16
+ json,
17
+ print: (value, humanFormatter, options) => {
10
18
  if (json) {
11
- writeLine(stdout, JSON.stringify(value, null, 2));
19
+ writeLine(stdout, JSON.stringify({
20
+ schemaVersion: cliJsonSchemaVersion,
21
+ command: options.command,
22
+ ok: options.ok ?? true,
23
+ result: value,
24
+ ...(options.error ? { error: options.error } : {}),
25
+ warnings: consumeWarnings(),
26
+ }, null, 2));
12
27
  return;
13
28
  }
14
29
  const message = typeof humanFormatter === 'function' ? humanFormatter(value) : humanFormatter;
15
30
  writeLine(stdout, message);
16
31
  },
32
+ printError: ({ command, error, result }) => {
33
+ if (json) {
34
+ writeLine(stdout, JSON.stringify({
35
+ schemaVersion: cliJsonSchemaVersion,
36
+ command,
37
+ ok: false,
38
+ ...(result === undefined ? {} : { result }),
39
+ error,
40
+ warnings: consumeWarnings(),
41
+ }, null, 2));
42
+ return;
43
+ }
44
+ writeLine(stderr, error.message);
45
+ },
17
46
  info: (message) => {
47
+ if (json) {
48
+ warnings.push(message);
49
+ return;
50
+ }
18
51
  writeLine(stderr, message);
19
52
  },
20
53
  error: (message) => {
@@ -0,0 +1,32 @@
1
+ import { createInterface } from 'node:readline/promises';
2
+ import { Writable } from 'node:stream';
3
+ export const createPromptSecret = ({ input = process.stdin, output = process.stderr, } = {}) => {
4
+ return async (message) => {
5
+ let muted = false;
6
+ const maskedOutput = new Writable({
7
+ write(chunk, encoding, callback) {
8
+ if (!muted) {
9
+ output.write(chunk, encoding);
10
+ }
11
+ callback();
12
+ },
13
+ });
14
+ const readline = createInterface({
15
+ input,
16
+ output: maskedOutput,
17
+ terminal: true,
18
+ });
19
+ try {
20
+ output.write(message);
21
+ muted = true;
22
+ const value = await readline.question('');
23
+ muted = false;
24
+ output.write('\n');
25
+ return value.trim();
26
+ }
27
+ finally {
28
+ muted = false;
29
+ readline.close();
30
+ }
31
+ };
32
+ };
@@ -21,7 +21,7 @@ export const requireResolvedBaseUrl = ({ flagValue, env, config, }) => {
21
21
  export const requireResolvedToken = ({ env, config, }) => {
22
22
  const resolvedToken = resolveToken({ env, config });
23
23
  if (!resolvedToken.value) {
24
- throw new CliCommandError(`Not authenticated. Set ${chalksurfTokenEnvVar} or run "chalksurf auth login --with-token".`, 3);
24
+ throw new CliCommandError(`Not authenticated. Set ${chalksurfTokenEnvVar} or run "chalksurf auth login".`, 3);
25
25
  }
26
26
  return resolvedToken;
27
27
  };
@@ -1,5 +1,5 @@
1
1
  import { createWriteStream } from 'node:fs';
2
- import { mkdtemp, readdir, rm, stat } from 'node:fs/promises';
2
+ import { mkdtemp, open, readdir, rm, stat } from 'node:fs/promises';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { basename, isAbsolute, relative, resolve } from 'node:path';
5
5
  import { Readable } from 'node:stream';
@@ -8,6 +8,8 @@ import pLimit from 'p-limit';
8
8
  import { CliCommandError } from './cli-error.js';
9
9
  const sourceResolutionExitCode = 4;
10
10
  const defaultMaxConcurrentUrlDownloads = 4;
11
+ const htmlSniffBytes = 1024;
12
+ const genericMimeTypes = new Set(['application/octet-stream', 'binary/octet-stream']);
11
13
  const createSourceResolutionError = (message) => {
12
14
  return new CliCommandError(message, sourceResolutionExitCode);
13
15
  };
@@ -85,7 +87,44 @@ const deriveRelativePathFromUrl = (url) => {
85
87
  }
86
88
  return fileName;
87
89
  };
88
- const resolveLocalFileSource = async ({ source, cwd, }) => {
90
+ const normalizeMimeType = (contentTypeHeaderValue) => {
91
+ const mimeType = contentTypeHeaderValue?.split(';', 1)[0]?.trim().toLowerCase();
92
+ return mimeType || undefined;
93
+ };
94
+ const stripLeadingHtmlComments = (value) => {
95
+ let normalizedValue = value.trimStart();
96
+ while (normalizedValue.startsWith('<!--')) {
97
+ const commentEndIndex = normalizedValue.indexOf('-->');
98
+ if (commentEndIndex === -1) {
99
+ return normalizedValue;
100
+ }
101
+ normalizedValue = normalizedValue.slice(commentEndIndex + 3).trimStart();
102
+ }
103
+ return normalizedValue;
104
+ };
105
+ const looksLikeHtmlDocument = (value) => {
106
+ const normalizedValue = stripLeadingHtmlComments(value.replace(/^\uFEFF/, ''));
107
+ return /^<(?:!doctype html|html|head|body)\b/i.test(normalizedValue);
108
+ };
109
+ const sniffMimeType = async ({ filePath, responseMimeType }) => {
110
+ if (responseMimeType && !genericMimeTypes.has(responseMimeType)) {
111
+ return responseMimeType;
112
+ }
113
+ const fileHandle = await open(filePath, 'r');
114
+ try {
115
+ const buffer = Buffer.alloc(htmlSniffBytes);
116
+ const { bytesRead } = await fileHandle.read(buffer, 0, buffer.length, 0);
117
+ const filePrefix = buffer.subarray(0, bytesRead).toString('utf8');
118
+ if (looksLikeHtmlDocument(filePrefix)) {
119
+ return 'text/html';
120
+ }
121
+ return responseMimeType;
122
+ }
123
+ finally {
124
+ await fileHandle.close();
125
+ }
126
+ };
127
+ const resolveLocalFileSource = async ({ source, cwd, sourceInputIndex, }) => {
89
128
  const resolvedPath = resolve(cwd, source.path);
90
129
  await ensureExistingFile(resolvedPath, 'Local source');
91
130
  const relativePath = normalizeRelativePath(source.relativePath ?? basename(resolvedPath));
@@ -96,10 +135,12 @@ const resolveLocalFileSource = async ({ source, cwd, }) => {
96
135
  filePath: resolvedPath,
97
136
  fileName: basename(relativePath),
98
137
  relativePath,
138
+ sourceInputIndex,
139
+ sourceInput: source,
99
140
  },
100
141
  ];
101
142
  };
102
- const resolveDirectorySource = async ({ source, cwd, }) => {
143
+ const resolveDirectorySource = async ({ source, cwd, sourceInputIndex, }) => {
103
144
  const resolvedDirectoryPath = resolve(cwd, source.path);
104
145
  const resolvedRelativeRoot = resolve(cwd, source.relativeRoot ?? source.path);
105
146
  await ensureExistingDirectory(resolvedDirectoryPath, 'Directory source');
@@ -119,10 +160,12 @@ const resolveDirectorySource = async ({ source, cwd, }) => {
119
160
  filePath,
120
161
  fileName: basename(relativePath),
121
162
  relativePath,
163
+ sourceInputIndex,
164
+ sourceInput: source,
122
165
  };
123
166
  });
124
167
  };
125
- const resolveUrlSource = async ({ source, fetchImpl, }) => {
168
+ const resolveUrlSource = async ({ source, sourceInputIndex, }) => {
126
169
  let parsedUrl;
127
170
  try {
128
171
  parsedUrl = new URL(source.url);
@@ -137,22 +180,30 @@ const resolveUrlSource = async ({ source, fetchImpl, }) => {
137
180
  const downloadDirectoryPath = await mkdtemp(resolve(tmpdir(), 'chalksurf-cli-source-'));
138
181
  const downloadedFilePath = resolve(downloadDirectoryPath, basename(relativePath));
139
182
  try {
140
- const response = await fetchImpl(source.url);
183
+ const response = await fetch(source.url);
141
184
  if (!response.ok) {
142
185
  throw createSourceResolutionError(`Failed to download "${source.url}": HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ''}.`);
143
186
  }
144
187
  if (!response.body) {
145
188
  throw createSourceResolutionError(`Failed to download "${source.url}": response body was empty.`);
146
189
  }
190
+ const responseMimeType = normalizeMimeType(response.headers.get('content-type'));
147
191
  await pipeline(Readable.fromWeb(response.body), createWriteStream(downloadedFilePath));
192
+ const mimeType = await sniffMimeType({
193
+ filePath: downloadedFilePath,
194
+ responseMimeType,
195
+ });
148
196
  return [
149
197
  {
150
198
  kind: 'url',
151
199
  input: source.url,
152
200
  filePath: downloadedFilePath,
153
201
  fileName: basename(relativePath),
202
+ mimeType,
154
203
  relativePath,
204
+ sourceInputIndex,
155
205
  cleanupPath: downloadDirectoryPath,
206
+ sourceInput: source,
156
207
  },
157
208
  ];
158
209
  }
@@ -182,16 +233,27 @@ export const cleanupResolvedSources = async (resolvedSources) => {
182
233
  await rm(cleanupPath, { force: true, recursive: true });
183
234
  }));
184
235
  };
185
- export const resolveSources = async ({ sources, cwd = process.cwd(), fetchImpl = fetch, maxConcurrentUrlDownloads = defaultMaxConcurrentUrlDownloads, }) => {
236
+ export const resolveSources = async ({ sources, cwd = process.cwd(), maxConcurrentUrlDownloads = defaultMaxConcurrentUrlDownloads, }) => {
186
237
  const limitUrlDownloads = pLimit(Math.max(1, maxConcurrentUrlDownloads));
187
- const settledSourceGroups = await Promise.allSettled(sources.map(async (source) => {
238
+ const settledSourceGroups = await Promise.allSettled(sources.map(async (source, sourceInputIndex) => {
188
239
  if (source.kind === 'local') {
189
- return await resolveLocalFileSource({ source, cwd });
240
+ return await resolveLocalFileSource({
241
+ source: source,
242
+ cwd,
243
+ sourceInputIndex,
244
+ });
190
245
  }
191
246
  if (source.kind === 'directory') {
192
- return await resolveDirectorySource({ source, cwd });
247
+ return await resolveDirectorySource({
248
+ source: source,
249
+ cwd,
250
+ sourceInputIndex,
251
+ });
193
252
  }
194
- return await limitUrlDownloads(() => resolveUrlSource({ source, fetchImpl }));
253
+ return await limitUrlDownloads(() => resolveUrlSource({
254
+ source: source,
255
+ sourceInputIndex,
256
+ }));
195
257
  }));
196
258
  const resolvedSources = settledSourceGroups.flatMap((result) => (result.status === 'fulfilled' ? result.value : []));
197
259
  const firstRejectedResult = settledSourceGroups.find((result) => result.status === 'rejected');
@@ -0,0 +1 @@
1
+ export const translationLanguages = ['english', 'hungarian', 'german', 'french', 'spanish', 'italian'];
@@ -1,4 +1,4 @@
1
- import { CliCommandError } from './cli-error.js';
1
+ import { CliCommandError, createSerializableCliError } from './cli-error.js';
2
2
  import { mapApiErrorToCliError } from './session.js';
3
3
  const isTerminalJob = (job) => {
4
4
  return ['completed', 'failed'].includes(job.status);
@@ -41,6 +41,21 @@ export const getWaitExitCode = ({ jobs, timedOut }) => {
41
41
  }
42
42
  return 0;
43
43
  };
44
+ export const getWaitError = ({ jobs, timedOut }) => {
45
+ if (timedOut) {
46
+ return createSerializableCliError({
47
+ exitCode: 6,
48
+ message: 'Timed out before all jobs completed.',
49
+ });
50
+ }
51
+ if (jobs.some((job) => job.status === 'failed')) {
52
+ return createSerializableCliError({
53
+ exitCode: 7,
54
+ message: 'One or more jobs failed.',
55
+ });
56
+ }
57
+ return undefined;
58
+ };
44
59
  export const waitForCliJobs = async ({ getUserJob, jobIds, maxPollIntervalMs = 5000, now, sleep, timeoutMs = 300000, }) => {
45
60
  const startedAt = now();
46
61
  let nextPollDelayMs = 1000;