@chalksurf/cli 0.1.0
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 +135 -0
- package/dist/bin/chalksurf.js +121 -0
- package/dist/commands/auth.js +155 -0
- package/dist/commands/job.js +94 -0
- package/dist/commands/org.js +106 -0
- package/dist/commands/sheet.js +278 -0
- package/dist/lib/api-client.js +71 -0
- package/dist/lib/cli-error.js +10 -0
- package/dist/lib/config-store.js +176 -0
- package/dist/lib/manifest.js +107 -0
- package/dist/lib/output.js +24 -0
- package/dist/lib/session.js +45 -0
- package/dist/lib/source-resolver.js +210 -0
- package/dist/lib/user-jobs.js +78 -0
- package/package.json +43 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { openAsBlob } from 'node:fs';
|
|
2
|
+
import { stat } from 'node:fs/promises';
|
|
3
|
+
import { extname, resolve } from 'node:path';
|
|
4
|
+
import { createApiClient } from '../lib/api-client.js';
|
|
5
|
+
import { CliCommandError } from '../lib/cli-error.js';
|
|
6
|
+
import { loadSheetImportManifest } from '../lib/manifest.js';
|
|
7
|
+
import { mapApiErrorToCliError, requireResolvedBaseUrl, requireResolvedToken, resolveRequestedOrganizationId, } from '../lib/session.js';
|
|
8
|
+
import { cleanupResolvedSources, resolveSources, } from '../lib/source-resolver.js';
|
|
9
|
+
import { formatCliJobSummary, getWaitExitCode, throwSilentExitCode, waitForCliJobs } from '../lib/user-jobs.js';
|
|
10
|
+
const mimeTypesByExtension = {
|
|
11
|
+
'.avif': 'image/avif',
|
|
12
|
+
'.doc': 'application/msword',
|
|
13
|
+
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
14
|
+
'.gif': 'image/gif',
|
|
15
|
+
'.jpeg': 'image/jpeg',
|
|
16
|
+
'.jpg': 'image/jpeg',
|
|
17
|
+
'.md': 'text/markdown',
|
|
18
|
+
'.pdf': 'application/pdf',
|
|
19
|
+
'.png': 'image/png',
|
|
20
|
+
'.svg': 'image/svg+xml',
|
|
21
|
+
'.tex': 'text/plain',
|
|
22
|
+
'.tif': 'image/tiff',
|
|
23
|
+
'.tiff': 'image/tiff',
|
|
24
|
+
'.txt': 'text/plain',
|
|
25
|
+
'.webp': 'image/webp',
|
|
26
|
+
};
|
|
27
|
+
const normalizeTimeoutMs = (timeoutMs) => {
|
|
28
|
+
const normalizedTimeoutMs = timeoutMs ?? 300000;
|
|
29
|
+
if (!Number.isFinite(normalizedTimeoutMs) || normalizedTimeoutMs <= 0) {
|
|
30
|
+
throw new CliCommandError('--timeout-ms must be a positive number.', 2);
|
|
31
|
+
}
|
|
32
|
+
return normalizedTimeoutMs;
|
|
33
|
+
};
|
|
34
|
+
const guessMimeType = (fileName) => {
|
|
35
|
+
return mimeTypesByExtension[extname(fileName).toLowerCase()] ?? 'application/octet-stream';
|
|
36
|
+
};
|
|
37
|
+
const isHttpUrl = (value) => {
|
|
38
|
+
try {
|
|
39
|
+
const parsedUrl = new URL(value);
|
|
40
|
+
return ['http:', 'https:'].includes(parsedUrl.protocol);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
const buildFormData = async (resolvedSources) => {
|
|
47
|
+
const formData = new FormData();
|
|
48
|
+
await Promise.all(resolvedSources.map(async (resolvedSource, index) => {
|
|
49
|
+
const mimeType = guessMimeType(resolvedSource.fileName);
|
|
50
|
+
const fileBlob = await openAsBlob(resolvedSource.filePath, { type: mimeType });
|
|
51
|
+
const file = new File([fileBlob], resolvedSource.fileName, { type: mimeType });
|
|
52
|
+
formData.set(`file-${index}`, file);
|
|
53
|
+
formData.set(`path-${index}`, resolvedSource.relativePath);
|
|
54
|
+
}));
|
|
55
|
+
return formData;
|
|
56
|
+
};
|
|
57
|
+
const buildCommandSources = async ({ cwd, manifestPath, rawSources, relativePath, stdin, wait, }) => {
|
|
58
|
+
if (manifestPath && rawSources.length > 0) {
|
|
59
|
+
throw new CliCommandError('Pass positional sources or --manifest, not both.', 2);
|
|
60
|
+
}
|
|
61
|
+
if (manifestPath && relativePath) {
|
|
62
|
+
throw new CliCommandError('--relative-path can only be used with a single positional source.', 2);
|
|
63
|
+
}
|
|
64
|
+
if (manifestPath) {
|
|
65
|
+
const manifest = await loadSheetImportManifest({
|
|
66
|
+
cwd,
|
|
67
|
+
manifestPath,
|
|
68
|
+
stdin,
|
|
69
|
+
});
|
|
70
|
+
return {
|
|
71
|
+
organizationId: manifest.organizationId,
|
|
72
|
+
sources: manifest.sources,
|
|
73
|
+
wait: wait || manifest.wait === true,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
if (rawSources.length === 0) {
|
|
77
|
+
throw new CliCommandError('At least one source or --manifest is required.', 2);
|
|
78
|
+
}
|
|
79
|
+
if (relativePath && rawSources.length !== 1) {
|
|
80
|
+
throw new CliCommandError('--relative-path can only be used with a single positional source.', 2);
|
|
81
|
+
}
|
|
82
|
+
const sources = [];
|
|
83
|
+
for (const rawSource of rawSources) {
|
|
84
|
+
if (isHttpUrl(rawSource)) {
|
|
85
|
+
sources.push({
|
|
86
|
+
kind: 'url',
|
|
87
|
+
url: rawSource,
|
|
88
|
+
relativePath,
|
|
89
|
+
});
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
const pathStats = await stat(resolve(cwd, rawSource));
|
|
94
|
+
if (pathStats.isDirectory()) {
|
|
95
|
+
if (relativePath) {
|
|
96
|
+
throw new CliCommandError('--relative-path cannot be used with directory sources.', 2);
|
|
97
|
+
}
|
|
98
|
+
sources.push({
|
|
99
|
+
kind: 'directory',
|
|
100
|
+
path: rawSource,
|
|
101
|
+
});
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
if (error.code !== 'ENOENT') {
|
|
107
|
+
throw error;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
sources.push({
|
|
111
|
+
kind: 'local',
|
|
112
|
+
path: rawSource,
|
|
113
|
+
relativePath,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
organizationId: undefined,
|
|
118
|
+
sources,
|
|
119
|
+
wait: wait === true,
|
|
120
|
+
};
|
|
121
|
+
};
|
|
122
|
+
const buildSheetImportJobs = ({ importResultJobs, resolvedSources, waitedJobsById, }) => {
|
|
123
|
+
return importResultJobs.map((job, index) => {
|
|
124
|
+
const source = resolvedSources[index];
|
|
125
|
+
const waitedJob = waitedJobsById?.get(job.jobId);
|
|
126
|
+
return {
|
|
127
|
+
jobId: job.jobId,
|
|
128
|
+
fileName: job.fileName,
|
|
129
|
+
relativePath: job.relativePath,
|
|
130
|
+
source: {
|
|
131
|
+
kind: source?.kind ?? 'local',
|
|
132
|
+
input: source?.input ?? job.relativePath,
|
|
133
|
+
relativePath: source?.relativePath ?? job.relativePath,
|
|
134
|
+
},
|
|
135
|
+
status: waitedJob?.status,
|
|
136
|
+
error: waitedJob?.error,
|
|
137
|
+
exerciseSheetId: waitedJob?.exerciseSheetId,
|
|
138
|
+
};
|
|
139
|
+
});
|
|
140
|
+
};
|
|
141
|
+
const formatQueuedImportOutput = (jobs) => {
|
|
142
|
+
return [
|
|
143
|
+
`Queued ${jobs.length} import${jobs.length === 1 ? '' : 's'}.`,
|
|
144
|
+
...jobs.map((job) => `${job.jobId} ${job.relativePath}`),
|
|
145
|
+
]
|
|
146
|
+
.filter(Boolean)
|
|
147
|
+
.join('\n');
|
|
148
|
+
};
|
|
149
|
+
const formatWaitedImportOutput = (jobs) => {
|
|
150
|
+
return [
|
|
151
|
+
`${jobs.length} job${jobs.length === 1 ? '' : 's'} queued.`,
|
|
152
|
+
...jobs.map((job) => formatCliJobSummary({
|
|
153
|
+
id: job.jobId,
|
|
154
|
+
status: job.status ?? 'pending',
|
|
155
|
+
type: 'exercise_sheet_import',
|
|
156
|
+
createdAt: '',
|
|
157
|
+
updatedAt: '',
|
|
158
|
+
error: job.error,
|
|
159
|
+
exerciseSheetId: job.exerciseSheetId,
|
|
160
|
+
})),
|
|
161
|
+
]
|
|
162
|
+
.filter(Boolean)
|
|
163
|
+
.join('\n');
|
|
164
|
+
};
|
|
165
|
+
export const registerSheetCommands = (sheetYargs, context) => {
|
|
166
|
+
return sheetYargs
|
|
167
|
+
.command('import [sources..]', 'Import one or more exercise sheet sources', (importYargs) => importYargs
|
|
168
|
+
.positional('sources', {
|
|
169
|
+
array: true,
|
|
170
|
+
type: 'string',
|
|
171
|
+
describe: 'Local file paths, directories, or HTTP(S) URLs',
|
|
172
|
+
})
|
|
173
|
+
.option('manifest', {
|
|
174
|
+
type: 'string',
|
|
175
|
+
describe: 'Load sources from a manifest file, or "-" for stdin',
|
|
176
|
+
})
|
|
177
|
+
.option('relative-path', {
|
|
178
|
+
type: 'string',
|
|
179
|
+
describe: 'Override the relative path for a single positional source',
|
|
180
|
+
})
|
|
181
|
+
.option('timeout-ms', {
|
|
182
|
+
type: 'number',
|
|
183
|
+
default: 300000,
|
|
184
|
+
describe: 'Maximum time to wait before timing out',
|
|
185
|
+
})
|
|
186
|
+
.option('wait', {
|
|
187
|
+
type: 'boolean',
|
|
188
|
+
default: false,
|
|
189
|
+
describe: 'Wait for the queued jobs to finish',
|
|
190
|
+
}), async (argv) => {
|
|
191
|
+
const config = await context.configStore.load();
|
|
192
|
+
const rawSources = (argv.sources ?? []).map(String);
|
|
193
|
+
const manifestPath = argv.manifest === '' ? '-' : argv.manifest;
|
|
194
|
+
const normalizedRawSources = manifestPath === '-' && rawSources[0] === '-' ? rawSources.slice(1) : rawSources;
|
|
195
|
+
const resolvedBaseUrl = requireResolvedBaseUrl({
|
|
196
|
+
flagValue: argv.baseUrl,
|
|
197
|
+
env: context.env,
|
|
198
|
+
config,
|
|
199
|
+
});
|
|
200
|
+
const resolvedToken = requireResolvedToken({
|
|
201
|
+
env: context.env,
|
|
202
|
+
config,
|
|
203
|
+
});
|
|
204
|
+
const commandSources = await buildCommandSources({
|
|
205
|
+
cwd: context.cwd,
|
|
206
|
+
manifestPath,
|
|
207
|
+
rawSources: normalizedRawSources,
|
|
208
|
+
relativePath: argv.relativePath,
|
|
209
|
+
stdin: context.stdin,
|
|
210
|
+
wait: argv.wait,
|
|
211
|
+
});
|
|
212
|
+
const organizationId = resolveRequestedOrganizationId({
|
|
213
|
+
flagValue: argv.organization,
|
|
214
|
+
fallbackValue: commandSources.organizationId,
|
|
215
|
+
env: context.env,
|
|
216
|
+
config,
|
|
217
|
+
});
|
|
218
|
+
const apiClient = createApiClient({
|
|
219
|
+
baseUrl: resolvedBaseUrl.value,
|
|
220
|
+
token: resolvedToken.value,
|
|
221
|
+
organizationId,
|
|
222
|
+
fetchImpl: context.fetchImpl,
|
|
223
|
+
});
|
|
224
|
+
let resolvedSources = [];
|
|
225
|
+
try {
|
|
226
|
+
resolvedSources = await resolveSources({
|
|
227
|
+
cwd: context.cwd,
|
|
228
|
+
fetchImpl: context.fetchImpl,
|
|
229
|
+
sources: commandSources.sources,
|
|
230
|
+
});
|
|
231
|
+
const formData = await buildFormData(resolvedSources);
|
|
232
|
+
let importResult;
|
|
233
|
+
try {
|
|
234
|
+
importResult = await apiClient.importExerciseSheet(formData);
|
|
235
|
+
}
|
|
236
|
+
catch (error) {
|
|
237
|
+
throw mapApiErrorToCliError(error);
|
|
238
|
+
}
|
|
239
|
+
if (!commandSources.wait) {
|
|
240
|
+
const jobs = buildSheetImportJobs({
|
|
241
|
+
importResultJobs: importResult.jobs,
|
|
242
|
+
resolvedSources,
|
|
243
|
+
});
|
|
244
|
+
context.output.print({
|
|
245
|
+
organizationId: organizationId ?? null,
|
|
246
|
+
jobs,
|
|
247
|
+
waited: false,
|
|
248
|
+
}, (result) => formatQueuedImportOutput(result.jobs));
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
const waitResult = await waitForCliJobs({
|
|
252
|
+
getUserJob: async (jobId) => await apiClient.getUserJob(jobId),
|
|
253
|
+
jobIds: importResult.jobs.map((job) => job.jobId),
|
|
254
|
+
now: context.now,
|
|
255
|
+
sleep: context.sleep,
|
|
256
|
+
timeoutMs: normalizeTimeoutMs(argv.timeoutMs),
|
|
257
|
+
});
|
|
258
|
+
const waitedJobsById = new Map(waitResult.jobs.map((job) => [job.id, job]));
|
|
259
|
+
const jobs = buildSheetImportJobs({
|
|
260
|
+
importResultJobs: importResult.jobs,
|
|
261
|
+
resolvedSources,
|
|
262
|
+
waitedJobsById,
|
|
263
|
+
});
|
|
264
|
+
context.output.print({
|
|
265
|
+
organizationId: organizationId ?? null,
|
|
266
|
+
jobs,
|
|
267
|
+
timedOut: waitResult.timedOut,
|
|
268
|
+
waited: true,
|
|
269
|
+
}, (result) => formatWaitedImportOutput(result.jobs));
|
|
270
|
+
throwSilentExitCode(getWaitExitCode(waitResult));
|
|
271
|
+
}
|
|
272
|
+
finally {
|
|
273
|
+
await cleanupResolvedSources(resolvedSources);
|
|
274
|
+
}
|
|
275
|
+
})
|
|
276
|
+
.demandCommand(1)
|
|
277
|
+
.strict();
|
|
278
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
const normalizeBaseUrl = (baseUrl) => {
|
|
2
|
+
return baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
|
|
3
|
+
};
|
|
4
|
+
const getErrorMessage = (payload, status) => {
|
|
5
|
+
return payload?.error?.message ?? `API request failed with status ${status}`;
|
|
6
|
+
};
|
|
7
|
+
const getResponseData = async ({ response, batched }) => {
|
|
8
|
+
const body = (await response.json());
|
|
9
|
+
const payload = batched ? body[0] : body;
|
|
10
|
+
if (!response.ok) {
|
|
11
|
+
throw new Error(getErrorMessage(payload, response.status));
|
|
12
|
+
}
|
|
13
|
+
if (payload?.error) {
|
|
14
|
+
throw new Error(payload.error.message);
|
|
15
|
+
}
|
|
16
|
+
if (!payload?.result) {
|
|
17
|
+
throw new Error('API response is missing a result payload');
|
|
18
|
+
}
|
|
19
|
+
return payload.result.data;
|
|
20
|
+
};
|
|
21
|
+
export const createApiClient = ({ baseUrl, token, organizationId, fetchImpl = fetch, }) => {
|
|
22
|
+
const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
|
|
23
|
+
const createHeaders = ({ includeJsonContentType }) => {
|
|
24
|
+
const headers = new Headers();
|
|
25
|
+
if (token) {
|
|
26
|
+
headers.set('authorization', `Bearer ${token}`);
|
|
27
|
+
}
|
|
28
|
+
if (organizationId) {
|
|
29
|
+
headers.set('x-organization-id', organizationId);
|
|
30
|
+
}
|
|
31
|
+
if (includeJsonContentType) {
|
|
32
|
+
headers.set('content-type', 'application/json');
|
|
33
|
+
}
|
|
34
|
+
return headers;
|
|
35
|
+
};
|
|
36
|
+
const query = async (procedureName, input) => {
|
|
37
|
+
const url = new URL(`trpc/${procedureName}`, normalizedBaseUrl);
|
|
38
|
+
url.searchParams.set('batch', '1');
|
|
39
|
+
url.searchParams.set('input', JSON.stringify({ 0: input ?? null }));
|
|
40
|
+
const response = await fetchImpl(url.toString(), {
|
|
41
|
+
method: 'GET',
|
|
42
|
+
headers: createHeaders({ includeJsonContentType: false }),
|
|
43
|
+
});
|
|
44
|
+
return await getResponseData({ response, batched: true });
|
|
45
|
+
};
|
|
46
|
+
const mutation = async (procedureName, input) => {
|
|
47
|
+
const isFormDataInput = input instanceof FormData;
|
|
48
|
+
const response = await fetchImpl(new URL(`trpc/${procedureName}`, normalizedBaseUrl).toString(), {
|
|
49
|
+
method: 'POST',
|
|
50
|
+
headers: createHeaders({ includeJsonContentType: !isFormDataInput }),
|
|
51
|
+
body: isFormDataInput ? input : JSON.stringify(input ?? {}),
|
|
52
|
+
});
|
|
53
|
+
return await getResponseData({ response, batched: false });
|
|
54
|
+
};
|
|
55
|
+
return {
|
|
56
|
+
query,
|
|
57
|
+
mutation,
|
|
58
|
+
getUserProfile: async () => {
|
|
59
|
+
return await query('getUserProfile', null);
|
|
60
|
+
},
|
|
61
|
+
listUserJobs: async () => {
|
|
62
|
+
return await query('listUserJobs', null);
|
|
63
|
+
},
|
|
64
|
+
getUserJob: async (id) => {
|
|
65
|
+
return await query('getUserJob', { id });
|
|
66
|
+
},
|
|
67
|
+
importExerciseSheet: async (formData) => {
|
|
68
|
+
return await mutation('importExerciseSheet', formData);
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
};
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { CliCommandError } from './cli-error.js';
|
|
5
|
+
export const chalksurfBaseUrlEnvVar = 'CHALKSURF_BASE_URL';
|
|
6
|
+
export const chalksurfTokenEnvVar = 'CHALKSURF_TOKEN';
|
|
7
|
+
export const chalksurfOrganizationIdEnvVar = 'CHALKSURF_ORGANIZATION_ID';
|
|
8
|
+
export const chalksurfConfigPathEnvVar = 'CHALKSURF_CONFIG_PATH';
|
|
9
|
+
const normalizeOptionalString = (value) => {
|
|
10
|
+
const trimmedValue = value?.trim();
|
|
11
|
+
return trimmedValue ? trimmedValue : undefined;
|
|
12
|
+
};
|
|
13
|
+
const normalizeStoredBaseUrl = (value) => {
|
|
14
|
+
const normalizedValue = normalizeOptionalString(value);
|
|
15
|
+
return normalizedValue?.replace(/\/+$/, '') || undefined;
|
|
16
|
+
};
|
|
17
|
+
const sanitizeConfig = (config) => {
|
|
18
|
+
return Object.fromEntries(Object.entries({
|
|
19
|
+
token: normalizeOptionalString(config.token),
|
|
20
|
+
baseUrl: normalizeStoredBaseUrl(config.baseUrl),
|
|
21
|
+
organizationId: normalizeOptionalString(config.organizationId),
|
|
22
|
+
}).filter(([, value]) => value !== undefined));
|
|
23
|
+
};
|
|
24
|
+
const getDefaultConfigPath = ({ env, platform, homeDirectory, }) => {
|
|
25
|
+
const explicitConfigPath = normalizeOptionalString(env[chalksurfConfigPathEnvVar]);
|
|
26
|
+
if (explicitConfigPath) {
|
|
27
|
+
return explicitConfigPath;
|
|
28
|
+
}
|
|
29
|
+
if (platform === 'win32') {
|
|
30
|
+
return join(env.APPDATA || join(homeDirectory, 'AppData', 'Roaming'), 'chalksurf', 'config.json');
|
|
31
|
+
}
|
|
32
|
+
if (platform === 'darwin') {
|
|
33
|
+
return join(homeDirectory, 'Library', 'Application Support', 'chalksurf', 'config.json');
|
|
34
|
+
}
|
|
35
|
+
return join(env.XDG_CONFIG_HOME || join(homeDirectory, '.config'), 'chalksurf', 'config.json');
|
|
36
|
+
};
|
|
37
|
+
const findOrganizationMembership = ({ memberships, organizationId, }) => {
|
|
38
|
+
return memberships.find((membership) => membership.organizationId === organizationId) ?? null;
|
|
39
|
+
};
|
|
40
|
+
const getFallbackOrganization = (memberships) => {
|
|
41
|
+
const personalOrganization = memberships.find((membership) => membership.organizationType === 'user') ?? memberships[0] ?? null;
|
|
42
|
+
if (!personalOrganization) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
organization: personalOrganization,
|
|
47
|
+
source: personalOrganization.organizationType === 'user' ? 'default-personal' : 'default-first',
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
export const resolveBaseUrl = ({ flagValue, env, config, }) => {
|
|
51
|
+
const normalizedFlagValue = normalizeStoredBaseUrl(flagValue);
|
|
52
|
+
if (normalizedFlagValue) {
|
|
53
|
+
return { value: normalizedFlagValue, source: 'flag' };
|
|
54
|
+
}
|
|
55
|
+
const normalizedEnvValue = normalizeStoredBaseUrl(env[chalksurfBaseUrlEnvVar]);
|
|
56
|
+
if (normalizedEnvValue) {
|
|
57
|
+
return { value: normalizedEnvValue, source: 'env' };
|
|
58
|
+
}
|
|
59
|
+
const normalizedConfigValue = normalizeStoredBaseUrl(config.baseUrl);
|
|
60
|
+
if (normalizedConfigValue) {
|
|
61
|
+
return { value: normalizedConfigValue, source: 'config' };
|
|
62
|
+
}
|
|
63
|
+
return { source: 'none' };
|
|
64
|
+
};
|
|
65
|
+
export const resolveToken = ({ env, config }) => {
|
|
66
|
+
const normalizedEnvValue = normalizeOptionalString(env[chalksurfTokenEnvVar]);
|
|
67
|
+
if (normalizedEnvValue) {
|
|
68
|
+
return { value: normalizedEnvValue, source: 'env' };
|
|
69
|
+
}
|
|
70
|
+
const normalizedConfigValue = normalizeOptionalString(config.token);
|
|
71
|
+
if (normalizedConfigValue) {
|
|
72
|
+
return { value: normalizedConfigValue, source: 'config' };
|
|
73
|
+
}
|
|
74
|
+
return { source: 'none' };
|
|
75
|
+
};
|
|
76
|
+
export const resolveOrganization = ({ flagValue, env, config, profile, }) => {
|
|
77
|
+
const flagOrganizationId = normalizeOptionalString(flagValue);
|
|
78
|
+
if (flagOrganizationId) {
|
|
79
|
+
const organization = findOrganizationMembership({
|
|
80
|
+
memberships: profile.organizationMemberships,
|
|
81
|
+
organizationId: flagOrganizationId,
|
|
82
|
+
});
|
|
83
|
+
if (!organization) {
|
|
84
|
+
throw new CliCommandError(`Organization "${flagOrganizationId}" is not accessible to the current user.`, 2);
|
|
85
|
+
}
|
|
86
|
+
return { organization, source: 'flag', warnings: [] };
|
|
87
|
+
}
|
|
88
|
+
const envOrganizationId = normalizeOptionalString(env[chalksurfOrganizationIdEnvVar]);
|
|
89
|
+
if (envOrganizationId) {
|
|
90
|
+
const organization = findOrganizationMembership({
|
|
91
|
+
memberships: profile.organizationMemberships,
|
|
92
|
+
organizationId: envOrganizationId,
|
|
93
|
+
});
|
|
94
|
+
if (!organization) {
|
|
95
|
+
throw new CliCommandError(`Organization "${envOrganizationId}" from ${chalksurfOrganizationIdEnvVar} is not accessible to the current user.`, 2);
|
|
96
|
+
}
|
|
97
|
+
return { organization, source: 'env', warnings: [] };
|
|
98
|
+
}
|
|
99
|
+
const configOrganizationId = normalizeOptionalString(config.organizationId);
|
|
100
|
+
if (configOrganizationId) {
|
|
101
|
+
const organization = findOrganizationMembership({
|
|
102
|
+
memberships: profile.organizationMemberships,
|
|
103
|
+
organizationId: configOrganizationId,
|
|
104
|
+
});
|
|
105
|
+
if (organization) {
|
|
106
|
+
return { organization, source: 'config', warnings: [] };
|
|
107
|
+
}
|
|
108
|
+
const fallbackOrganization = getFallbackOrganization(profile.organizationMemberships);
|
|
109
|
+
if (!fallbackOrganization) {
|
|
110
|
+
return {
|
|
111
|
+
organization: null,
|
|
112
|
+
source: 'none',
|
|
113
|
+
warnings: [`Stored organization "${configOrganizationId}" is no longer accessible.`],
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
organization: fallbackOrganization.organization,
|
|
118
|
+
source: fallbackOrganization.source,
|
|
119
|
+
warnings: [
|
|
120
|
+
`Stored organization "${configOrganizationId}" is no longer accessible; using "${fallbackOrganization.organization.organizationName}" instead.`,
|
|
121
|
+
],
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
const fallbackOrganization = getFallbackOrganization(profile.organizationMemberships);
|
|
125
|
+
if (!fallbackOrganization) {
|
|
126
|
+
return { organization: null, source: 'none', warnings: [] };
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
organization: fallbackOrganization.organization,
|
|
130
|
+
source: fallbackOrganization.source,
|
|
131
|
+
warnings: [],
|
|
132
|
+
};
|
|
133
|
+
};
|
|
134
|
+
export const createConfigStore = ({ configPath, env = process.env, platform = process.platform, homeDirectory = homedir(), fs = { chmod, mkdir, readFile, rm, writeFile }, } = {}) => {
|
|
135
|
+
const resolvedConfigPath = configPath || getDefaultConfigPath({ env, platform, homeDirectory });
|
|
136
|
+
const load = async () => {
|
|
137
|
+
try {
|
|
138
|
+
const configContents = await fs.readFile(resolvedConfigPath, 'utf8');
|
|
139
|
+
const parsedConfig = JSON.parse(configContents);
|
|
140
|
+
return sanitizeConfig(parsedConfig);
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
if (error.code === 'ENOENT') {
|
|
144
|
+
return {};
|
|
145
|
+
}
|
|
146
|
+
if (error instanceof SyntaxError) {
|
|
147
|
+
throw new CliCommandError(`Failed to parse CLI config at ${resolvedConfigPath}.`, 2);
|
|
148
|
+
}
|
|
149
|
+
throw error;
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
const save = async (config) => {
|
|
153
|
+
const sanitizedConfig = sanitizeConfig(config);
|
|
154
|
+
if (Object.keys(sanitizedConfig).length === 0) {
|
|
155
|
+
await fs.rm(resolvedConfigPath, { force: true });
|
|
156
|
+
return sanitizedConfig;
|
|
157
|
+
}
|
|
158
|
+
await fs.mkdir(dirname(resolvedConfigPath), { recursive: true, mode: 0o700 });
|
|
159
|
+
await fs.writeFile(resolvedConfigPath, `${JSON.stringify(sanitizedConfig, null, 2)}\n`, {
|
|
160
|
+
mode: 0o600,
|
|
161
|
+
});
|
|
162
|
+
await fs.chmod(resolvedConfigPath, 0o600);
|
|
163
|
+
return sanitizedConfig;
|
|
164
|
+
};
|
|
165
|
+
const update = async (updater) => {
|
|
166
|
+
const currentConfig = await load();
|
|
167
|
+
const nextConfig = await updater(currentConfig);
|
|
168
|
+
return await save(nextConfig);
|
|
169
|
+
};
|
|
170
|
+
return {
|
|
171
|
+
path: resolvedConfigPath,
|
|
172
|
+
load,
|
|
173
|
+
save,
|
|
174
|
+
update,
|
|
175
|
+
};
|
|
176
|
+
};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { CliCommandError } from './cli-error.js';
|
|
4
|
+
const isObject = (value) => {
|
|
5
|
+
return typeof value === 'object' && value !== null;
|
|
6
|
+
};
|
|
7
|
+
const normalizeOptionalString = (value) => {
|
|
8
|
+
if (typeof value !== 'string') {
|
|
9
|
+
return undefined;
|
|
10
|
+
}
|
|
11
|
+
const normalizedValue = value.trim();
|
|
12
|
+
return normalizedValue.length > 0 ? normalizedValue : undefined;
|
|
13
|
+
};
|
|
14
|
+
const isInteractiveStdin = (stdin) => {
|
|
15
|
+
return stdin.isTTY === true;
|
|
16
|
+
};
|
|
17
|
+
const readTextFromStdin = async (stdin) => {
|
|
18
|
+
let text = '';
|
|
19
|
+
for await (const chunk of stdin) {
|
|
20
|
+
text += typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8');
|
|
21
|
+
}
|
|
22
|
+
return text;
|
|
23
|
+
};
|
|
24
|
+
const parseManifestSource = (value, index) => {
|
|
25
|
+
if (!isObject(value)) {
|
|
26
|
+
throw new CliCommandError(`Manifest source at index ${index} must be an object.`, 2);
|
|
27
|
+
}
|
|
28
|
+
const kind = normalizeOptionalString(value.kind);
|
|
29
|
+
if (kind === 'local') {
|
|
30
|
+
const path = normalizeOptionalString(value.path);
|
|
31
|
+
if (!path) {
|
|
32
|
+
throw new CliCommandError(`Manifest local source at index ${index} must include a path.`, 2);
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
kind: 'local',
|
|
36
|
+
path,
|
|
37
|
+
relativePath: normalizeOptionalString(value.relativePath),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
if (kind === 'directory') {
|
|
41
|
+
const path = normalizeOptionalString(value.path);
|
|
42
|
+
if (!path) {
|
|
43
|
+
throw new CliCommandError(`Manifest directory source at index ${index} must include a path.`, 2);
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
kind: 'directory',
|
|
47
|
+
path,
|
|
48
|
+
relativeRoot: normalizeOptionalString(value.relativeRoot),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
if (kind === 'url') {
|
|
52
|
+
const url = normalizeOptionalString(value.url);
|
|
53
|
+
if (!url) {
|
|
54
|
+
throw new CliCommandError(`Manifest URL source at index ${index} must include a url.`, 2);
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
kind: 'url',
|
|
58
|
+
url,
|
|
59
|
+
relativePath: normalizeOptionalString(value.relativePath),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
throw new CliCommandError(`Manifest source at index ${index} has unsupported kind "${String(value.kind)}".`, 2);
|
|
63
|
+
};
|
|
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);
|
|
74
|
+
}
|
|
75
|
+
if (!Array.isArray(parsedManifest.sources) || parsedManifest.sources.length === 0) {
|
|
76
|
+
throw new CliCommandError('Manifest must include a non-empty sources array.', 2);
|
|
77
|
+
}
|
|
78
|
+
const wait = typeof parsedManifest.wait === 'boolean' ? parsedManifest.wait : undefined;
|
|
79
|
+
return {
|
|
80
|
+
organizationId: normalizeOptionalString(parsedManifest.organizationId),
|
|
81
|
+
wait,
|
|
82
|
+
sources: parsedManifest.sources.map((source, index) => parseManifestSource(source, index)),
|
|
83
|
+
};
|
|
84
|
+
};
|
|
85
|
+
export const loadSheetImportManifest = async ({ cwd, manifestPath, stdin, }) => {
|
|
86
|
+
if (manifestPath === '-') {
|
|
87
|
+
if (isInteractiveStdin(stdin)) {
|
|
88
|
+
throw new CliCommandError('No manifest was piped on stdin. Pipe JSON into "chalksurf sheet import --manifest -".', 2);
|
|
89
|
+
}
|
|
90
|
+
return parseManifest(await readTextFromStdin(stdin));
|
|
91
|
+
}
|
|
92
|
+
const resolvedManifestPath = resolve(cwd, manifestPath);
|
|
93
|
+
let fileStats;
|
|
94
|
+
try {
|
|
95
|
+
fileStats = await stat(resolvedManifestPath);
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
if (error.code === 'ENOENT') {
|
|
99
|
+
throw new CliCommandError(`Manifest file "${manifestPath}" does not exist.`, 2);
|
|
100
|
+
}
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
if (!fileStats.isFile()) {
|
|
104
|
+
throw new CliCommandError(`Manifest path "${manifestPath}" is not a file.`, 2);
|
|
105
|
+
}
|
|
106
|
+
return parseManifest(await readFile(resolvedManifestPath, 'utf8'));
|
|
107
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
const ensureTrailingNewline = (value) => {
|
|
2
|
+
return value.endsWith('\n') ? value : `${value}\n`;
|
|
3
|
+
};
|
|
4
|
+
const writeLine = (writer, value) => {
|
|
5
|
+
writer.write(ensureTrailingNewline(value));
|
|
6
|
+
};
|
|
7
|
+
export const createOutput = ({ json, stdout, stderr, }) => {
|
|
8
|
+
return {
|
|
9
|
+
print: (value, humanFormatter) => {
|
|
10
|
+
if (json) {
|
|
11
|
+
writeLine(stdout, JSON.stringify(value, null, 2));
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
const message = typeof humanFormatter === 'function' ? humanFormatter(value) : humanFormatter;
|
|
15
|
+
writeLine(stdout, message);
|
|
16
|
+
},
|
|
17
|
+
info: (message) => {
|
|
18
|
+
writeLine(stderr, message);
|
|
19
|
+
},
|
|
20
|
+
error: (message) => {
|
|
21
|
+
writeLine(stderr, message);
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
};
|