@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 ADDED
@@ -0,0 +1,135 @@
1
+ # `@chalksurf/cli`
2
+
3
+ Publishable Chalksurf CLI package.
4
+
5
+ Current internal release line: `0.1.0`. Expect breaking changes while the CLI is still only used internally.
6
+
7
+ Current milestone scope:
8
+
9
+ - package skeleton and build output
10
+ - `chalksurf --help`
11
+ - `chalksurf --version`
12
+ - `chalksurf auth login --with-token`
13
+ - `chalksurf auth status`
14
+ - `chalksurf auth logout`
15
+ - `chalksurf org list`
16
+ - `chalksurf org use <organization-id>`
17
+ - `chalksurf sheet import [sources...]`
18
+ - `chalksurf job get <job-id>`
19
+ - `chalksurf job wait <job-id> [<job-id>...]`
20
+ - shared output helpers
21
+ - shared HTTP client wrapper
22
+ - secure local config persistence for CLI auth
23
+ - source resolution for local files, directories, and URLs
24
+ - manifest-driven imports and optional job waiting
25
+
26
+ ## Installation
27
+
28
+ Run the published CLI without a global install:
29
+
30
+ ```bash
31
+ npx @chalksurf/cli@0.1.0 --help
32
+ ```
33
+
34
+ Install it globally when you want a persistent local binary:
35
+
36
+ ```bash
37
+ npm install -g @chalksurf/cli@0.1.0
38
+ chalksurf --version
39
+ ```
40
+
41
+ ## Usage
42
+
43
+ Pipe a CLI token into the login command:
44
+
45
+ ```bash
46
+ printf '%s' "$CHALKSURF_TOKEN" | chalksurf auth login --with-token --base-url https://api.chalksurf.com
47
+ ```
48
+
49
+ Check the current session:
50
+
51
+ ```bash
52
+ chalksurf auth status
53
+ chalksurf org list
54
+ ```
55
+
56
+ Select a default organization:
57
+
58
+ ```bash
59
+ chalksurf org use org_123
60
+ ```
61
+
62
+ For headless or CI usage, prefer environment variables:
63
+
64
+ ```bash
65
+ export CHALKSURF_BASE_URL=https://api.chalksurf.com
66
+ export CHALKSURF_TOKEN=cs_cli_...
67
+ export CHALKSURF_ORGANIZATION_ID=org_123
68
+ chalksurf auth status --json
69
+ ```
70
+
71
+ Use the published package directly in agent or CI runs when you do not want a global install:
72
+
73
+ ```bash
74
+ CHALKSURF_BASE_URL=https://api.chalksurf.com \
75
+ CHALKSURF_TOKEN=cs_cli_... \
76
+ CHALKSURF_ORGANIZATION_ID=org_123 \
77
+ npx @chalksurf/cli@0.1.0 auth status --json
78
+ ```
79
+
80
+ Import a local file:
81
+
82
+ ```bash
83
+ chalksurf sheet import ./fixtures/algebra.pdf
84
+ ```
85
+
86
+ Import a URL and override the stored default organization for this run:
87
+
88
+ ```bash
89
+ chalksurf sheet import https://example.com/trig.docx \
90
+ --relative-path Imported/trig.docx \
91
+ --organization org_123
92
+ ```
93
+
94
+ Import from a manifest and wait for the jobs to finish:
95
+
96
+ ```bash
97
+ cat import.json | chalksurf sheet import --manifest - --wait --json
98
+ ```
99
+
100
+ Wait on an existing job:
101
+
102
+ ```bash
103
+ chalksurf job get job_123 --json
104
+ chalksurf job wait job_123 job_124 --json
105
+ ```
106
+
107
+ ## Local And Staging Testing
108
+
109
+ Use separate config files per environment so local and staging tokens do not overwrite each other:
110
+
111
+ ```bash
112
+ npm run cli-dev -- --help
113
+ ```
114
+
115
+ Run the CLI directly from source during local development:
116
+
117
+ ```bash
118
+ export CHALKSURF_CONFIG_PATH=/tmp/chalksurf-local.json
119
+ printf '%s' "$LOCAL_CLI_TOKEN" | npm run cli-dev -- auth login --with-token --base-url http://localhost:8080
120
+ npm run cli-dev -- auth status --json
121
+ npm run cli-dev -- org list --json
122
+ npm run cli-dev -- sheet import ./fixtures/algebra.pdf --wait --json
123
+ npm run cli-dev -- job get job_123 --json
124
+ ```
125
+
126
+ ```bash
127
+ export CHALKSURF_CONFIG_PATH=/tmp/chalksurf-staging.json
128
+ printf '%s' "$STAGING_CLI_TOKEN" | npm run cli-dev -- auth login --with-token --base-url https://staging-api.chalksurf.com
129
+ npm run cli-dev -- auth status --json
130
+ npm run cli-dev -- org list --json
131
+ npm run cli-dev -- sheet import https://example.com/worksheet.docx --relative-path Imported/worksheet.docx --json
132
+ npm run cli-dev -- job wait job_123 --json
133
+ ```
134
+
135
+ For short-lived agent runs, skip local persistence and inject credentials through environment variables instead.
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync, realpathSync } from 'node:fs';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { hideBin } from 'yargs/helpers';
5
+ import yargs from 'yargs/yargs';
6
+ import { registerAuthCommands } from '../commands/auth.js';
7
+ import { registerJobCommands } from '../commands/job.js';
8
+ import { registerOrgCommands } from '../commands/org.js';
9
+ import { registerSheetCommands } from '../commands/sheet.js';
10
+ import { CliCommandError } from '../lib/cli-error.js';
11
+ import { createConfigStore } from '../lib/config-store.js';
12
+ import { createOutput } from '../lib/output.js';
13
+ const packageJson = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
14
+ const createCli = ({ cwd, configPath, env, fetchImpl, now, output, sleep, stdin, }) => {
15
+ const commandContext = {
16
+ cwd,
17
+ configStore: createConfigStore({ configPath, env }),
18
+ env,
19
+ fetchImpl,
20
+ now,
21
+ output,
22
+ sleep,
23
+ stdin,
24
+ };
25
+ return yargs()
26
+ .scriptName('chalksurf')
27
+ .usage('$0 <command> [options]')
28
+ .version(false)
29
+ .option('json', {
30
+ type: 'boolean',
31
+ default: false,
32
+ describe: 'Write machine-readable JSON to stdout',
33
+ })
34
+ .option('base-url', {
35
+ type: 'string',
36
+ global: true,
37
+ describe: 'Base URL of the ChalkSurf API',
38
+ })
39
+ .option('organization', {
40
+ type: 'string',
41
+ global: true,
42
+ describe: 'Organization ID to use for this command',
43
+ })
44
+ .option('version', {
45
+ type: 'boolean',
46
+ alias: 'v',
47
+ describe: 'Show the CLI version',
48
+ })
49
+ .command('auth <subcommand>', 'Authentication commands', (authYargs) => registerAuthCommands(authYargs, commandContext), () => { })
50
+ .command('org <subcommand>', 'Organization commands', (orgYargs) => registerOrgCommands(orgYargs, commandContext), () => { })
51
+ .command('sheet <subcommand>', 'Exercise sheet commands', (sheetYargs) => registerSheetCommands(sheetYargs, commandContext), () => { })
52
+ .command('job <subcommand>', 'Job commands', (jobYargs) => registerJobCommands(jobYargs, commandContext), () => { })
53
+ .help()
54
+ .strict()
55
+ .exitProcess(false);
56
+ };
57
+ const hasFlag = (argv, flags) => {
58
+ return argv.some((argument) => flags.includes(argument));
59
+ };
60
+ export const runCli = async ({ argv, cwd = process.cwd(), configPath, env = process.env, fetchImpl = fetch, nowImpl = Date.now, sleepImpl = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)), stdin = process.stdin, stdout = process.stdout, stderr = process.stderr, }) => {
61
+ const output = createOutput({
62
+ json: hasFlag(argv, ['--json']),
63
+ stdout,
64
+ stderr,
65
+ });
66
+ const cli = createCli({
67
+ cwd,
68
+ configPath,
69
+ env,
70
+ fetchImpl,
71
+ now: nowImpl,
72
+ output,
73
+ sleep: sleepImpl,
74
+ stdin,
75
+ });
76
+ if (argv.length === 0 || hasFlag(argv, ['--help', '-h'])) {
77
+ stdout.write(await cli.getHelp());
78
+ return 0;
79
+ }
80
+ if (hasFlag(argv, ['--version', '-v'])) {
81
+ output.print({ version: packageJson.version }, packageJson.version);
82
+ return 0;
83
+ }
84
+ try {
85
+ await cli.parseAsync(argv);
86
+ return 0;
87
+ }
88
+ catch (error) {
89
+ if (error instanceof CliCommandError) {
90
+ if (error.shouldReport && error.message) {
91
+ output.error(error.message);
92
+ }
93
+ return error.exitCode;
94
+ }
95
+ const message = error instanceof Error ? error.message : 'Unknown CLI error';
96
+ output.error(message);
97
+ return 1;
98
+ }
99
+ };
100
+ export const isDirectExecution = ({ importMetaUrl, processArgv1, }) => {
101
+ if (!processArgv1) {
102
+ return false;
103
+ }
104
+ try {
105
+ return realpathSync(fileURLToPath(importMetaUrl)) === realpathSync(processArgv1);
106
+ }
107
+ catch {
108
+ return false;
109
+ }
110
+ };
111
+ if (isDirectExecution({ importMetaUrl: import.meta.url, processArgv1: process.argv[1] })) {
112
+ void runCli({ argv: hideBin(process.argv) })
113
+ .then((exitCode) => {
114
+ process.exitCode = exitCode;
115
+ })
116
+ .catch((error) => {
117
+ const message = error instanceof Error ? error.message : 'Unknown CLI error';
118
+ process.stderr.write(`${message}\n`);
119
+ process.exitCode = 1;
120
+ });
121
+ }
@@ -0,0 +1,155 @@
1
+ import { createApiClient } from '../lib/api-client.js';
2
+ import { CliCommandError } from '../lib/cli-error.js';
3
+ import { resolveOrganization } from '../lib/config-store.js';
4
+ import { formatUserIdentity, mapApiErrorToCliError, requireResolvedBaseUrl, requireResolvedToken, } from '../lib/session.js';
5
+ const isInteractiveStdin = (stdin) => {
6
+ return stdin.isTTY === true;
7
+ };
8
+ const readTokenFromStdin = async (stdin) => {
9
+ let token = '';
10
+ for await (const chunk of stdin) {
11
+ token += typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8');
12
+ }
13
+ return token.trim();
14
+ };
15
+ const serializeOrganization = (organization) => {
16
+ if (!organization) {
17
+ return null;
18
+ }
19
+ return {
20
+ id: organization.organizationId,
21
+ name: organization.organizationName,
22
+ role: organization.role,
23
+ type: organization.organizationType,
24
+ };
25
+ };
26
+ const formatOrganizationSummary = (organization) => {
27
+ if (!organization) {
28
+ return 'No default organization selected.';
29
+ }
30
+ return `Default organization: ${organization.name} (${organization.id}).`;
31
+ };
32
+ export const registerAuthCommands = (authYargs, context) => {
33
+ return authYargs
34
+ .command('login', 'Read a CLI token from stdin and store it locally', (loginYargs) => {
35
+ return loginYargs.option('with-token', {
36
+ type: 'boolean',
37
+ demandOption: true,
38
+ describe: 'Read the token from stdin',
39
+ });
40
+ }, async (argv) => {
41
+ const config = await context.configStore.load();
42
+ const resolvedBaseUrl = requireResolvedBaseUrl({
43
+ flagValue: argv.baseUrl,
44
+ env: context.env,
45
+ config,
46
+ });
47
+ if (isInteractiveStdin(context.stdin)) {
48
+ throw new CliCommandError('No token was piped on stdin. Pipe a CLI token into "chalksurf auth login --with-token".', 2);
49
+ }
50
+ const token = await readTokenFromStdin(context.stdin);
51
+ if (!token) {
52
+ throw new CliCommandError('No token was provided on stdin.', 2);
53
+ }
54
+ const apiClient = createApiClient({
55
+ baseUrl: resolvedBaseUrl.value,
56
+ token,
57
+ fetchImpl: context.fetchImpl,
58
+ });
59
+ let profile;
60
+ try {
61
+ profile = await apiClient.getUserProfile();
62
+ }
63
+ catch (error) {
64
+ throw mapApiErrorToCliError(error);
65
+ }
66
+ const resolvedOrganization = resolveOrganization({
67
+ flagValue: argv.organization,
68
+ env: context.env,
69
+ config,
70
+ profile,
71
+ });
72
+ await context.configStore.update((currentConfig) => ({
73
+ ...currentConfig,
74
+ token,
75
+ baseUrl: resolvedBaseUrl.value,
76
+ organizationId: resolvedOrganization.organization?.organizationId,
77
+ }));
78
+ resolvedOrganization.warnings.forEach((warning) => {
79
+ context.output.info(warning);
80
+ });
81
+ context.output.print({
82
+ authenticated: true,
83
+ baseUrl: resolvedBaseUrl.value,
84
+ baseUrlSource: resolvedBaseUrl.source,
85
+ organization: serializeOrganization(resolvedOrganization.organization),
86
+ user: {
87
+ email: profile.email,
88
+ id: profile.id,
89
+ name: profile.name,
90
+ },
91
+ warnings: resolvedOrganization.warnings,
92
+ }, (result) => `Logged in as ${formatUserIdentity(result.user)}.\n${formatOrganizationSummary(result.organization)}`);
93
+ })
94
+ .command('status', 'Show the current authentication and organization state', () => { }, async (argv) => {
95
+ const config = await context.configStore.load();
96
+ const resolvedBaseUrl = requireResolvedBaseUrl({
97
+ flagValue: argv.baseUrl,
98
+ env: context.env,
99
+ config,
100
+ });
101
+ const resolvedToken = requireResolvedToken({
102
+ env: context.env,
103
+ config,
104
+ });
105
+ const apiClient = createApiClient({
106
+ baseUrl: resolvedBaseUrl.value,
107
+ token: resolvedToken.value,
108
+ fetchImpl: context.fetchImpl,
109
+ });
110
+ let profile;
111
+ try {
112
+ profile = await apiClient.getUserProfile();
113
+ }
114
+ catch (error) {
115
+ throw mapApiErrorToCliError(error);
116
+ }
117
+ const resolvedOrganization = resolveOrganization({
118
+ flagValue: argv.organization,
119
+ env: context.env,
120
+ config,
121
+ profile,
122
+ });
123
+ resolvedOrganization.warnings.forEach((warning) => {
124
+ context.output.info(warning);
125
+ });
126
+ context.output.print({
127
+ authenticated: true,
128
+ baseUrl: resolvedBaseUrl.value,
129
+ baseUrlSource: resolvedBaseUrl.source,
130
+ organization: serializeOrganization(resolvedOrganization.organization),
131
+ organizationSource: resolvedOrganization.source,
132
+ tokenSource: resolvedToken.source,
133
+ user: {
134
+ email: profile.email,
135
+ id: profile.id,
136
+ name: profile.name,
137
+ },
138
+ warnings: resolvedOrganization.warnings,
139
+ }, (result) => `Authenticated as ${formatUserIdentity(result.user)}.\n${formatOrganizationSummary(result.organization)}`);
140
+ })
141
+ .command('logout', 'Remove the locally stored CLI token', () => { }, async () => {
142
+ const config = await context.configStore.load();
143
+ const hadStoredToken = Boolean(config.token);
144
+ await context.configStore.update((currentConfig) => ({
145
+ ...currentConfig,
146
+ token: undefined,
147
+ }));
148
+ context.output.print({
149
+ clearedStoredToken: hadStoredToken,
150
+ loggedOut: true,
151
+ }, hadStoredToken ? 'Stored CLI token removed.' : 'No stored CLI token was present.');
152
+ })
153
+ .demandCommand(1)
154
+ .strict();
155
+ };
@@ -0,0 +1,94 @@
1
+ import { createApiClient } from '../lib/api-client.js';
2
+ import { CliCommandError } from '../lib/cli-error.js';
3
+ import { mapApiErrorToCliError, requireResolvedBaseUrl, requireResolvedToken, resolveRequestedOrganizationId, } from '../lib/session.js';
4
+ import { formatCliJobSummary, getWaitExitCode, serializeCliJob, throwSilentExitCode, waitForCliJobs, } from '../lib/user-jobs.js';
5
+ const normalizeTimeoutMs = (timeoutMs) => {
6
+ const normalizedTimeoutMs = timeoutMs ?? 300000;
7
+ if (!Number.isFinite(normalizedTimeoutMs) || normalizedTimeoutMs <= 0) {
8
+ throw new CliCommandError('--timeout-ms must be a positive number.', 2);
9
+ }
10
+ return normalizedTimeoutMs;
11
+ };
12
+ export const registerJobCommands = (jobYargs, context) => {
13
+ return jobYargs
14
+ .command('get <jobId>', 'Fetch a single job', (getYargs) => getYargs.positional('jobId', {
15
+ type: 'string',
16
+ describe: 'Job ID to fetch',
17
+ }), async (argv) => {
18
+ const config = await context.configStore.load();
19
+ const resolvedBaseUrl = requireResolvedBaseUrl({
20
+ flagValue: argv.baseUrl,
21
+ env: context.env,
22
+ config,
23
+ });
24
+ const resolvedToken = requireResolvedToken({
25
+ env: context.env,
26
+ config,
27
+ });
28
+ const organizationId = resolveRequestedOrganizationId({
29
+ flagValue: argv.organization,
30
+ env: context.env,
31
+ config,
32
+ });
33
+ const apiClient = createApiClient({
34
+ baseUrl: resolvedBaseUrl.value,
35
+ token: resolvedToken.value,
36
+ organizationId,
37
+ fetchImpl: context.fetchImpl,
38
+ });
39
+ let job;
40
+ try {
41
+ job = await apiClient.getUserJob(String(argv.jobId));
42
+ }
43
+ catch (error) {
44
+ throw mapApiErrorToCliError(error);
45
+ }
46
+ const serializedJob = serializeCliJob(job);
47
+ context.output.print({ job: serializedJob }, (result) => formatCliJobSummary(result.job));
48
+ })
49
+ .command('wait <jobIds..>', 'Poll jobs until they complete, fail, or time out', (waitYargs) => waitYargs
50
+ .positional('jobIds', {
51
+ array: true,
52
+ type: 'string',
53
+ describe: 'One or more job IDs to wait for',
54
+ })
55
+ .option('timeout-ms', {
56
+ type: 'number',
57
+ default: 300000,
58
+ describe: 'Maximum time to wait before timing out',
59
+ }), async (argv) => {
60
+ const config = await context.configStore.load();
61
+ const resolvedBaseUrl = requireResolvedBaseUrl({
62
+ flagValue: argv.baseUrl,
63
+ env: context.env,
64
+ config,
65
+ });
66
+ const resolvedToken = requireResolvedToken({
67
+ env: context.env,
68
+ config,
69
+ });
70
+ const organizationId = resolveRequestedOrganizationId({
71
+ flagValue: argv.organization,
72
+ env: context.env,
73
+ config,
74
+ });
75
+ const apiClient = createApiClient({
76
+ baseUrl: resolvedBaseUrl.value,
77
+ token: resolvedToken.value,
78
+ organizationId,
79
+ fetchImpl: context.fetchImpl,
80
+ });
81
+ const timeoutMs = normalizeTimeoutMs(argv.timeoutMs);
82
+ const waitResult = await waitForCliJobs({
83
+ getUserJob: async (jobId) => await apiClient.getUserJob(jobId),
84
+ jobIds: (argv.jobIds ?? []).map(String),
85
+ now: context.now,
86
+ sleep: context.sleep,
87
+ timeoutMs,
88
+ });
89
+ context.output.print(waitResult, (result) => result.jobs.map((job) => formatCliJobSummary(job)).join('\n'));
90
+ throwSilentExitCode(getWaitExitCode(waitResult));
91
+ })
92
+ .demandCommand(1)
93
+ .strict();
94
+ };
@@ -0,0 +1,106 @@
1
+ import { createApiClient } from '../lib/api-client.js';
2
+ import { CliCommandError } from '../lib/cli-error.js';
3
+ import { resolveOrganization } from '../lib/config-store.js';
4
+ import { mapApiErrorToCliError, requireResolvedBaseUrl, requireResolvedToken } from '../lib/session.js';
5
+ const serializeOrganization = ({ organizationId, organizationName, organizationType, role, selected, }) => {
6
+ return {
7
+ id: organizationId,
8
+ name: organizationName,
9
+ role,
10
+ selected,
11
+ type: organizationType,
12
+ };
13
+ };
14
+ export const registerOrgCommands = (orgYargs, context) => {
15
+ return orgYargs
16
+ .command('list', 'List organizations available to the authenticated user', () => { }, async (argv) => {
17
+ const config = await context.configStore.load();
18
+ const resolvedBaseUrl = requireResolvedBaseUrl({
19
+ flagValue: argv.baseUrl,
20
+ env: context.env,
21
+ config,
22
+ });
23
+ const resolvedToken = requireResolvedToken({
24
+ env: context.env,
25
+ config,
26
+ });
27
+ const apiClient = createApiClient({
28
+ baseUrl: resolvedBaseUrl.value,
29
+ token: resolvedToken.value,
30
+ fetchImpl: context.fetchImpl,
31
+ });
32
+ let profile;
33
+ try {
34
+ profile = await apiClient.getUserProfile();
35
+ }
36
+ catch (error) {
37
+ throw mapApiErrorToCliError(error);
38
+ }
39
+ const resolvedOrganization = resolveOrganization({
40
+ flagValue: argv.organization,
41
+ env: context.env,
42
+ config,
43
+ profile,
44
+ });
45
+ resolvedOrganization.warnings.forEach((warning) => {
46
+ context.output.info(warning);
47
+ });
48
+ const organizations = profile.organizationMemberships.map((membership) => serializeOrganization({
49
+ ...membership,
50
+ selected: membership.organizationId === resolvedOrganization.organization?.organizationId,
51
+ }));
52
+ context.output.print({
53
+ organizations,
54
+ selectedOrganizationId: resolvedOrganization.organization?.organizationId ?? null,
55
+ selectedOrganizationSource: resolvedOrganization.source,
56
+ warnings: resolvedOrganization.warnings,
57
+ }, (result) => result.organizations
58
+ .map((organization) => `${organization.selected ? '*' : ' '} ${organization.name} (${organization.id}) [${organization.type}, ${organization.role}]`)
59
+ .join('\n'));
60
+ })
61
+ .command('use <organizationId>', 'Set the default organization used by the CLI', (useYargs) => {
62
+ return useYargs.positional('organizationId', {
63
+ type: 'string',
64
+ describe: 'Organization ID to store as the default',
65
+ });
66
+ }, async (argv) => {
67
+ const config = await context.configStore.load();
68
+ const resolvedBaseUrl = requireResolvedBaseUrl({
69
+ flagValue: argv.baseUrl,
70
+ env: context.env,
71
+ config,
72
+ });
73
+ const resolvedToken = requireResolvedToken({
74
+ env: context.env,
75
+ config,
76
+ });
77
+ const apiClient = createApiClient({
78
+ baseUrl: resolvedBaseUrl.value,
79
+ token: resolvedToken.value,
80
+ fetchImpl: context.fetchImpl,
81
+ });
82
+ let profile;
83
+ try {
84
+ profile = await apiClient.getUserProfile();
85
+ }
86
+ catch (error) {
87
+ throw mapApiErrorToCliError(error);
88
+ }
89
+ const organization = profile.organizationMemberships.find((membership) => membership.organizationId === String(argv.organizationId));
90
+ if (!organization) {
91
+ throw new CliCommandError(`Organization "${String(argv.organizationId)}" is not accessible to the current user.`, 2);
92
+ }
93
+ await context.configStore.update((currentConfig) => ({
94
+ ...currentConfig,
95
+ organizationId: organization.organizationId,
96
+ }));
97
+ context.output.print({
98
+ organization: serializeOrganization({
99
+ ...organization,
100
+ selected: true,
101
+ }),
102
+ }, (result) => `Default organization set to ${result.organization.name} (${result.organization.id}).`);
103
+ })
104
+ .demandCommand(1)
105
+ .strict();
106
+ };