@api-now/cli 1.0.4

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.
@@ -0,0 +1,104 @@
1
+ import { getSdkClient, handleApiError } from '../utils/api.js';
2
+ import { config } from '../utils/config.js';
3
+ import { Formatter, CliErrorCode } from '../utils/formatter.js';
4
+ /**
5
+ * Registers organization commands (list, set-default) with the main Commander program.
6
+ *
7
+ * @param program - The root Commander program.
8
+ */
9
+ export function registerOrgsCommands(program) {
10
+ const orgsCmd = program.command('orgs').description('Manage organization configurations');
11
+ orgsCmd
12
+ .command('list')
13
+ .description('List all organizations you belong to')
14
+ .action(async () => {
15
+ const formatter = new Formatter({ format: config.resolved.format });
16
+ try {
17
+ const client = getSdkClient();
18
+ const res = await client.organizations.list({ token: client.token });
19
+ const orgs = res.data || [];
20
+ const resolved = config.resolved;
21
+ const rows = orgs.map((org) => ({
22
+ id: org.key,
23
+ name: org.name,
24
+ slug: org.slug,
25
+ isDefault: org.key === resolved.org ? 'Yes' : 'No',
26
+ }));
27
+ formatter.list(rows, ['id', 'name', 'slug', 'isDefault'], {
28
+ headers: ['ID', 'NAME', 'SLUG', 'DEFAULT'],
29
+ });
30
+ }
31
+ catch (err) {
32
+ const apiErr = handleApiError(err);
33
+ formatter.error(apiErr);
34
+ process.exit(1);
35
+ }
36
+ });
37
+ orgsCmd
38
+ .command('set-default <oid>')
39
+ .description('Set your default organization ID')
40
+ .action(async (oid) => {
41
+ const formatter = new Formatter({ format: config.resolved.format });
42
+ try {
43
+ const client = getSdkClient();
44
+ const res = await client.organizations.list({ token: client.token });
45
+ const orgs = res.data || [];
46
+ const exists = orgs.some((org) => org.key === oid);
47
+ if (!exists) {
48
+ formatter.error({
49
+ code: CliErrorCode.INVALID_ARGUMENT,
50
+ message: `Organization ID '${oid}' not found in your authorized list.`,
51
+ detail: 'Run `apinow orgs list` to see all authorized organizations and their IDs.',
52
+ });
53
+ process.exit(1);
54
+ }
55
+ config.writeProperty('org', oid);
56
+ formatter.success(`Default organization ID set to '${oid}'.`);
57
+ }
58
+ catch (err) {
59
+ const apiErr = handleApiError(err);
60
+ formatter.error(apiErr);
61
+ process.exit(1);
62
+ }
63
+ });
64
+ orgsCmd
65
+ .command('default')
66
+ .alias('get-default')
67
+ .description('Get the default configured organization')
68
+ .action(async () => {
69
+ const formatter = new Formatter({ format: config.resolved.format });
70
+ try {
71
+ const resolved = config.resolved;
72
+ if (!resolved.org) {
73
+ formatter.error({
74
+ code: CliErrorCode.MISSING_REQUIRED,
75
+ message: 'No default organization configured.',
76
+ detail: 'Set one using `apinow orgs set-default <oid>`. Run `apinow orgs list` to list available organizations.',
77
+ });
78
+ process.exit(1);
79
+ }
80
+ const client = getSdkClient();
81
+ const res = await client.organizations.list({ token: client.token });
82
+ const orgs = res.data || [];
83
+ const org = orgs.find((org) => org.key === resolved.org);
84
+ if (!org) {
85
+ formatter.error({
86
+ code: CliErrorCode.INVALID_ARGUMENT,
87
+ message: `Default organization ID '${resolved.org}' not found in your authorized list.`,
88
+ detail: 'Run `apinow orgs set-default <oid>` to configure a valid default organization.',
89
+ });
90
+ process.exit(1);
91
+ }
92
+ formatter.object({
93
+ id: org.key,
94
+ name: org.name,
95
+ slug: org.slug,
96
+ });
97
+ }
98
+ catch (err) {
99
+ const apiErr = handleApiError(err);
100
+ formatter.error(apiErr);
101
+ process.exit(1);
102
+ }
103
+ });
104
+ }
@@ -0,0 +1,145 @@
1
+ import { getSdkClient, handleApiError } from '../utils/api.js';
2
+ import { config } from '../utils/config.js';
3
+ import { Formatter, CliErrorCode, style } from '../utils/formatter.js';
4
+ /**
5
+ * Helper function to decode the active token and extract its ID (jti or id claim).
6
+ *
7
+ * @returns The active token's ID if available, otherwise null.
8
+ */
9
+ function getActiveTokenId() {
10
+ const token = config.resolved.token;
11
+ if (!token)
12
+ return null;
13
+ try {
14
+ const parts = token.split('.');
15
+ if (parts.length === 3) {
16
+ const payload = parts[1];
17
+ const decoded = Buffer.from(payload, 'base64url').toString('utf8');
18
+ const parsed = JSON.parse(decoded);
19
+ if (parsed && typeof parsed === 'object') {
20
+ return parsed.jti || parsed.id || null;
21
+ }
22
+ }
23
+ }
24
+ catch {
25
+ // Ignore and return null if decoding fails
26
+ }
27
+ return null;
28
+ }
29
+ /**
30
+ * Registers personal access tokens commands (list, create, delete) under the auth command namespace.
31
+ *
32
+ * @param authCmd - The auth subcommand router.
33
+ */
34
+ export function registerTokensCommands(authCmd) {
35
+ const tokensCmd = authCmd.command('tokens').description('Manage personal access tokens');
36
+ tokensCmd
37
+ .command('list')
38
+ .description('List all personal access tokens')
39
+ .action(async () => {
40
+ const formatter = new Formatter({ format: config.resolved.format });
41
+ try {
42
+ const client = getSdkClient();
43
+ if (!client.token) {
44
+ formatter.error({
45
+ code: CliErrorCode.UNAUTHORIZED,
46
+ message: 'Not logged in.',
47
+ detail: 'Use `apinow auth login <provider>` to authenticate.',
48
+ });
49
+ process.exit(1);
50
+ }
51
+ const tokens = await client.users.listTokens({ token: client.token });
52
+ const activeTokenId = getActiveTokenId();
53
+ const rows = tokens.map((token) => ({
54
+ id: token.id,
55
+ name: token.name || 'N/A',
56
+ lastUsedAt: token.lastUsedAt || 'Never',
57
+ expiresAt: token.expiresAt || 'Never',
58
+ active: token.id === activeTokenId ? 'Yes' : 'No',
59
+ }));
60
+ formatter.list(rows, ['id', 'name', 'lastUsedAt', 'expiresAt', 'active'], {
61
+ headers: ['ID', 'NAME', 'LAST USED', 'EXPIRES', 'ACTIVE'],
62
+ });
63
+ }
64
+ catch (err) {
65
+ const apiErr = handleApiError(err);
66
+ formatter.error(apiErr);
67
+ process.exit(1);
68
+ }
69
+ });
70
+ tokensCmd
71
+ .command('create')
72
+ .description('Create a new personal access token')
73
+ .option('--name <name>', 'Optional name to identify the token')
74
+ .option('--expires-at <expires>', 'Optional expiration time (e.g. "30 days", "10 minutes", or timestamp)')
75
+ .action(async (options) => {
76
+ const formatter = new Formatter({ format: config.resolved.format });
77
+ try {
78
+ const client = getSdkClient();
79
+ if (!client.token) {
80
+ formatter.error({
81
+ code: CliErrorCode.UNAUTHORIZED,
82
+ message: 'Not logged in.',
83
+ detail: 'Use `apinow auth login <provider>` to authenticate.',
84
+ });
85
+ process.exit(1);
86
+ }
87
+ const payload = {};
88
+ if (options.name) {
89
+ payload.name = options.name;
90
+ }
91
+ if (options.expiresAt) {
92
+ if (/^\d+$/.test(options.expiresAt)) {
93
+ payload.expiresAt = parseInt(options.expiresAt, 10);
94
+ }
95
+ else {
96
+ payload.expiresAt = options.expiresAt;
97
+ }
98
+ }
99
+ const res = await client.users.createToken(payload, { token: client.token });
100
+ if (config.resolved.format === 'json') {
101
+ formatter.success('Token created successfully!', res);
102
+ }
103
+ else {
104
+ formatter.success('Token created successfully!');
105
+ console.log('\n' +
106
+ style.yellow('⚠ WARNING: Make sure to copy your personal access token now. You will NOT be able to see it again!') +
107
+ '\n');
108
+ console.log(`${style.bold('Raw Token')}: ${style.cyan(res.token)}`);
109
+ console.log(`${style.bold('ID')}: ${res.id}`);
110
+ console.log(`${style.bold('Name')}: ${res.name || 'N/A'}`);
111
+ console.log(`${style.bold('Type')}: ${res.type}`);
112
+ console.log(`${style.bold('Expires At')}: ${res.expiresAt || 'Never'}\n`);
113
+ }
114
+ }
115
+ catch (err) {
116
+ const apiErr = handleApiError(err);
117
+ formatter.error(apiErr);
118
+ process.exit(1);
119
+ }
120
+ });
121
+ tokensCmd
122
+ .command('delete <id>')
123
+ .description('Delete a personal access token by ID')
124
+ .action(async (id) => {
125
+ const formatter = new Formatter({ format: config.resolved.format });
126
+ try {
127
+ const client = getSdkClient();
128
+ if (!client.token) {
129
+ formatter.error({
130
+ code: CliErrorCode.UNAUTHORIZED,
131
+ message: 'Not logged in.',
132
+ detail: 'Use `apinow auth login <provider>` to authenticate.',
133
+ });
134
+ process.exit(1);
135
+ }
136
+ await client.users.deleteToken(id, { token: client.token });
137
+ formatter.success(`Token '${id}' deleted successfully.`);
138
+ }
139
+ catch (err) {
140
+ const apiErr = handleApiError(err);
141
+ formatter.error(apiErr);
142
+ process.exit(1);
143
+ }
144
+ });
145
+ }
package/dist/index.js ADDED
@@ -0,0 +1,83 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Main entry point for the API NOW! Platform Command Line Interface (CLI) tool.
4
+ * Binds global options, imports all subcommands, and executes the parser.
5
+ */
6
+ import { createRequire } from 'node:module';
7
+ import updateNotifier from 'update-notifier';
8
+ import { Command } from 'commander';
9
+ import { registerConfigCommands } from './commands/config.js';
10
+ import { registerAuthCommands } from './commands/auth.js';
11
+ import { registerOrgsCommands } from './commands/orgs.js';
12
+ import { registerFilesCommands } from './commands/files.js';
13
+ import { registerCatalogCommands } from './commands/catalog.js';
14
+ import { config } from './utils/config.js';
15
+ import { Formatter, CliErrorCode } from './utils/formatter.js';
16
+ // Guard Node.js version >= 22
17
+ if (parseInt(process.versions.node.split('.')[0], 10) < 22) {
18
+ console.error('Error: apinow requires Node.js 22 or higher.');
19
+ process.exit(1);
20
+ }
21
+ function getOutputFormat() {
22
+ const index = process.argv.indexOf('--format');
23
+ if (index !== -1 && index + 1 < process.argv.length) {
24
+ const val = process.argv[index + 1];
25
+ if (val === 'json')
26
+ return 'json';
27
+ }
28
+ try {
29
+ return config.resolved.format || 'text';
30
+ }
31
+ catch {
32
+ return 'text';
33
+ }
34
+ }
35
+ process.on('uncaughtException', (err) => {
36
+ const formatter = new Formatter({ format: getOutputFormat() });
37
+ formatter.error({
38
+ code: CliErrorCode.UNKNOWN,
39
+ message: err.message || 'An unexpected error occurred.',
40
+ detail: err.stack,
41
+ });
42
+ process.exit(1);
43
+ });
44
+ process.on('unhandledRejection', (reason) => {
45
+ const formatter = new Formatter({ format: getOutputFormat() });
46
+ const message = reason instanceof Error ? reason.message : String(reason);
47
+ const detail = reason instanceof Error ? reason.stack : undefined;
48
+ formatter.error({
49
+ code: CliErrorCode.UNKNOWN,
50
+ message: `Unhandled promise rejection: ${message}`,
51
+ detail,
52
+ });
53
+ process.exit(1);
54
+ });
55
+ // Load package.json for metadata
56
+ const require = createRequire(import.meta.url);
57
+ const pkg = require('../package.json');
58
+ // Check for updates and notify the user on exit
59
+ updateNotifier({ pkg }).notify();
60
+ // Load configuration initially
61
+ config.load();
62
+ const program = new Command();
63
+ program
64
+ .name('apinow')
65
+ .description('API NOW! Platform CLI Tool')
66
+ .version(pkg.version, '--version')
67
+ .option('--format <format>', 'Output format: text, json', 'text')
68
+ .option('--api-url <url>', 'Override target API server base URL')
69
+ .option('--debug', 'Enable debug/verbose logging');
70
+ program.hook('preAction', () => {
71
+ config.saturate({
72
+ apiUrl: program.opts().apiUrl,
73
+ format: program.opts().format,
74
+ });
75
+ });
76
+ // Register command routers
77
+ registerConfigCommands(program);
78
+ registerAuthCommands(program);
79
+ registerOrgsCommands(program);
80
+ registerFilesCommands(program);
81
+ registerCatalogCommands(program);
82
+ // Parse arguments
83
+ await program.parseAsync(process.argv);
@@ -0,0 +1,54 @@
1
+ import { StoreSdk } from '@api-now/core/sdk/StoreSdkNode.js';
2
+ import { config } from './config.js';
3
+ import { CliErrorCode } from './formatter.js';
4
+ /**
5
+ * Configures and returns a StoreSdk instance for communicating with the API NOW! platform.
6
+ * Reads the token from local configuration and configures it on the SDK automatically.
7
+ *
8
+ * @param options - Configuration overrides for the client connection.
9
+ * @returns A StoreSdk instance ready for requests.
10
+ */
11
+ export function getSdkClient(options = {}) {
12
+ const resolved = config.resolved;
13
+ const apiUrl = options.apiUrl || resolved.apiUrl;
14
+ const sdk = new StoreSdk(apiUrl);
15
+ if (resolved.token) {
16
+ sdk.token = resolved.token;
17
+ }
18
+ return sdk;
19
+ }
20
+ /**
21
+ * Standardizes raw error throws from the SDK into a consistent, readable format.
22
+ *
23
+ * @param error - The raw error object caught in a try/catch block.
24
+ * @returns A standardized ApiErrorResponse object.
25
+ */
26
+ export function handleApiError(error) {
27
+ if (error && typeof error === 'object') {
28
+ const err = error;
29
+ const status = err.status;
30
+ const code = err.code;
31
+ let message = err.message || 'An unknown error occurred.';
32
+ const help = err.help;
33
+ if (status === 401) {
34
+ message = 'Unauthorized. Please login using `auth login`.';
35
+ return {
36
+ message,
37
+ status,
38
+ code: CliErrorCode.UNAUTHORIZED,
39
+ help,
40
+ };
41
+ }
42
+ return {
43
+ message,
44
+ status,
45
+ code: code || CliErrorCode.API_FAILURE,
46
+ help,
47
+ };
48
+ }
49
+ const err = error;
50
+ return {
51
+ message: err?.message || 'An unknown error occurred.',
52
+ code: CliErrorCode.UNKNOWN,
53
+ };
54
+ }
@@ -0,0 +1,202 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ import fs from 'node:fs';
4
+ /**
5
+ * The fallback API URL when not configured.
6
+ */
7
+ const DEFAULT_API_URL = 'http://localhost:8080';
8
+ /**
9
+ * The folder name for the CLI configuration.
10
+ */
11
+ const CLI_FOLDER = 'apinow-cli';
12
+ /**
13
+ * The config file name.
14
+ */
15
+ const CONFIG_FILE = 'config.json';
16
+ /**
17
+ * Manager class for resolving, loading, and saturating the CLI configuration.
18
+ */
19
+ class CliConfigManager {
20
+ _resolved = null;
21
+ /**
22
+ * The resolved read-only CLI configuration.
23
+ */
24
+ get resolved() {
25
+ if (!this._resolved) {
26
+ this.load();
27
+ }
28
+ const res = this._resolved;
29
+ if (!res) {
30
+ throw new Error('Configuration could not be loaded.');
31
+ }
32
+ return res;
33
+ }
34
+ /**
35
+ * The directory path for storing configuration files.
36
+ */
37
+ configDir = this.#getConfigDir();
38
+ /**
39
+ * The absolute configuration file path.
40
+ */
41
+ configPath = path.join(this.configDir, CONFIG_FILE);
42
+ /**
43
+ * Loads configuration from disk and environment variables.
44
+ */
45
+ load() {
46
+ const diskConfig = this.#readConfig();
47
+ const token = process.env.APINOW_API_TOKEN || process.env.APINOW_TOKEN || diskConfig.token;
48
+ const apiUrl = process.env.APINOW_API_URL || diskConfig.apiUrl || DEFAULT_API_URL;
49
+ const org = diskConfig.org || '';
50
+ this._resolved = {
51
+ token,
52
+ apiUrl,
53
+ org,
54
+ format: 'text',
55
+ };
56
+ }
57
+ /**
58
+ * Saturates/overrides configuration fields with commander options.
59
+ */
60
+ saturate(options) {
61
+ if (!this._resolved) {
62
+ this.load();
63
+ }
64
+ const current = this._resolved;
65
+ if (!current) {
66
+ throw new Error('Configuration could not be loaded.');
67
+ }
68
+ this._resolved = {
69
+ token: current.token,
70
+ apiUrl: options.apiUrl || current.apiUrl,
71
+ org: current.org,
72
+ format: options.format === 'json' ? 'json' : 'text',
73
+ };
74
+ }
75
+ /**
76
+ * Returns the directory path for storing configuration files depending on the OS platform.
77
+ *
78
+ * - Windows: `%APPDATA%/apinow-cli`
79
+ * - MacOS: `~/Library/Preferences/apinow-cli`
80
+ * - Linux: `~/.config/apinow-cli` (or respects `$XDG_CONFIG_HOME`)
81
+ *
82
+ * @returns The absolute directory path.
83
+ */
84
+ #getConfigDir() {
85
+ if (process.platform === 'win32') {
86
+ const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
87
+ if (!appData) {
88
+ throw new Error('Could not determine the configuration directory.');
89
+ }
90
+ return path.join(appData, CLI_FOLDER);
91
+ }
92
+ if (process.platform === 'darwin') {
93
+ return path.join(os.homedir(), 'Library', 'Preferences', CLI_FOLDER);
94
+ }
95
+ return path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), CLI_FOLDER);
96
+ }
97
+ /**
98
+ * Reads and parses the CLI configuration from the configuration file.
99
+ * If the configuration file does not exist, returns default settings.
100
+ *
101
+ * @example
102
+ * ```typescript
103
+ * const config = this.#readConfig();
104
+ * console.log(config.apiUrl);
105
+ * ```
106
+ *
107
+ * @returns The current configuration object.
108
+ */
109
+ #readConfig() {
110
+ try {
111
+ if (!fs.existsSync(this.configPath)) {
112
+ return { apiUrl: DEFAULT_API_URL };
113
+ }
114
+ const content = fs.readFileSync(this.configPath, 'utf8');
115
+ const config = JSON.parse(content);
116
+ return {
117
+ apiUrl: DEFAULT_API_URL,
118
+ ...config,
119
+ };
120
+ }
121
+ catch (_error) {
122
+ return { apiUrl: DEFAULT_API_URL };
123
+ }
124
+ }
125
+ /**
126
+ * Writes a single property to the configuration file on disk.
127
+ */
128
+ writeProperty(key, value) {
129
+ const current = this.#readConfig();
130
+ current[key] = value;
131
+ this.#writeConfig(current);
132
+ }
133
+ /**
134
+ * Merges and writes the provided settings to the configuration file on disk.
135
+ * Creates the directory structure if it does not exist.
136
+ *
137
+ * @example
138
+ * ```typescript
139
+ * writeConfig({ token: 'my-secret-token' });
140
+ * ```
141
+ *
142
+ * @param config A partial config object containing keys to set or update.
143
+ */
144
+ #writeConfig(config) {
145
+ try {
146
+ const current = this.#readConfig();
147
+ const updated = { ...current, ...config };
148
+ if (!fs.existsSync(this.configDir)) {
149
+ fs.mkdirSync(this.configDir, { recursive: true });
150
+ }
151
+ fs.writeFileSync(this.configPath, JSON.stringify(updated, null, 2), 'utf8');
152
+ }
153
+ catch (error) {
154
+ console.error('Failed to write configuration:', error);
155
+ }
156
+ }
157
+ /**
158
+ * Clears/removes a specific configuration key from the configuration file.
159
+ *
160
+ * @example
161
+ * ```typescript
162
+ * clearConfigKey('token');
163
+ * ```
164
+ *
165
+ * @param key The key of the CliConfig to remove.
166
+ */
167
+ clearConfigKey(key) {
168
+ try {
169
+ const current = this.#readConfig();
170
+ // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
171
+ delete current[key];
172
+ if (!fs.existsSync(this.configDir)) {
173
+ fs.mkdirSync(this.configDir, { recursive: true });
174
+ }
175
+ fs.writeFileSync(this.configPath, JSON.stringify(current, null, 2), 'utf8');
176
+ }
177
+ catch (error) {
178
+ console.error(`Failed to clear key ${key} from configuration:`, error);
179
+ }
180
+ }
181
+ /**
182
+ * Resets/removes the configuration file and directory.
183
+ */
184
+ reset() {
185
+ try {
186
+ if (fs.existsSync(this.configPath)) {
187
+ fs.unlinkSync(this.configPath);
188
+ }
189
+ if (fs.existsSync(this.configDir)) {
190
+ const files = fs.readdirSync(this.configDir);
191
+ if (files.length === 0) {
192
+ fs.rmdirSync(this.configDir);
193
+ }
194
+ }
195
+ this._resolved = null;
196
+ }
197
+ catch (error) {
198
+ console.error('Failed to reset configuration:', error);
199
+ }
200
+ }
201
+ }
202
+ export const config = new CliConfigManager();