@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.
@@ -5,7 +5,11 @@ import { CliCommandError } from './cli-error.js';
5
5
  export const chalksurfBaseUrlEnvVar = 'CHALKSURF_BASE_URL';
6
6
  export const chalksurfTokenEnvVar = 'CHALKSURF_TOKEN';
7
7
  export const chalksurfOrganizationIdEnvVar = 'CHALKSURF_ORGANIZATION_ID';
8
+ export const chalksurfProfileEnvVar = 'CHALKSURF_PROFILE';
8
9
  export const chalksurfConfigPathEnvVar = 'CHALKSURF_CONFIG_PATH';
10
+ export const cliConfigSchemaVersion = 2;
11
+ export const implicitDefaultProfileName = 'default';
12
+ const profileNamePattern = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
9
13
  const normalizeOptionalString = (value) => {
10
14
  const trimmedValue = value?.trim();
11
15
  return trimmedValue ? trimmedValue : undefined;
@@ -14,13 +18,91 @@ const normalizeStoredBaseUrl = (value) => {
14
18
  const normalizedValue = normalizeOptionalString(value);
15
19
  return normalizedValue?.replace(/\/+$/, '') || undefined;
16
20
  };
17
- const sanitizeConfig = (config) => {
21
+ const isRecord = (value) => {
22
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
23
+ };
24
+ export const assertValidProfileName = (profileName) => {
25
+ if (!profileNamePattern.test(profileName)) {
26
+ throw new CliCommandError(`Invalid profile "${profileName}". Profile names must start with a letter or number and contain only letters, numbers, ".", "_", or "-".`, 2);
27
+ }
28
+ };
29
+ const normalizeProfileName = (value) => {
30
+ const profileName = normalizeOptionalString(value);
31
+ if (!profileName) {
32
+ return undefined;
33
+ }
34
+ assertValidProfileName(profileName);
35
+ return profileName;
36
+ };
37
+ const sanitizeProfileConfig = (config) => {
18
38
  return Object.fromEntries(Object.entries({
19
39
  token: normalizeOptionalString(config.token),
20
40
  baseUrl: normalizeStoredBaseUrl(config.baseUrl),
21
41
  organizationId: normalizeOptionalString(config.organizationId),
22
42
  }).filter(([, value]) => value !== undefined));
23
43
  };
44
+ const hasProfileConfigValues = (config) => {
45
+ return Boolean(config.baseUrl || config.token || config.organizationId);
46
+ };
47
+ const readProfileConfig = (value) => {
48
+ if (!isRecord(value)) {
49
+ return {};
50
+ }
51
+ return sanitizeProfileConfig({
52
+ baseUrl: typeof value.baseUrl === 'string' ? value.baseUrl : undefined,
53
+ organizationId: typeof value.organizationId === 'string' ? value.organizationId : undefined,
54
+ token: typeof value.token === 'string' ? value.token : undefined,
55
+ });
56
+ };
57
+ const sanitizeRootConfig = (config) => {
58
+ const profiles = Object.fromEntries(Object.entries(config.profiles)
59
+ .map(([profileName, profileConfig]) => {
60
+ const normalizedProfileName = normalizeProfileName(profileName);
61
+ const sanitizedProfileConfig = sanitizeProfileConfig(profileConfig);
62
+ return normalizedProfileName && hasProfileConfigValues(sanitizedProfileConfig)
63
+ ? [normalizedProfileName, sanitizedProfileConfig]
64
+ : null;
65
+ })
66
+ .filter((entry) => entry !== null));
67
+ const defaultProfile = normalizeProfileName(config.defaultProfile);
68
+ return {
69
+ schemaVersion: cliConfigSchemaVersion,
70
+ ...(defaultProfile && profiles[defaultProfile] ? { defaultProfile } : {}),
71
+ profiles,
72
+ };
73
+ };
74
+ const readRootConfig = (value) => {
75
+ if (!isRecord(value)) {
76
+ return { schemaVersion: cliConfigSchemaVersion, profiles: {} };
77
+ }
78
+ if (isRecord(value.profiles)) {
79
+ const profiles = Object.fromEntries(Object.entries(value.profiles)
80
+ .map(([profileName, profileConfig]) => {
81
+ const sanitizedProfileConfig = readProfileConfig(profileConfig);
82
+ return hasProfileConfigValues(sanitizedProfileConfig)
83
+ ? [profileName, sanitizedProfileConfig]
84
+ : null;
85
+ })
86
+ .filter((entry) => entry !== null));
87
+ return sanitizeRootConfig({
88
+ schemaVersion: cliConfigSchemaVersion,
89
+ defaultProfile: typeof value.defaultProfile === 'string' ? value.defaultProfile : undefined,
90
+ profiles,
91
+ });
92
+ }
93
+ const legacyProfile = readProfileConfig(value);
94
+ if (!hasProfileConfigValues(legacyProfile)) {
95
+ return { schemaVersion: cliConfigSchemaVersion, profiles: {} };
96
+ }
97
+ return {
98
+ schemaVersion: cliConfigSchemaVersion,
99
+ defaultProfile: implicitDefaultProfileName,
100
+ profiles: {
101
+ [implicitDefaultProfileName]: legacyProfile,
102
+ },
103
+ };
104
+ };
105
+ const rootConfigHasProfiles = (config) => Object.keys(config.profiles).length > 0;
24
106
  const getDefaultConfigPath = ({ env, platform, homeDirectory, }) => {
25
107
  const explicitConfigPath = normalizeOptionalString(env[chalksurfConfigPathEnvVar]);
26
108
  if (explicitConfigPath) {
@@ -73,6 +155,24 @@ export const resolveToken = ({ env, config }) => {
73
155
  }
74
156
  return { source: 'none' };
75
157
  };
158
+ export const resolveProfile = ({ flagValue, env, config, }) => {
159
+ const flagProfileName = normalizeProfileName(flagValue);
160
+ if (flagProfileName) {
161
+ return { profileName: flagProfileName, profileSource: 'flag' };
162
+ }
163
+ const envProfileName = normalizeProfileName(env[chalksurfProfileEnvVar]);
164
+ if (envProfileName) {
165
+ return { profileName: envProfileName, profileSource: 'env' };
166
+ }
167
+ const defaultProfile = normalizeProfileName(config.defaultProfile);
168
+ if (defaultProfile) {
169
+ return { profileName: defaultProfile, profileSource: 'config' };
170
+ }
171
+ if (!rootConfigHasProfiles(config)) {
172
+ return { profileName: implicitDefaultProfileName, profileSource: 'implicit-default' };
173
+ }
174
+ throw new CliCommandError(`No active ChalkSurf profile selected. Pass --profile, set ${chalksurfProfileEnvVar}, or run "chalksurf profile use <profile>".`, 2);
175
+ };
76
176
  export const resolveOrganization = ({ flagValue, env, config, profile, }) => {
77
177
  const flagOrganizationId = normalizeOptionalString(flagValue);
78
178
  if (flagOrganizationId) {
@@ -134,14 +234,23 @@ export const resolveOrganization = ({ flagValue, env, config, profile, }) => {
134
234
  export const createConfigStore = ({ configPath, env = process.env, platform = process.platform, homeDirectory = homedir(), fs = { chmod, mkdir, readFile, rm, writeFile }, } = {}) => {
135
235
  const resolvedConfigPath = configPath || getDefaultConfigPath({ env, platform, homeDirectory });
136
236
  const load = async () => {
237
+ const rootConfig = await loadRoot();
238
+ const resolvedProfile = resolveProfile({ env, config: rootConfig });
239
+ const profileConfig = rootConfig.profiles[resolvedProfile.profileName] ?? {};
240
+ return {
241
+ ...profileConfig,
242
+ ...resolvedProfile,
243
+ };
244
+ };
245
+ const loadRoot = async () => {
137
246
  try {
138
247
  const configContents = await fs.readFile(resolvedConfigPath, 'utf8');
139
248
  const parsedConfig = JSON.parse(configContents);
140
- return sanitizeConfig(parsedConfig);
249
+ return readRootConfig(parsedConfig);
141
250
  }
142
251
  catch (error) {
143
252
  if (error.code === 'ENOENT') {
144
- return {};
253
+ return { schemaVersion: cliConfigSchemaVersion, profiles: {} };
145
254
  }
146
255
  if (error instanceof SyntaxError) {
147
256
  throw new CliCommandError(`Failed to parse CLI config at ${resolvedConfigPath}.`, 2);
@@ -149,9 +258,18 @@ export const createConfigStore = ({ configPath, env = process.env, platform = pr
149
258
  throw error;
150
259
  }
151
260
  };
152
- const save = async (config) => {
153
- const sanitizedConfig = sanitizeConfig(config);
154
- if (Object.keys(sanitizedConfig).length === 0) {
261
+ const loadProfile = async ({ profileName } = {}) => {
262
+ const rootConfig = await loadRoot();
263
+ const resolvedProfile = resolveProfile({ flagValue: profileName, env, config: rootConfig });
264
+ const profileConfig = rootConfig.profiles[resolvedProfile.profileName] ?? {};
265
+ return {
266
+ ...profileConfig,
267
+ ...resolvedProfile,
268
+ };
269
+ };
270
+ const saveRoot = async (config) => {
271
+ const sanitizedConfig = sanitizeRootConfig(config);
272
+ if (!rootConfigHasProfiles(sanitizedConfig)) {
155
273
  await fs.rm(resolvedConfigPath, { force: true });
156
274
  return sanitizedConfig;
157
275
  }
@@ -162,15 +280,75 @@ export const createConfigStore = ({ configPath, env = process.env, platform = pr
162
280
  await fs.chmod(resolvedConfigPath, 0o600);
163
281
  return sanitizedConfig;
164
282
  };
165
- const update = async (updater) => {
166
- const currentConfig = await load();
167
- const nextConfig = await updater(currentConfig);
168
- return await save(nextConfig);
283
+ const save = async (config) => {
284
+ const rootConfig = await loadRoot();
285
+ const nextProfileConfig = sanitizeProfileConfig(config);
286
+ const nextRootConfig = {
287
+ ...rootConfig,
288
+ profiles: {
289
+ ...rootConfig.profiles,
290
+ },
291
+ };
292
+ if (hasProfileConfigValues(nextProfileConfig)) {
293
+ nextRootConfig.profiles[config.profileName] = nextProfileConfig;
294
+ }
295
+ else {
296
+ delete nextRootConfig.profiles[config.profileName];
297
+ }
298
+ if (config.profileSource === 'implicit-default' && hasProfileConfigValues(nextProfileConfig)) {
299
+ nextRootConfig.defaultProfile = config.profileName;
300
+ }
301
+ return await saveRoot(nextRootConfig);
302
+ };
303
+ const update = async ({ profileName }, updater) => {
304
+ const currentConfig = await loadProfile({ profileName });
305
+ const nextProfileConfig = await updater(currentConfig);
306
+ return await save({
307
+ ...nextProfileConfig,
308
+ profileName: currentConfig.profileName,
309
+ profileSource: currentConfig.profileSource,
310
+ });
311
+ };
312
+ const setDefaultProfile = async (profileName) => {
313
+ const normalizedProfileName = normalizeProfileName(profileName);
314
+ if (!normalizedProfileName) {
315
+ throw new CliCommandError('Profile name is required.', 2);
316
+ }
317
+ const rootConfig = await loadRoot();
318
+ if (!rootConfig.profiles[normalizedProfileName]) {
319
+ throw new CliCommandError(`Profile "${normalizedProfileName}" does not exist. Run "chalksurf auth login --profile ${normalizedProfileName} --base-url <url>" first.`, 2);
320
+ }
321
+ return await saveRoot({
322
+ ...rootConfig,
323
+ defaultProfile: normalizedProfileName,
324
+ });
325
+ };
326
+ const deleteProfile = async (profileName) => {
327
+ const normalizedProfileName = normalizeProfileName(profileName);
328
+ if (!normalizedProfileName) {
329
+ throw new CliCommandError('Profile name is required.', 2);
330
+ }
331
+ const rootConfig = await loadRoot();
332
+ if (!rootConfig.profiles[normalizedProfileName]) {
333
+ throw new CliCommandError(`Profile "${normalizedProfileName}" does not exist.`, 2);
334
+ }
335
+ const profiles = { ...rootConfig.profiles };
336
+ delete profiles[normalizedProfileName];
337
+ return await saveRoot({
338
+ schemaVersion: cliConfigSchemaVersion,
339
+ ...(rootConfig.defaultProfile === normalizedProfileName ? {} : { defaultProfile: rootConfig.defaultProfile }),
340
+ profiles,
341
+ });
169
342
  };
170
343
  return {
171
344
  path: resolvedConfigPath,
172
345
  load,
346
+ loadProfile,
347
+ loadRoot,
173
348
  save,
349
+ saveRoot,
174
350
  update,
351
+ setDefaultProfile,
352
+ deleteProfile,
175
353
  };
176
354
  };
@@ -0,0 +1,120 @@
1
+ import { openAsBlob } from 'node:fs';
2
+ import { stat } from 'node:fs/promises';
3
+ import { extname, resolve } from 'node:path';
4
+ import { CliCommandError } from './cli-error.js';
5
+ const mimeTypesByExtension = {
6
+ '.avif': 'image/avif',
7
+ '.doc': 'application/msword',
8
+ '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
9
+ '.gif': 'image/gif',
10
+ '.htm': 'text/html',
11
+ '.html': 'text/html',
12
+ '.jpeg': 'image/jpeg',
13
+ '.jpg': 'image/jpeg',
14
+ '.md': 'text/markdown',
15
+ '.pdf': 'application/pdf',
16
+ '.png': 'image/png',
17
+ '.svg': 'image/svg+xml',
18
+ '.tex': 'text/plain',
19
+ '.tif': 'image/tiff',
20
+ '.tiff': 'image/tiff',
21
+ '.txt': 'text/plain',
22
+ '.webp': 'image/webp',
23
+ };
24
+ const guessMimeType = (fileName) => {
25
+ return mimeTypesByExtension[extname(fileName).toLowerCase()] ?? 'application/octet-stream';
26
+ };
27
+ const isHttpUrl = (value) => {
28
+ try {
29
+ const parsedUrl = new URL(value);
30
+ return ['http:', 'https:'].includes(parsedUrl.protocol);
31
+ }
32
+ catch {
33
+ return false;
34
+ }
35
+ };
36
+ export const buildFileImportFormData = async (resolvedSources) => {
37
+ const formData = new FormData();
38
+ await Promise.all(resolvedSources.map(async (resolvedSource, index) => {
39
+ const mimeType = resolvedSource.mimeType ?? guessMimeType(resolvedSource.fileName);
40
+ const fileBlob = await openAsBlob(resolvedSource.filePath, { type: mimeType });
41
+ const file = new File([fileBlob], resolvedSource.fileName, { type: mimeType });
42
+ formData.set(`file-${index}`, file);
43
+ formData.set(`path-${index}`, resolvedSource.relativePath);
44
+ }));
45
+ return formData;
46
+ };
47
+ export const buildFileImportCommandSources = async ({ cwd, loadManifest, manifestPath, rawSources, relativePath, stdin, wait, }) => {
48
+ if (manifestPath && rawSources.length > 0) {
49
+ throw new CliCommandError('Pass positional sources or --manifest, not both.', 2);
50
+ }
51
+ if (manifestPath && relativePath) {
52
+ throw new CliCommandError('--relative-path can only be used with a single positional source.', 2);
53
+ }
54
+ if (manifestPath) {
55
+ const manifest = await loadManifest({
56
+ cwd,
57
+ manifestPath,
58
+ stdin,
59
+ });
60
+ const { organizationId, sources, wait: manifestWait, ...manifestExtras } = manifest;
61
+ return {
62
+ organizationId,
63
+ sources,
64
+ wait: wait || manifestWait === true,
65
+ ...manifestExtras,
66
+ };
67
+ }
68
+ if (rawSources.length === 0) {
69
+ throw new CliCommandError('At least one source or --manifest is required.', 2);
70
+ }
71
+ if (relativePath && rawSources.length !== 1) {
72
+ throw new CliCommandError('--relative-path can only be used with a single positional source.', 2);
73
+ }
74
+ const sources = [];
75
+ for (const rawSource of rawSources) {
76
+ if (isHttpUrl(rawSource)) {
77
+ sources.push({
78
+ kind: 'url',
79
+ url: rawSource,
80
+ relativePath,
81
+ });
82
+ continue;
83
+ }
84
+ try {
85
+ const pathStats = await stat(resolve(cwd, rawSource));
86
+ if (pathStats.isDirectory()) {
87
+ if (relativePath) {
88
+ throw new CliCommandError('--relative-path cannot be used with directory sources.', 2);
89
+ }
90
+ sources.push({
91
+ kind: 'directory',
92
+ path: rawSource,
93
+ });
94
+ continue;
95
+ }
96
+ }
97
+ catch (error) {
98
+ if (error.code !== 'ENOENT') {
99
+ throw error;
100
+ }
101
+ }
102
+ sources.push({
103
+ kind: 'local',
104
+ path: rawSource,
105
+ relativePath,
106
+ });
107
+ }
108
+ return {
109
+ organizationId: undefined,
110
+ sources,
111
+ wait: wait === true,
112
+ };
113
+ };
114
+ export const normalizeWaitTimeoutMs = (timeoutMs) => {
115
+ const normalizedTimeoutMs = timeoutMs ?? 300000;
116
+ if (!Number.isFinite(normalizedTimeoutMs) || normalizedTimeoutMs <= 0) {
117
+ throw new CliCommandError('--timeout-ms must be a positive number.', 2);
118
+ }
119
+ return normalizedTimeoutMs;
120
+ };
@@ -0,0 +1,67 @@
1
+ export const buildCliImportSources = ({ resolvedSources, getExtra, }) => {
2
+ return resolvedSources.map((resolvedSource, sourceIndex) => ({
3
+ sourceIndex,
4
+ sourceInputIndex: resolvedSource.sourceInputIndex,
5
+ sourceId: resolvedSource.sourceInput.sourceId ?? null,
6
+ kind: resolvedSource.kind,
7
+ input: resolvedSource.input,
8
+ fileName: resolvedSource.fileName,
9
+ relativePath: resolvedSource.relativePath,
10
+ ...(getExtra ? getExtra(resolvedSource) : {}),
11
+ }));
12
+ };
13
+ export const summarizeCliImportJobs = ({ jobs, sourceCount, sourceInputCount, timedOut, }) => {
14
+ const summary = {
15
+ sourceCount,
16
+ sourceInputCount,
17
+ jobCount: jobs.length,
18
+ queued: 0,
19
+ pending: 0,
20
+ inProgress: 0,
21
+ completed: 0,
22
+ failed: 0,
23
+ timedOut,
24
+ };
25
+ for (const job of jobs) {
26
+ if (job.status === 'queued') {
27
+ summary.queued += 1;
28
+ continue;
29
+ }
30
+ if (job.status === 'pending') {
31
+ summary.pending += 1;
32
+ continue;
33
+ }
34
+ if (job.status === 'in_progress') {
35
+ summary.inProgress += 1;
36
+ continue;
37
+ }
38
+ if (job.status === 'completed') {
39
+ summary.completed += 1;
40
+ continue;
41
+ }
42
+ summary.failed += 1;
43
+ }
44
+ return summary;
45
+ };
46
+ export const formatCliImportSummary = (summary) => {
47
+ const parts = [];
48
+ if (summary.queued > 0) {
49
+ parts.push(`${summary.queued} queued`);
50
+ }
51
+ if (summary.pending > 0) {
52
+ parts.push(`${summary.pending} pending`);
53
+ }
54
+ if (summary.inProgress > 0) {
55
+ parts.push(`${summary.inProgress} in progress`);
56
+ }
57
+ if (summary.completed > 0) {
58
+ parts.push(`${summary.completed} completed`);
59
+ }
60
+ if (summary.failed > 0) {
61
+ parts.push(`${summary.failed} failed`);
62
+ }
63
+ if (summary.timedOut) {
64
+ parts.push('timed out');
65
+ }
66
+ return `Summary: ${parts.join(', ') || '0 jobs'}.`;
67
+ };