@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,45 @@
|
|
|
1
|
+
import { CliCommandError } from './cli-error.js';
|
|
2
|
+
import { chalksurfBaseUrlEnvVar, chalksurfOrganizationIdEnvVar, chalksurfTokenEnvVar, resolveBaseUrl, resolveToken, } from './config-store.js';
|
|
3
|
+
const normalizeOptionalString = (value) => {
|
|
4
|
+
const trimmedValue = value?.trim();
|
|
5
|
+
return trimmedValue ? trimmedValue : undefined;
|
|
6
|
+
};
|
|
7
|
+
export const mapApiErrorToCliError = (error) => {
|
|
8
|
+
const message = error instanceof Error ? error.message : 'Unknown API error';
|
|
9
|
+
if (message.includes('UNAUTHORIZED')) {
|
|
10
|
+
return new CliCommandError('Authentication failed. The CLI token is invalid or revoked.', 3);
|
|
11
|
+
}
|
|
12
|
+
return new CliCommandError(`API request failed: ${message}`, 5);
|
|
13
|
+
};
|
|
14
|
+
export const requireResolvedBaseUrl = ({ flagValue, env, config, }) => {
|
|
15
|
+
const resolvedBaseUrl = resolveBaseUrl({ flagValue, env, config });
|
|
16
|
+
if (!resolvedBaseUrl.value) {
|
|
17
|
+
throw new CliCommandError(`Missing ChalkSurf base URL. Pass --base-url, set ${chalksurfBaseUrlEnvVar}, or log in first.`, 2);
|
|
18
|
+
}
|
|
19
|
+
return resolvedBaseUrl;
|
|
20
|
+
};
|
|
21
|
+
export const requireResolvedToken = ({ env, config, }) => {
|
|
22
|
+
const resolvedToken = resolveToken({ env, config });
|
|
23
|
+
if (!resolvedToken.value) {
|
|
24
|
+
throw new CliCommandError(`Not authenticated. Set ${chalksurfTokenEnvVar} or run "chalksurf auth login --with-token".`, 3);
|
|
25
|
+
}
|
|
26
|
+
return resolvedToken;
|
|
27
|
+
};
|
|
28
|
+
export const resolveRequestedOrganizationId = ({ config, env, fallbackValue, flagValue, }) => {
|
|
29
|
+
return (normalizeOptionalString(flagValue) ??
|
|
30
|
+
normalizeOptionalString(fallbackValue) ??
|
|
31
|
+
normalizeOptionalString(env[chalksurfOrganizationIdEnvVar]) ??
|
|
32
|
+
normalizeOptionalString(config.organizationId));
|
|
33
|
+
};
|
|
34
|
+
export const formatUserIdentity = (profile) => {
|
|
35
|
+
if (profile.name && profile.email) {
|
|
36
|
+
return `${profile.name} <${profile.email}>`;
|
|
37
|
+
}
|
|
38
|
+
if (profile.email) {
|
|
39
|
+
return profile.email;
|
|
40
|
+
}
|
|
41
|
+
if (profile.name) {
|
|
42
|
+
return profile.name;
|
|
43
|
+
}
|
|
44
|
+
return profile.id;
|
|
45
|
+
};
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { createWriteStream } from 'node:fs';
|
|
2
|
+
import { mkdtemp, readdir, rm, stat } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { basename, isAbsolute, relative, resolve } from 'node:path';
|
|
5
|
+
import { Readable } from 'node:stream';
|
|
6
|
+
import { pipeline } from 'node:stream/promises';
|
|
7
|
+
import pLimit from 'p-limit';
|
|
8
|
+
import { CliCommandError } from './cli-error.js';
|
|
9
|
+
const sourceResolutionExitCode = 4;
|
|
10
|
+
const defaultMaxConcurrentUrlDownloads = 4;
|
|
11
|
+
const createSourceResolutionError = (message) => {
|
|
12
|
+
return new CliCommandError(message, sourceResolutionExitCode);
|
|
13
|
+
};
|
|
14
|
+
const normalizeRelativePath = (relativePath) => {
|
|
15
|
+
const normalizedSegments = relativePath
|
|
16
|
+
.replaceAll('\\', '/')
|
|
17
|
+
.split('/')
|
|
18
|
+
.filter((segment) => segment.length > 0 && segment !== '.');
|
|
19
|
+
if (normalizedSegments.some((segment) => segment === '..')) {
|
|
20
|
+
throw createSourceResolutionError(`Relative path "${relativePath}" cannot contain "..".`);
|
|
21
|
+
}
|
|
22
|
+
const normalizedRelativePath = normalizedSegments.join('/');
|
|
23
|
+
if (!normalizedRelativePath) {
|
|
24
|
+
throw createSourceResolutionError('Relative path cannot be empty.');
|
|
25
|
+
}
|
|
26
|
+
return normalizedRelativePath;
|
|
27
|
+
};
|
|
28
|
+
const ensureExistingFile = async (filePath, label) => {
|
|
29
|
+
let fileStats;
|
|
30
|
+
try {
|
|
31
|
+
fileStats = await stat(filePath);
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
if (error.code === 'ENOENT') {
|
|
35
|
+
throw createSourceResolutionError(`${label} "${filePath}" does not exist.`);
|
|
36
|
+
}
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
if (!fileStats.isFile()) {
|
|
40
|
+
throw createSourceResolutionError(`${label} "${filePath}" is not a file.`);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
const ensureExistingDirectory = async (directoryPath, label) => {
|
|
44
|
+
let directoryStats;
|
|
45
|
+
try {
|
|
46
|
+
directoryStats = await stat(directoryPath);
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
if (error.code === 'ENOENT') {
|
|
50
|
+
throw createSourceResolutionError(`${label} "${directoryPath}" does not exist.`);
|
|
51
|
+
}
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
if (!directoryStats.isDirectory()) {
|
|
55
|
+
throw createSourceResolutionError(`${label} "${directoryPath}" is not a directory.`);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
const isContainedWithin = ({ childPath, parentPath }) => {
|
|
59
|
+
const relativePath = relative(parentPath, childPath);
|
|
60
|
+
if (!relativePath) {
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
return !relativePath.startsWith('..') && !isAbsolute(relativePath);
|
|
64
|
+
};
|
|
65
|
+
const collectFilesRecursively = async (directoryPath) => {
|
|
66
|
+
const directoryEntries = await readdir(directoryPath, { withFileTypes: true });
|
|
67
|
+
const sortedEntries = directoryEntries.sort((leftEntry, rightEntry) => leftEntry.name.localeCompare(rightEntry.name));
|
|
68
|
+
const filePaths = [];
|
|
69
|
+
for (const directoryEntry of sortedEntries) {
|
|
70
|
+
const entryPath = resolve(directoryPath, directoryEntry.name);
|
|
71
|
+
if (directoryEntry.isDirectory()) {
|
|
72
|
+
filePaths.push(...(await collectFilesRecursively(entryPath)));
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (directoryEntry.isFile()) {
|
|
76
|
+
filePaths.push(entryPath);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return filePaths;
|
|
80
|
+
};
|
|
81
|
+
const deriveRelativePathFromUrl = (url) => {
|
|
82
|
+
const fileName = basename(decodeURIComponent(url.pathname));
|
|
83
|
+
if (!fileName) {
|
|
84
|
+
throw createSourceResolutionError(`URL source "${url.toString()}" does not include a file name. Pass a relative path explicitly.`);
|
|
85
|
+
}
|
|
86
|
+
return fileName;
|
|
87
|
+
};
|
|
88
|
+
const resolveLocalFileSource = async ({ source, cwd, }) => {
|
|
89
|
+
const resolvedPath = resolve(cwd, source.path);
|
|
90
|
+
await ensureExistingFile(resolvedPath, 'Local source');
|
|
91
|
+
const relativePath = normalizeRelativePath(source.relativePath ?? basename(resolvedPath));
|
|
92
|
+
return [
|
|
93
|
+
{
|
|
94
|
+
kind: 'local',
|
|
95
|
+
input: source.path,
|
|
96
|
+
filePath: resolvedPath,
|
|
97
|
+
fileName: basename(relativePath),
|
|
98
|
+
relativePath,
|
|
99
|
+
},
|
|
100
|
+
];
|
|
101
|
+
};
|
|
102
|
+
const resolveDirectorySource = async ({ source, cwd, }) => {
|
|
103
|
+
const resolvedDirectoryPath = resolve(cwd, source.path);
|
|
104
|
+
const resolvedRelativeRoot = resolve(cwd, source.relativeRoot ?? source.path);
|
|
105
|
+
await ensureExistingDirectory(resolvedDirectoryPath, 'Directory source');
|
|
106
|
+
await ensureExistingDirectory(resolvedRelativeRoot, 'Directory relativeRoot');
|
|
107
|
+
if (!isContainedWithin({ childPath: resolvedDirectoryPath, parentPath: resolvedRelativeRoot })) {
|
|
108
|
+
throw createSourceResolutionError(`Directory source "${resolvedDirectoryPath}" is not contained within relativeRoot "${resolvedRelativeRoot}".`);
|
|
109
|
+
}
|
|
110
|
+
const filePaths = await collectFilesRecursively(resolvedDirectoryPath);
|
|
111
|
+
if (filePaths.length === 0) {
|
|
112
|
+
throw createSourceResolutionError(`Directory source "${resolvedDirectoryPath}" does not contain any files.`);
|
|
113
|
+
}
|
|
114
|
+
return filePaths.map((filePath) => {
|
|
115
|
+
const relativePath = normalizeRelativePath(relative(resolvedRelativeRoot, filePath));
|
|
116
|
+
return {
|
|
117
|
+
kind: 'local',
|
|
118
|
+
input: filePath,
|
|
119
|
+
filePath,
|
|
120
|
+
fileName: basename(relativePath),
|
|
121
|
+
relativePath,
|
|
122
|
+
};
|
|
123
|
+
});
|
|
124
|
+
};
|
|
125
|
+
const resolveUrlSource = async ({ source, fetchImpl, }) => {
|
|
126
|
+
let parsedUrl;
|
|
127
|
+
try {
|
|
128
|
+
parsedUrl = new URL(source.url);
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
throw createSourceResolutionError(`URL source "${source.url}" is not a valid URL.`);
|
|
132
|
+
}
|
|
133
|
+
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
|
|
134
|
+
throw createSourceResolutionError(`URL source "${source.url}" must use http or https.`);
|
|
135
|
+
}
|
|
136
|
+
const relativePath = normalizeRelativePath(source.relativePath ?? deriveRelativePathFromUrl(parsedUrl));
|
|
137
|
+
const downloadDirectoryPath = await mkdtemp(resolve(tmpdir(), 'chalksurf-cli-source-'));
|
|
138
|
+
const downloadedFilePath = resolve(downloadDirectoryPath, basename(relativePath));
|
|
139
|
+
try {
|
|
140
|
+
const response = await fetchImpl(source.url);
|
|
141
|
+
if (!response.ok) {
|
|
142
|
+
throw createSourceResolutionError(`Failed to download "${source.url}": HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ''}.`);
|
|
143
|
+
}
|
|
144
|
+
if (!response.body) {
|
|
145
|
+
throw createSourceResolutionError(`Failed to download "${source.url}": response body was empty.`);
|
|
146
|
+
}
|
|
147
|
+
await pipeline(Readable.fromWeb(response.body), createWriteStream(downloadedFilePath));
|
|
148
|
+
return [
|
|
149
|
+
{
|
|
150
|
+
kind: 'url',
|
|
151
|
+
input: source.url,
|
|
152
|
+
filePath: downloadedFilePath,
|
|
153
|
+
fileName: basename(relativePath),
|
|
154
|
+
relativePath,
|
|
155
|
+
cleanupPath: downloadDirectoryPath,
|
|
156
|
+
},
|
|
157
|
+
];
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
await rm(downloadDirectoryPath, { force: true, recursive: true });
|
|
161
|
+
if (error instanceof CliCommandError) {
|
|
162
|
+
throw error;
|
|
163
|
+
}
|
|
164
|
+
const message = error instanceof Error ? error.message : 'Unknown download error';
|
|
165
|
+
throw createSourceResolutionError(`Failed to download "${source.url}": ${message}`);
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
const assertUniqueRelativePaths = (resolvedSources) => {
|
|
169
|
+
const seenRelativePaths = new Set();
|
|
170
|
+
for (const resolvedSource of resolvedSources) {
|
|
171
|
+
if (seenRelativePaths.has(resolvedSource.relativePath)) {
|
|
172
|
+
throw createSourceResolutionError(`Resolved sources contain duplicate relative path "${resolvedSource.relativePath}".`);
|
|
173
|
+
}
|
|
174
|
+
seenRelativePaths.add(resolvedSource.relativePath);
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
export const cleanupResolvedSources = async (resolvedSources) => {
|
|
178
|
+
const cleanupPaths = Array.from(new Set(resolvedSources
|
|
179
|
+
.map((resolvedSource) => resolvedSource.cleanupPath)
|
|
180
|
+
.filter((cleanupPath) => cleanupPath !== undefined)));
|
|
181
|
+
await Promise.all(cleanupPaths.map(async (cleanupPath) => {
|
|
182
|
+
await rm(cleanupPath, { force: true, recursive: true });
|
|
183
|
+
}));
|
|
184
|
+
};
|
|
185
|
+
export const resolveSources = async ({ sources, cwd = process.cwd(), fetchImpl = fetch, maxConcurrentUrlDownloads = defaultMaxConcurrentUrlDownloads, }) => {
|
|
186
|
+
const limitUrlDownloads = pLimit(Math.max(1, maxConcurrentUrlDownloads));
|
|
187
|
+
const settledSourceGroups = await Promise.allSettled(sources.map(async (source) => {
|
|
188
|
+
if (source.kind === 'local') {
|
|
189
|
+
return await resolveLocalFileSource({ source, cwd });
|
|
190
|
+
}
|
|
191
|
+
if (source.kind === 'directory') {
|
|
192
|
+
return await resolveDirectorySource({ source, cwd });
|
|
193
|
+
}
|
|
194
|
+
return await limitUrlDownloads(() => resolveUrlSource({ source, fetchImpl }));
|
|
195
|
+
}));
|
|
196
|
+
const resolvedSources = settledSourceGroups.flatMap((result) => (result.status === 'fulfilled' ? result.value : []));
|
|
197
|
+
const firstRejectedResult = settledSourceGroups.find((result) => result.status === 'rejected');
|
|
198
|
+
if (firstRejectedResult?.status === 'rejected') {
|
|
199
|
+
await cleanupResolvedSources(resolvedSources);
|
|
200
|
+
throw firstRejectedResult.reason;
|
|
201
|
+
}
|
|
202
|
+
try {
|
|
203
|
+
assertUniqueRelativePaths(resolvedSources);
|
|
204
|
+
return resolvedSources;
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
await cleanupResolvedSources(resolvedSources);
|
|
208
|
+
throw error;
|
|
209
|
+
}
|
|
210
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { CliCommandError } from './cli-error.js';
|
|
2
|
+
import { mapApiErrorToCliError } from './session.js';
|
|
3
|
+
const isTerminalJob = (job) => {
|
|
4
|
+
return ['completed', 'failed'].includes(job.status);
|
|
5
|
+
};
|
|
6
|
+
export const serializeCliJob = (job) => {
|
|
7
|
+
return {
|
|
8
|
+
id: job.id,
|
|
9
|
+
status: job.status,
|
|
10
|
+
type: job.type,
|
|
11
|
+
createdAt: job.createdAt,
|
|
12
|
+
updatedAt: job.updatedAt,
|
|
13
|
+
error: job.result?.error,
|
|
14
|
+
exerciseId: job.result?.exerciseId,
|
|
15
|
+
exerciseIds: job.result?.exerciseIds,
|
|
16
|
+
exerciseSheetId: job.result?.exerciseSheetId,
|
|
17
|
+
resultCode: job.result?.resultCode,
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
export const formatCliJobSummary = (job) => {
|
|
21
|
+
if (job.status === 'completed') {
|
|
22
|
+
if (job.exerciseSheetId) {
|
|
23
|
+
return `${job.id} completed -> ${job.exerciseSheetId}`;
|
|
24
|
+
}
|
|
25
|
+
if (job.exerciseId) {
|
|
26
|
+
return `${job.id} completed -> ${job.exerciseId}`;
|
|
27
|
+
}
|
|
28
|
+
return `${job.id} completed`;
|
|
29
|
+
}
|
|
30
|
+
if (job.status === 'failed') {
|
|
31
|
+
return `${job.id} failed -> ${job.error ?? 'Unknown error'}`;
|
|
32
|
+
}
|
|
33
|
+
return `${job.id} ${job.status}`;
|
|
34
|
+
};
|
|
35
|
+
export const getWaitExitCode = ({ jobs, timedOut }) => {
|
|
36
|
+
if (timedOut) {
|
|
37
|
+
return 6;
|
|
38
|
+
}
|
|
39
|
+
if (jobs.some((job) => job.status === 'failed')) {
|
|
40
|
+
return 7;
|
|
41
|
+
}
|
|
42
|
+
return 0;
|
|
43
|
+
};
|
|
44
|
+
export const waitForCliJobs = async ({ getUserJob, jobIds, maxPollIntervalMs = 5000, now, sleep, timeoutMs = 300000, }) => {
|
|
45
|
+
const startedAt = now();
|
|
46
|
+
let nextPollDelayMs = 1000;
|
|
47
|
+
let latestJobs = [];
|
|
48
|
+
while (true) {
|
|
49
|
+
try {
|
|
50
|
+
latestJobs = await Promise.all(jobIds.map(async (jobId) => await getUserJob(jobId)));
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
throw mapApiErrorToCliError(error);
|
|
54
|
+
}
|
|
55
|
+
if (latestJobs.every(isTerminalJob)) {
|
|
56
|
+
return {
|
|
57
|
+
jobs: latestJobs.map(serializeCliJob),
|
|
58
|
+
timedOut: false,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
const elapsedMs = now() - startedAt;
|
|
62
|
+
if (elapsedMs >= timeoutMs) {
|
|
63
|
+
return {
|
|
64
|
+
jobs: latestJobs.map(serializeCliJob),
|
|
65
|
+
timedOut: true,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
const remainingMs = timeoutMs - elapsedMs;
|
|
69
|
+
await sleep(Math.max(0, Math.min(nextPollDelayMs, remainingMs)));
|
|
70
|
+
nextPollDelayMs = Math.min(nextPollDelayMs * 2, maxPollIntervalMs);
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
export const throwSilentExitCode = (exitCode) => {
|
|
74
|
+
if (exitCode === 0) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
throw new CliCommandError('', exitCode, false);
|
|
78
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@chalksurf/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=22"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"bin": {
|
|
14
|
+
"chalksurf": "dist/bin/chalksurf.js"
|
|
15
|
+
},
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/knowledge-maps/chalksurf.git"
|
|
19
|
+
},
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"dev": "node --import tsx ./src/bin/chalksurf.ts",
|
|
25
|
+
"prepack": "npm run build",
|
|
26
|
+
"build": "tsc --project ./tsconfig.build.json",
|
|
27
|
+
"lint": "eslint .",
|
|
28
|
+
"type-check": "tsc --noEmit --project ./tsconfig.json",
|
|
29
|
+
"test": "vitest --config ./vitest.config.ts --run",
|
|
30
|
+
"smoke-pack": "node --import tsx ./scripts/smoke-pack.ts"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"p-limit": "^6.2.0",
|
|
34
|
+
"yargs": "^17.7.2"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@types/node": "^24.10.1",
|
|
38
|
+
"@types/yargs": "^17.0.35",
|
|
39
|
+
"tsx": "^4.20.6",
|
|
40
|
+
"typescript": "^5.0.0",
|
|
41
|
+
"vitest": "^4.1.2"
|
|
42
|
+
}
|
|
43
|
+
}
|