diff --git a/README.md b/README.md index d52a3df..d422660 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ FLB (Fleetbase CLI) is a command-line interface tool designed for managing Fleet - Set registry token to a Fleetbase instance - Search and list available extensions from the registry - Install and Uninstall extensions -- Flexible registry configuration +- Flexible registry configuration: one config file (`~/.fleetbase/config.json`) and a `--host` option for self-hosted instances ## Installation @@ -47,16 +47,18 @@ Check your email for a verification code, then run: flb verify -e your@email.com -c 123456 --host localhost:8000 ``` -Or use the command provided in the verification email. Upon successful verification, you'll receive your registry token. +Or use the command provided in the verification email. Upon successful verification, a registry token is issued and saved to `~/.fleetbase/config.json`. The token is not printed unless you pass `--show-token`. ### Step 3: Set Your Registry Token -Use the token from the verification step: +Write the token into your Fleetbase instance (the token saved in step 2 is used when none is given): ```bash -flb set-auth flb_your_token_here --registry http://localhost:8000 +flb set-auth --registry http://localhost:8000 --host localhost:8000 --path /path/to/fleetbase ``` +Passing `--host` here also stores `localhost:8000` as the default API host in `~/.fleetbase/config.json`, so later commands such as `flb install` or `flb search` no longer need `--host`. + ### Step 4: You're Ready! You can now publish extensions, install extensions, and manage your Fleetbase instance through the CLI. @@ -94,7 +96,7 @@ flb register -u myusername -e my@email.com -p mypassword --host localhost:8000 #### Verify Email Address -Verify your email address using the code sent to your email. +Verify your email address using the code sent to your email. On success the issued registry token is saved to `~/.fleetbase/config.json` (keyed by API host) and shown masked. ```bash flb verify @@ -104,6 +106,7 @@ flb verify - `-e, --email `: Email address - `-c, --code `: Verification code from email - `-h, --host `: API host (default: `https://api.fleetbase.io`) +- `--show-token`: Print the issued token in full (hidden by default) **Example:** ```bash @@ -139,13 +142,14 @@ flb generate-token - `-e, --email `: Email address - `-p, --password `: Password - `-h, --host `: API host (default: `https://api.fleetbase.io`) +- `--show-token`: Print the generated token in full (hidden by default) **Example:** ```bash flb generate-token -e my@email.com --host localhost:8000 ``` -**Note:** This command requires your account to be verified. Each time you generate a token, it replaces the previous one. +**Note:** This command requires your account to be verified. Each time you generate a token, it replaces the previous one. The new token is saved to `~/.fleetbase/config.json`. ### Installing Fleetbase @@ -206,13 +210,17 @@ To setup registry auth use: flb set-auth [token] ``` +`set-auth` stores the token in `~/.fleetbase/config.json` (keyed by registry host) and writes a `bearer` entry for Composer into `/api/auth.json`. It no longer appends to `/console/.npmrc`. If `[token]` is omitted, the token previously saved by `flb verify`, `flb generate-token` or `flb set-auth` for that host is reused. + **Options:** +- `[token]`: Registry token (optional when one is already stored for the registry or API host) - `-p, --path `: Path of the Fleetbase instance to install setup for (default: `.`) - `-r, --registry `: Registry URL the credentials are for (default: `https://registry.fleetbase.io`) +- `--host `: Fleetbase API host; also saved as the default host in `~/.fleetbase/config.json` **Example:** ```bash -flb set-auth flb_your_token_here --registry http://localhost:8000 +flb set-auth flb_your_token_here --registry http://localhost:8000 --host localhost:8000 ``` ### Login to the Fleetbase Registry @@ -227,14 +235,17 @@ flb login - `-u, --username `: Username for the registry - `-p, --password `: Password for the registry - `-e, --email `: Email associated with your account -- `-r, --registry `: Registry URL (default: `https://registry.fleetbase.io`) +- `-r, --registry `: Registry URL (default: `--host` if given, otherwise `https://registry.fleetbase.io`) +- `--host `: Self-hosted Fleetbase API host; used as the registry unless `--registry` is given - `--scope `: Scope for the registry (optional) - `--quotes `: Quotes option for `npm-cli-login` (optional) - `--config-path `: Path to the npm config file (optional) +The command exits with a non-zero status if the login fails. + **Example:** ```bash -flb login -u myusername -r http://localhost:8000 +flb login -u myusername -p mypassword -e my@email.com --host localhost:8000 ``` ### Scaffolding a Extension @@ -254,8 +265,8 @@ flb scaffold - `-a, --author`: The name of the extension author - `-e, --email`: The email of the extension author - `-k, --keywords`: The keywords of the extension to scaffold -- `-n, --namespace`: The PHP Namespace of the extension to scaffold -- `-r, --repo`: The Repository URL of the extension to scaffold +- `--namespace`: The PHP Namespace of the extension to scaffold +- `-r, --repo`: The Repository URL of the extension to scaffold (default: none; `extension.json` and `package.json` get no `repository` unless you provide one) ### Searching for Extensions @@ -273,7 +284,7 @@ Alias: `flb list-extensions` - `-f, --free`: Show only free extensions - `--json`: Output results as raw JSON (useful for scripting) - `--simple`: Output one extension per line as tab-separated values: `slug`, `name`, `version`, `price` -- `-h, --host `: API host to fetch extensions from (default: `https://api.fleetbase.io`) +- `-h, --host `: API host to fetch extensions from (default: host in `~/.fleetbase/config.json`, then `https://api.fleetbase.io`) **Examples:** ```bash @@ -316,10 +327,14 @@ flb install [extension] **Options:** - `[extension]`: The name of the extension to install - `-p, --path`: (Optional) The path to the fleetbase instance directory. Defaults to the current directory +- `--host `: (Optional) Fleetbase API host used to look up the package (default: host in `~/.fleetbase/config.json`, then `https://api.fleetbase.io`) + +The command exits with a non-zero status if the lookup or installation fails. **Example:** ```bash flb install @fleetbase/storefront-api --path /opt/fleetbase +flb install @fleetbase/storefront-api --path /opt/fleetbase --host localhost:8000 ``` ### Uninstalling a Extension @@ -333,6 +348,7 @@ flb uninstall [extension] **Options:** - `[extension]`: The name of the extension to uninstall - `-p, --path`: (Optional) The path to the fleetbase instance directory. Defaults to the current directory +- `--host `: (Optional) Fleetbase API host used to look up the package (default: host in `~/.fleetbase/config.json`, then `https://api.fleetbase.io`) **Example:** ```bash @@ -356,8 +372,8 @@ flb bundle --upload **Options:** - `-p, --path `: Path of the Fleetbase extension (default: `.`) - `--upload`: After bundling, upload the bundle to the Fleetbase registry using your authentication token -- `--auth-token `: Auth token for uploading the bundle (used with `--upload` option) -- `-r, --registry `: Registry URL (default: `https://registry.fleetbase.io`) +- `--auth-token `: Auth token for uploading the bundle (used with `--upload` option). Defaults to the token stored in `~/.fleetbase/config.json` for the API host (then the registry host) +- `--host `: Fleetbase API host to upload to (default: host in `~/.fleetbase/config.json`, then `https://api.fleetbase.io`) ### Uploading a Extension Bundle @@ -367,11 +383,13 @@ To upload an extension bundle, use: flb bundle-upload ``` +Alias: `flb upload-bundle` + **Options:** -- `[bundleFile]`: Path to the bundle file to upload. If not provided, it will look for the bundle in the current directory +- `[bundleFile]`: Path to the bundle file to upload. If not provided, it will look for the latest bundle in the current directory - `-p, --path `: Path where the bundle is located (default: `.`) -- `--auth-token `: Auth token for uploading the bundle. If not provided, the token will be read from the `.npmrc` file -- `-r, --registry `: Registry URL (default: `https://registry.fleetbase.io`) +- `--auth-token `: Auth token for uploading the bundle. If not provided, the token is read from `~/.fleetbase/config.json` (never from `.npmrc`) +- `--host `: Fleetbase API host to upload to (default: host in `~/.fleetbase/config.json`, then `https://api.fleetbase.io`) ### Version Bump and Extension @@ -399,10 +417,40 @@ flb unpublish -r http://my-registry.com ## Configuration -FLB can be configured via command-line options. The most common options include: +### Config file + +FLB keeps its settings in `~/.fleetbase/config.json` (override the directory with the `FLEETBASE_HOME` environment variable). The file is created with mode `0600` and looks like: + +```json +{ + "host": "http://localhost:8000", + "registry": "http://localhost:8000", + "tokens": { + "localhost:8000": "flb_...", + "api.fleetbase.io": "flb_..." + }, + "defaultOrganization": null +} +``` + +- `host`: default API host used when `--host` is not given +- `tokens`: registry tokens keyed by host (`host[:port]`). Written by `flb verify`, `flb generate-token` and `flb set-auth`; read by `flb bundle --upload`, `flb bundle-upload` and `flb set-auth` +- `registry` and `defaultOrganization` are reserved for upcoming CLI features -- `-r, --registry [url]`: Specify a custom registry URL -- `-h, --host [url]`: Specify the API host for developer account operations +### Global options + +- `--host `: Fleetbase API host. Precedence: `--host` flag > `host` in `~/.fleetbase/config.json` > `https://api.fleetbase.io`. Accepted on `install`, `uninstall`, `bundle`, `bundle-upload`, `search`, `register`, `verify`, `resend-verification`, `generate-token`, `login` and `set-auth`, or globally before the command (`flb --host localhost:8000 install ...`) +- `-r, --registry [url]`: Specify a custom registry URL (default: `https://registry.fleetbase.io`) + +Global options are positional: when given before the command name they apply to every command; `install-fleetbase --host` keeps its own meaning (the address to bind to). + +### Host normalisation + +A host may be given with or without a scheme: + +- an existing scheme is kept (`https://api.example.com`, `http://localhost:8000`) +- `localhost`, `127.0.0.1` and hosts with an explicit port get `http://` (`localhost:8000` → `http://localhost:8000`) +- everything else gets `https://` (`api.example.com` → `https://api.example.com`) ## Self-Hosted vs Cloud @@ -410,10 +458,10 @@ FLB can be configured via command-line options. The most common options include: If you're running Fleetbase on your own infrastructure: -1. Use `flb register` to create a Registry Developer Account -2. Verify your email with `flb verify` -3. Use the provided token with `flb set-auth` -4. Specify `--host` parameter for all commands to point to your instance +1. Use `flb register --host ` to create a Registry Developer Account +2. Verify your email with `flb verify --host `; the token is saved to `~/.fleetbase/config.json` +3. Run `flb set-auth --registry --host --path ` to configure your instance and store the default host +4. Later commands use the stored host; pass `--host` to override it per command ### Cloud Users @@ -427,6 +475,12 @@ If you're using Fleetbase Cloud (console.fleetbase.io): Contributions to Fleetbase CLI are welcome. Please ensure that your code adheres to the project's coding standards and include tests for new features or bug fixes. +Run the test suite with: + +```bash +npm test +``` + # License & Copyright Fleetbase is made available under the terms of the GNU Affero General Public License 3.0 (AGPL 3.0). For other licenses contact us. diff --git a/index.js b/index.js index 4776753..6cde497 100755 --- a/index.js +++ b/index.js @@ -11,13 +11,176 @@ const semver = require('semver'); const glob = require('glob-promise'); const FormData = require('form-data'); const _ = require('lodash'); +const os = require('os'); const packageJson = require('./package.json'); const maxBuffer = 1024 * 1024 * 50; // 50MB const defaultRegistry = 'https://registry.fleetbase.io'; -const packageLookupApi = 'https://api.fleetbase.io/~registry/v1/lookup'; -const bundleUploadApi = 'https://api.fleetbase.io/~registry/v1/bundle-upload'; -const extensionsListApi = 'https://api.fleetbase.io/~registry/v1/extensions'; +const defaultHost = 'https://api.fleetbase.io'; const starterExtensionRepo = 'https://github.com/fleetbase/starter-extension.git'; +// CLI config lives in ~/.fleetbase/config.json (override the directory with FLEETBASE_HOME, e.g. for tests) +const configDir = process.env.FLEETBASE_HOME || path.join(os.homedir(), '.fleetbase'); +const configFilePath = path.join(configDir, 'config.json'); +const configDisplayPath = process.env.FLEETBASE_HOME ? configFilePath : '~/.fleetbase/config.json'; + +// ─── Host and config helpers ────────────────────────────────────────────────── + +/** + * Normalise a host into a URL with a scheme. + * - an existing scheme is kept as-is + * - localhost / 127.0.0.1 / hosts with an explicit port get http:// + * - everything else gets https:// + * Trailing slashes are stripped. + * @param {string} host + * @returns {string} + */ +function normalizeHost (host) { + if (typeof host !== 'string') { + return host; + } + + const value = host.trim().replace(/\/+$/, ''); + if (!value) { + return value; + } + + if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value)) { + return value; + } + + const authority = value.split('/')[0]; + // "[::1]:8000" -> hostname "::1"; "localhost:8000" -> "localhost" + const bracketed = authority.match(/^\[([^\]]+)\](?::(\d+))?$/); + const hostname = (bracketed ? bracketed[1] : authority.replace(/:\d+$/, '')).toLowerCase(); + const hasPort = bracketed ? Boolean(bracketed[2]) : /:\d+$/.test(authority); + const isLocal = ['localhost', '127.0.0.1', '0.0.0.0', '::1'].includes(hostname) || hostname.endsWith('.localhost'); + const scheme = isLocal || hasPort ? 'http' : 'https'; + + return `${scheme}://${value}`; +} + +/** + * The host[:port] part of a URL, used as the key for stored tokens. + * @param {string} host + * @returns {string} + */ +function hostKey (host) { + return new URL(normalizeHost(host)).host; +} + +function isDefaultHost (host) { + return normalizeHost(host) === defaultHost; +} + +/** Suffix for printed follow-up commands: empty for the default host. */ +function hostFlag (host) { + return isDefaultHost(host) ? '' : ` --host ${host}`; +} + +function maskToken (token) { + const value = String(token ?? ''); + if (value.length <= 8) { + return '********'; + } + return `${value.slice(0, 4)}${'*'.repeat(Math.min(value.length - 8, 12))}${value.slice(-4)}`; +} + +/** + * Read ~/.fleetbase/config.json. Always resolves to an object. + * Shape: { host, registry, tokens: { "": "" }, defaultOrganization } + */ +async function readConfig () { + try { + if (await fs.pathExists(configFilePath)) { + const config = await fs.readJson(configFilePath); + if (config && typeof config === 'object' && !Array.isArray(config)) { + return config; + } + } + } catch (error) { + console.warn(`Warning: could not read ${configFilePath}: ${error.message}`); + } + + return {}; +} + +async function writeConfig (config) { + await fs.ensureDir(configDir); + await fs.writeJson(configFilePath, config, { spaces: 4, mode: 0o600 }); + try { + await fs.chmod(configFilePath, 0o600); + } catch (error) { + // best effort: the file holds tokens, but not every filesystem supports chmod + } + + return configFilePath; +} + +async function updateConfig (updater) { + const config = await readConfig(); + const next = (await updater(config)) || config; + await writeConfig(next); + return next; +} + +/** Store a token for a host (keyed by host[:port]). */ +async function saveToken (host, token) { + return updateConfig(config => { + config.tokens = { ...(config.tokens || {}), [hostKey(host)]: token }; + }); +} + +/** First stored token found for any of the given hosts, or null. */ +async function getStoredToken (...hosts) { + const config = await readConfig(); + const tokens = config.tokens || {}; + + for (const host of hosts) { + if (!host) { + continue; + } + const token = tokens[hostKey(host)]; + if (token) { + return token; + } + } + + return null; +} + +/** + * Resolve the API host: first truthy candidate (flags) > config file > default. + * @param {...string} candidates + * @returns {Promise} + */ +async function resolveHost (...candidates) { + for (const candidate of candidates) { + if (candidate) { + return normalizeHost(candidate); + } + } + + const config = await readConfig(); + if (config.host) { + return normalizeHost(config.host); + } + + return defaultHost; +} + +/** Build a registry API URL on the given host. */ +function registryApi (host, endpoint) { + return `${normalizeHost(host)}/~registry/v1/${endpoint.replace(/^\/+/, '')}`; +} + +/** Host flag for the current invocation: command option, then the global --host. */ +function hostOption (options = {}) { + return options.host || program.opts().host; +} + +/** Registry for the current invocation: command option, then the global --registry, then the default. */ +function registryOption (options = {}) { + return options.registry || program.opts().registry || defaultRegistry; +} function publishPackage (packagePath, registry, options = {}) { if (typeof options.onBefore === 'function') { @@ -130,16 +293,31 @@ function onAfterPublishComposer (packagePath) { fs.removeSync(path.join(packagePath, 'package.json')); } -async function setAuth (token, fleetbasePath = '.', fleetbaseRegistry = defaultRegistry) { +async function setAuth (token, fleetbasePath = '.', fleetbaseRegistry = defaultRegistry, apiHost = null) { try { if (!token) { - console.error('Auth token is required.'); + // Fall back to a token previously stored by `flb verify` / `flb generate-token` / `flb set-auth` + token = await getStoredToken(fleetbaseRegistry, apiHost); + } + + if (!token) { + console.error(`Auth token is required (none stored for ${fleetbaseRegistry} in ${configFilePath}).`); process.exit(1); } + // Store the token in ~/.fleetbase/config.json, keyed by host + await updateConfig(config => { + config.tokens = { ...(config.tokens || {}), [hostKey(fleetbaseRegistry)]: token }; + if (apiHost) { + config.host = normalizeHost(apiHost); + config.tokens[hostKey(apiHost)] = token; + } + }); + console.log(`Registry token saved to ${configFilePath} for ${hostKey(fleetbaseRegistry)}`); + // Expand ~ to the home directory if present if (fleetbasePath.startsWith('~')) { - fleetbasePath = path.join(require('os').homedir(), fleetbasePath.slice(1)); + fleetbasePath = path.join(os.homedir(), fleetbasePath.slice(1)); } // Determine Fleetbase path @@ -158,25 +336,18 @@ async function setAuth (token, fleetbasePath = '.', fleetbaseRegistry = defaultR currentPath = defaultFleetbasePath; } - const npmrcPath = path.join(currentPath, 'console', '.npmrc'); const composerAuthPath = path.join(currentPath, 'api', 'auth.json'); - // Set the npmrc token - const authString = `//${new URL(fleetbaseRegistry).host}/:_authToken="${token}"\n`; - - // Append to .npmrc if it exists, otherwise create and write - if (await fs.pathExists(npmrcPath)) { - await fs.appendFile(npmrcPath, authString); - console.log(`NPM auth token set in ${npmrcPath}`); - } else { - await fs.writeFile(npmrcPath, authString); - console.log(`NPM auth token set in ${npmrcPath}`); + // Without a Fleetbase instance there is nothing more to write; the token is already stored in the config file + if (!(await fs.pathExists(path.join(currentPath, 'api')))) { + console.warn(`No Fleetbase instance found at ${path.resolve(fleetbasePath || '.')} or ${defaultFleetbasePath}; skipped writing api/auth.json.`); + return; } // Set the composer auth token const newBearerConfig = { bearer: { - [new URL(fleetbaseRegistry).host]: token, + [hostKey(fleetbaseRegistry)]: token, }, }; @@ -202,11 +373,17 @@ async function setAuth (token, fleetbasePath = '.', fleetbaseRegistry = defaultR } } -async function uninstallPackage (packageName, fleetbasePath = '.') { +async function uninstallPackage (packageName, fleetbasePath = '.', host = null) { try { + if (!packageName) { + throw new Error('Package name is required'); + } + + const apiHost = await resolveHost(host); + // Expand ~ to the home directory if present if (fleetbasePath.startsWith('~')) { - fleetbasePath = path.join(require('os').homedir(), fleetbasePath.slice(1)); + fleetbasePath = path.join(os.homedir(), fleetbasePath.slice(1)); } // Resolve the Fleetbase instance path @@ -224,7 +401,7 @@ async function uninstallPackage (packageName, fleetbasePath = '.') { } // Make the GET request to the lookup API - const response = await axios.get(packageLookupApi, { + const response = await axios.get(registryApi(apiHost, 'lookup'), { params: { package: packageName }, }); @@ -242,15 +419,22 @@ async function uninstallPackage (packageName, fleetbasePath = '.') { console.log('Package uninstall successful!'); } catch (error) { - console.error(`Unnstall failed: ${error.message}`); + console.error(`Uninstall failed: ${error.response?.data?.error ?? error.response?.data?.message ?? error.message}`); + process.exit(1); } } -async function installPackage (packageName, fleetbasePath = '.') { +async function installPackage (packageName, fleetbasePath = '.', host = null) { try { + if (!packageName) { + throw new Error('Package name is required'); + } + + const apiHost = await resolveHost(host); + // Expand ~ to the home directory if present if (fleetbasePath.startsWith('~')) { - fleetbasePath = path.join(require('os').homedir(), fleetbasePath.slice(1)); + fleetbasePath = path.join(os.homedir(), fleetbasePath.slice(1)); } // Resolve the Fleetbase instance path @@ -268,7 +452,7 @@ async function installPackage (packageName, fleetbasePath = '.') { } // Make the GET request to the lookup API - const response = await axios.get(packageLookupApi, { + const response = await axios.get(registryApi(apiHost, 'lookup'), { params: { package: packageName }, }); @@ -286,7 +470,8 @@ async function installPackage (packageName, fleetbasePath = '.') { console.log('Package installation successful!'); } catch (error) { - console.error(`Installation failed: ${error.message}`); + console.error(`Installation failed: ${error.response?.data?.error ?? error.response?.data?.message ?? error.message}`); + process.exit(1); } } @@ -303,8 +488,8 @@ async function scaffoldExtension (options) { { type: 'input', name: 'repo', message: 'Repository URL:', initial: options.repo }, ]); - const targetDirName = _.kebabCase(answers.name); - const targetPath = path.resolve(options.path || '.', targetDirName); + let targetDirName = _.kebabCase(answers.name); + let targetPath = path.resolve(options.path || '.', targetDirName); // Check if the target directory exists, and if so, append "-1", "-2", etc. until a unique directory is found let counter = 1; @@ -342,18 +527,21 @@ async function scaffoldExtension (options) { const configPath = path.join(targetPath, `server/config/starter.php`); const routesPath = path.join(targetPath, `server/src/routes.php`); + // Only set a repository when one was given; otherwise drop the starter repo's value + const repository = answers.repo && answers.repo.trim() ? answers.repo.trim() : undefined; + // Load and update files await updateJsonFile(extensionJsonPath, { name: answers.name, description: answers.description, - repository: answers.repo, + repository, author: `${answers.author} <${answers.email}>`, }); await updateJsonFile(packageJsonPath, { name: packageJsonName, description: answers.description, - repository: answers.repo, + repository, author: `${answers.author} <${answers.email}>`, keywords: keywordsArray, fleetbase: { @@ -399,6 +587,7 @@ async function scaffoldExtension (options) { console.log('Extension scaffolded successfully!'); } catch (error) { console.error(`Error scaffolding extension: ${error.message}`); + process.exit(1); } } @@ -583,7 +772,7 @@ async function bundleExtension (options) { if (upload) { // Call upload function with the bundle path - await uploadBundle(bundlePath, options); + await uploadBundle(bundlePath, { ...options, registry: registryOption(options), host: hostOption(options) }); } } catch (error) { console.error(`Error bundling extension: ${error.message}`); @@ -594,18 +783,24 @@ async function bundleExtension (options) { // Function to upload the bundle async function uploadBundle (bundlePath, options) { const registry = options.registry || defaultRegistry; - const uploadUrl = bundleUploadApi; + const apiHost = await resolveHost(options.host); + const uploadUrl = registryApi(apiHost, 'bundle-upload'); let authToken = options.authToken; if (!authToken) { - // Try to get auth token from ~/.npmrc - authToken = await getAuthToken(registry); + // Try to get the auth token from ~/.fleetbase/config.json (keyed by API host, then registry host) + authToken = await getStoredToken(apiHost, registry); if (!authToken) { - console.error(`Auth token not found for registry ${registry}. Please provide an auth token using the --auth-token option.`); + console.error(`Auth token not found for ${hostKey(apiHost)} or ${hostKey(registry)} in ${configFilePath}. Run \`flb set-auth \` or pass --auth-token.`); process.exit(1); } } + if (!(await fs.pathExists(bundlePath))) { + console.error(`Bundle file not found: ${bundlePath}`); + process.exit(1); + } + try { const form = new FormData(); form.append('bundle', fs.createReadStream(bundlePath)); @@ -619,35 +814,16 @@ async function uploadBundle (bundlePath, options) { maxBodyLength: Infinity, }); - console.log(`Bundle uploaded successfully: ${response.data.message}`); + console.log(`Bundle uploaded successfully: ${response.data?.message ?? 'OK'}`); } catch (error) { - console.log(error.response.data); - console.error(`Error uploading bundle: ${error.response.data?.error ?? error.message}`); - process.exit(1); - } -} - -// Function to get the auth token from .npmrc -async function getAuthToken (registryUrl) { - const npmrcPath = path.join(require('os').homedir(), '.npmrc'); - if (!(await fs.pathExists(npmrcPath))) { - return null; - } - - const npmrcContent = await fs.readFile(npmrcPath, 'utf-8'); - const lines = npmrcContent.split('\n'); - - const registryHost = new URL(registryUrl).host; - - // Look for line matching //registry.fleetbase.io/:_authToken=... - for (const line of lines) { - const match = line.match(new RegExp(`^//${registryHost}/:_authToken=(.*)$`)); - if (match) { - return match[1].replace(/^"|"$/g, ''); // Remove quotes if present + const data = error.response?.data; + const message = (data && typeof data === 'object' && (data.error || data.message)) || (typeof data === 'string' && data) || error.message; + if (error.response) { + console.error(`Upload failed with HTTP ${error.response.status} from ${uploadUrl}`); } + console.error(`Error uploading bundle: ${message}`); + process.exit(1); } - - return null; } // Function to find the latest bundle @@ -681,8 +857,9 @@ async function findLatestBundle (directory) { // Command to handle the upload async function uploadCommand (bundleFile, options) { const directory = options.path || '.'; - const registry = options.registry || defaultRegistry; + const registry = registryOption(options); const authToken = options.authToken; + const host = hostOption(options); if (!bundleFile) { bundleFile = await findLatestBundle(directory); @@ -692,9 +869,9 @@ async function uploadCommand (bundleFile, options) { } } - const bundlePath = path.join(directory, bundleFile); + const bundlePath = path.isAbsolute(bundleFile) ? bundleFile : path.join(directory, bundleFile); - await uploadBundle(bundlePath, { registry, authToken }); + await uploadBundle(bundlePath, { registry, authToken, host }); } // Function to bump the version @@ -724,11 +901,9 @@ async function versionBump (options) { // Command to handle registration async function registerCommand(options) { - const host = options.host || 'https://api.fleetbase.io'; - // Ensure host has protocol, add https:// if missing - const apiHost = host.startsWith('http://') || host.startsWith('https://') ? host : `https://${host}`; - const registrationApi = `${apiHost}/~registry/v1/developer-account/register`; - + const apiHost = await resolveHost(hostOption(options)); + const registrationApi = registryApi(apiHost, 'developer-account/register'); + try { // Collect registration information const answers = await prompt([ @@ -797,9 +972,9 @@ async function registerCommand(options) { console.log('\n✓ Account created successfully!'); console.log('✓ A verification code has been sent to your email.'); console.log('\n👉 Next step: Verify your email address'); - const verifyCmd = `flb verify -e ${registrationData.email}` + (host !== 'https://api.fleetbase.io' ? ` --host ${host}` : ''); + const verifyCmd = `flb verify -e ${registrationData.email}${hostFlag(apiHost)}`; console.log(` Run: ${verifyCmd}`); - const loginCmd = `flb login -u ${registrationData.username}` + (host !== 'https://api.fleetbase.io' ? ` --host ${host}` : ''); + const loginCmd = `flb login -u ${registrationData.username}${hostFlag(apiHost)}`; console.log(`\n✓ After verification, login with: ${loginCmd}`); } else { console.error('Registration failed:', response.data.message || 'Unknown error'); @@ -831,6 +1006,34 @@ async function registerCommand(options) { } } +/** + * Store a freshly issued registry token in the config file and tell the user where it went. + * The token itself is only printed when --show-token was passed. + */ +async function printAndStoreToken (token, apiHost, showToken = false) { + let saved = false; + try { + await saveToken(apiHost, token); + saved = true; + } catch (error) { + console.warn(`\n⚠️ Could not save the token to ${configFilePath}: ${error.message}`); + } + + console.log('\n🔑 Your Registry Token:'); + if (showToken) { + console.log(` ${token}`); + } else { + console.log(` ${maskToken(token)} (hidden; pass --show-token to print it)`); + } + + if (saved) { + console.log(`\n✓ Token saved to ${configFilePath} for ${hostKey(apiHost)}`); + } + console.log('\n💡 Keep this token secret! You\'ll need it to authenticate with the registry.'); + const registryHint = isDefaultHost(apiHost) ? '' : ` --registry ${apiHost}`; + console.log(` To configure a Fleetbase instance, run: flb set-auth${saved ? '' : ' '}${registryHint} --path `); +} + // Command to verify developer account email async function verifyCommand(options) { console.log('\n📧 Verify Your Registry Developer Account\n'); @@ -838,7 +1041,7 @@ async function verifyCommand(options) { try { let email = options.email; let code = options.code; - const host = options.host || 'https://api.fleetbase.io'; + const apiHost = await resolveHost(hostOption(options)); // Only prompt if values not provided if (!email || !code) { @@ -864,11 +1067,7 @@ async function verifyCommand(options) { code = code || answers.code; } - // Ensure host has protocol - const apiHost = host.startsWith('http://') || host.startsWith('https://') - ? host - : `https://${host}`; - const verificationApi = `${apiHost}/~registry/v1/developer-account/verify`; + const verificationApi = registryApi(apiHost, 'developer-account/verify'); console.log('\nVerifying account...'); @@ -881,15 +1080,12 @@ async function verifyCommand(options) { if (response.data.status === 'success') { console.log('\n✓ Email verified successfully!'); - // Display registry token if provided + // Store the registry token if provided; only print it when asked to if (response.data.token) { - console.log('\n🔑 Your Registry Token:'); - console.log(` ${response.data.token}`); - console.log('\n💡 Save this token securely! You\'ll need it to authenticate with the registry.'); - console.log(' Use: flb set-auth ' + response.data.token + (host !== 'https://api.fleetbase.io' ? ` --registry ${host}` : '')); + await printAndStoreToken(response.data.token, apiHost, options.showToken); } - - console.log('\n✓ You can now login with: flb login -u ' + (host !== 'https://api.fleetbase.io' ? ` --host ${host}` : '')); + + console.log(`\n✓ You can now login with: flb login -u ${hostFlag(apiHost)}`); } else { console.error('\nVerification failed:', response.data.message || 'Unknown error'); process.exit(1); @@ -925,7 +1121,7 @@ async function generateTokenCommand(options) { try { let email = options.email; let password = options.password; - const host = options.host || 'https://api.fleetbase.io'; + const apiHost = await resolveHost(hostOption(options)); // Prompt for credentials if not provided if (!email || !password) { @@ -950,11 +1146,7 @@ async function generateTokenCommand(options) { password = password || answers.password; } - // Ensure host has protocol - const apiHost = host.startsWith('http://') || host.startsWith('https://') - ? host - : `https://${host}`; - const generateTokenApi = `${apiHost}/~registry/v1/developer-account/generate-token`; + const generateTokenApi = registryApi(apiHost, 'developer-account/generate-token'); console.log('\nGenerating token...'); @@ -966,10 +1158,7 @@ async function generateTokenCommand(options) { if (response.data.status === 'success') { console.log('\n✓ ' + response.data.message); - console.log('\n🔑 Your Registry Token:'); - console.log(` ${response.data.token}`); - console.log('\n💡 Save this token securely! You\'ll need it to authenticate with the registry.'); - console.log(' Use: flb set-auth ' + response.data.token + (host !== 'https://api.fleetbase.io' ? ` --registry ${host}` : '')); + await printAndStoreToken(response.data.token, apiHost, options.showToken); console.log('\n⚠️ Note: This replaces any previously generated token.'); } else { console.error('\nToken generation failed:', response.data.message || 'Unknown error'); @@ -1005,7 +1194,7 @@ async function resendVerificationCommand(options) { try { let email = options.email; - const host = options.host || 'https://api.fleetbase.io'; + const apiHost = await resolveHost(hostOption(options)); // Prompt for email if not provided if (!email) { @@ -1020,11 +1209,7 @@ async function resendVerificationCommand(options) { email = answers.email; } - // Ensure host has protocol - const apiHost = host.startsWith('http://') || host.startsWith('https://') - ? host - : `https://${host}`; - const resendApi = `${apiHost}/~registry/v1/developer-account/resend-verification`; + const resendApi = registryApi(apiHost, 'developer-account/resend-verification'); console.log('\nResending verification code...'); @@ -1037,7 +1222,7 @@ async function resendVerificationCommand(options) { console.log('\n✓ Verification code sent!'); console.log('✓ Check your email for the new verification code.'); console.log('\n👉 Once you receive it, run:'); - console.log(` flb verify -e ${email}` + (host !== 'https://api.fleetbase.io' ? ` --host ${host}` : '')); + console.log(` flb verify -e ${email}${hostFlag(apiHost)}`); } else { console.error('\nFailed to resend:', response.data.message || 'Unknown error'); process.exit(1); @@ -1701,12 +1886,47 @@ ${buildEnvBlock(dbEnvVars)} -function loginCommand (options) { - const npmLogin = require('npm-cli-login'); +/** + * Promise wrapper around npm-cli-login. The package's default export is callback based and + * throws from inside async callbacks, so failures could never be caught by the caller. + * @returns {Promise} path of the npmrc file that was written + */ +function npmLogin (username, password, email, registry, scope, quotes, configPath) { + const ncl = require('npm-cli-login/lib/login.js'); + const args = ncl.processArguments(username, password, email, registry, scope || undefined, quotes, configPath || undefined); + const toError = err => (err instanceof Error ? err : new Error(String(err))); + + return new Promise((resolve, reject) => { + ncl.login(args, (loginError, response) => { + if (loginError) { + return reject(toError(loginError)); + } + + ncl.readFile(args, (readError, contents) => { + if (readError) { + return reject(toError(readError)); + } + + let lines; + try { + lines = ncl.generateFileContents(args, contents, response); + } catch (error) { + return reject(toError(error)); + } + + ncl.writeFile(args, lines, writeError => (writeError ? reject(toError(writeError)) : resolve(args.configPath))); + }); + }); + }); +} + +async function loginCommand (options) { const username = options.username; const password = options.password; const email = options.email; - const registry = options.registry || defaultRegistry; + // An explicit --host (self-hosted instance) doubles as the registry unless --registry is given + const host = hostOption(options); + const registry = normalizeHost(options.registry || program.opts().registry || host || defaultRegistry); const scope = options.scope || ''; const quotes = options.quotes || ''; const configPath = options.configPath || ''; @@ -1717,8 +1937,8 @@ function loginCommand (options) { } try { - npmLogin(username, password, email, registry, scope, quotes, configPath); - console.log(`Logged in to registry ${registry}`); + const npmrcPath = await npmLogin(username, password, email, registry, scope, quotes, configPath); + console.log(`Logged in to registry ${registry} (credentials written to ${npmrcPath})`); } catch (error) { console.error(`Error during login: ${error.message}`); process.exit(1); @@ -1779,11 +1999,8 @@ function displayExtensionsTable(extensions) { // Command: search and list available extensions async function searchExtensionsCommand(query, options) { - const host = options.host || 'https://api.fleetbase.io'; - const apiHost = host.startsWith('http://') || host.startsWith('https://') - ? host - : `https://${host}`; - const endpoint = `${apiHost}/~registry/v1/extensions`; + const apiHost = await resolveHost(hostOption(options)); + const endpoint = registryApi(apiHost, 'extensions'); if (!options.json && !options.simple) { console.log('\n\u{1F50D} Searching Fleetbase Extensions...\n'); @@ -1862,19 +2079,25 @@ async function searchExtensionsCommand(query, options) { } program.name('flb').description('CLI tool for managing Fleetbase Extensions').version(`${packageJson.name} ${packageJson.version}`, '-v, --version', 'Output the current version'); -program.option('-r, --registry [url]', 'Specify a fleetbase extension repository', defaultRegistry); +// Global options must come before the sub-command (e.g. `flb --host localhost:8000 install ...`) so that +// sub-commands can define their own --host / --registry (e.g. install-fleetbase's bind host). +program.enablePositionalOptions(); +program.option('-r, --registry [url]', `Specify a fleetbase extension repository (default: ${defaultRegistry})`); +program.option('--host ', `Fleetbase API host (default: host in ${configDisplayPath}, then ${defaultHost})`); program .command('set-auth [token]') .option('-p, --path ', 'Path of the Fleetbase instance to install setup for') - .option('-r, --registry ', 'Registry URL the credentials are for', defaultRegistry) - .description('Set registry auth token') - .action(async (token, { path, registry }) => { - const fleetbasePath = path || '.'; - const fleetbaseRegistry = program.opts().registry ?? registry; + .option('-r, --registry ', `Registry URL the credentials are for (default: ${defaultRegistry})`) + .option('--host ', 'Fleetbase API host to store as the default host') + .description(`Set registry auth token (stored in ${configDisplayPath} and the instance's api/auth.json)`) + .action(async (token, options) => { + const fleetbasePath = options.path || '.'; + const fleetbaseRegistry = normalizeHost(registryOption(options)); + const apiHost = hostOption(options) ? normalizeHost(hostOption(options)) : null; console.log(`Using registry: ${fleetbaseRegistry}`); console.log(`Using path: ${fleetbasePath}`); - await setAuth(token, fleetbasePath, fleetbaseRegistry); + await setAuth(token, fleetbasePath, fleetbaseRegistry, apiHost); }); program @@ -1886,19 +2109,20 @@ program .option('-a, --author ', 'Name of the extension author') .option('-e, --email ', 'Email of the extension author') .option('-k, --keywords ', 'Keywords of the extension to scaffold') - .option('-n, --namespace ', 'PHP Namespace of the extension to scaffold') - .option('-r, --repo ', 'Repository URL of the extension to scaffold', starterExtensionRepo.replace('.git', '')) + .option('--namespace ', 'PHP Namespace of the extension to scaffold') + .option('-r, --repo ', 'Repository URL of the extension to scaffold (default: none)') .action(scaffoldExtension); program .command('install [packageName]') .option('-p, --path ', 'Path of the Fleetbase instance to install to') + .option('--host ', `Fleetbase API host (default: ${defaultHost})`) .description('Install a Fleetbase Extension') - .action(async (packageName, { path }) => { - const fleetbasePath = path || '.'; + .action(async (packageName, options) => { + const fleetbasePath = options.path || '.'; console.log(`Installing package: ${packageName}`); console.log(`Using path: ${fleetbasePath}`); - await installPackage(packageName, fleetbasePath); + await installPackage(packageName, fleetbasePath, hostOption(options)); }); program @@ -1909,26 +2133,27 @@ program .option('-f, --free', 'Show only free extensions') .option('--json', 'Output results as raw JSON') .option('--simple', 'Output one extension per line: slug, name, version, price (for scripting)') - .option('-h, --host ', 'API host to fetch extensions from (default: https://api.fleetbase.io)') + .option('-h, --host ', `API host to fetch extensions from (default: ${defaultHost})`) .action(searchExtensionsCommand); program .command('uninstall [packageName]') .option('-p, --path ', 'Path of the Fleetbase instance to uninstall for') + .option('--host ', `Fleetbase API host (default: ${defaultHost})`) .description('Uninstall a Fleetbase Extension') - .action(async (packageName, { path }) => { - const fleetbasePath = path || '.'; + .action(async (packageName, options) => { + const fleetbasePath = options.path || '.'; console.log(`Uninstalling package: ${packageName}`); console.log(`Using path: ${fleetbasePath}`); - await uninstallPackage(packageName, fleetbasePath); + await uninstallPackage(packageName, fleetbasePath, hostOption(options)); }); program .command('publish [packagePath]') - .option('-r, --registry ', 'Registry URL the credentials are for', defaultRegistry) + .option('-r, --registry ', `Registry URL the credentials are for (default: ${defaultRegistry})`) .description('Publish a Fleetbase Extension') - .action(async (packagePath = '.', { registry }) => { - const fleetbaseRegistry = program.opts().registry ?? registry; + .action(async (packagePath = '.', options) => { + const fleetbaseRegistry = registryOption(options); console.log(`Using registry: ${fleetbaseRegistry}`); const hasPackageJson = await fs.pathExists(path.join(packagePath, 'package.json')); @@ -1950,10 +2175,10 @@ program program .command('unpublish [packageName]') - .option('-r, --registry ', 'Registry URL the credentials are for', defaultRegistry) + .option('-r, --registry ', `Registry URL the credentials are for (default: ${defaultRegistry})`) .description('Unpublish a Fleetbase Extension') - .action(async (packageName, { registry }) => { - const fleetbaseRegistry = program.opts().registry ?? registry; + .action(async (packageName, options) => { + const fleetbaseRegistry = registryOption(options); console.log(`Using registry: ${fleetbaseRegistry}`); if (!packageName) { @@ -1980,7 +2205,8 @@ program .description('Bundle the Fleetbase extension into a tar.gz file') .option('-p, --path ', 'Path of the Fleetbase extension to bundle', '.') .option('-u, --upload', 'Upload the created bundle after bundling') - .option('--auth-token ', 'Auth token for uploading the bundle') + .option('--auth-token ', `Auth token for uploading the bundle (default: token stored in ${configDisplayPath})`) + .option('--host ', `Fleetbase API host to upload to (default: ${defaultHost})`) .action(bundleExtension); program @@ -1988,7 +2214,8 @@ program .alias('upload-bundle') .description('Upload a Fleetbase extension bundle') .option('-p, --path ', 'Path where the bundle is located', '.') - .option('--auth-token ', 'Auth token for uploading the bundle') + .option('--auth-token ', `Auth token for uploading the bundle (default: token stored in ${configDisplayPath})`) + .option('--host ', `Fleetbase API host to upload to (default: ${defaultHost})`) .action(uploadCommand); program @@ -2008,7 +2235,7 @@ program .option('-e, --email ', 'Email address') .option('-p, --password ', 'Password') .option('-n, --name ', 'Your full name (optional)') - .option('-h, --host ', 'API host with protocol (default: https://api.fleetbase.io)') + .option('-h, --host ', `API host (default: ${defaultHost})`) .action(registerCommand); program @@ -2016,14 +2243,15 @@ program .description('Verify your Registry Developer Account email') .option('-e, --email ', 'Email address') .option('-c, --code ', 'Verification code from email') - .option('-h, --host ', 'API host with protocol (default: https://api.fleetbase.io)') + .option('-h, --host ', `API host (default: ${defaultHost})`) + .option('--show-token', 'Print the issued registry token (hidden by default)') .action(verifyCommand); program .command('resend-verification') .description('Resend verification code to your email') .option('-e, --email ', 'Email address') - .option('-h, --host ', 'API host with protocol (default: https://api.fleetbase.io)') + .option('-h, --host ', `API host (default: ${defaultHost})`) .action(resendVerificationCommand); program @@ -2031,7 +2259,8 @@ program .description('Generate or regenerate your registry authentication token') .option('-e, --email ', 'Email address') .option('-p, --password ', 'Password') - .option('-h, --host ', 'API host with protocol (default: https://api.fleetbase.io)') + .option('-h, --host ', `API host (default: ${defaultHost})`) + .option('--show-token', 'Print the generated registry token (hidden by default)') .action(generateTokenCommand); program @@ -2050,10 +2279,33 @@ program .option('-u, --username ', 'Username for the registry') .option('-p, --password ', 'Password for the registry') .option('-e, --email ', 'Email associated with your account') - .option('-r, --registry ', 'Registry URL', defaultRegistry) + .option('-r, --registry ', `Registry URL (default: --host if given, else ${defaultRegistry})`) + .option('--host ', 'Self-hosted Fleetbase API host; used as the registry unless --registry is given') .option('--scope ', 'Scope for the registry') .option('--quotes ', 'Quotes option for npm-cli-login') .option('--config-path ', 'Path to the npm config file') .action(loginCommand); -program.parse(process.argv); +if (require.main === module) { + program.parse(process.argv); +} else { + // Exported for tests + module.exports = { + program, + defaultHost, + defaultRegistry, + configFilePath, + normalizeHost, + hostKey, + isDefaultHost, + hostFlag, + maskToken, + readConfig, + writeConfig, + updateConfig, + saveToken, + getStoredToken, + resolveHost, + registryApi, + }; +} diff --git a/package.json b/package.json index 540b6fc..06597cd 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "flb": "./index.js" }, "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "node test/cli.test.js" }, "dependencies": { "axios": "^1.7.3", diff --git a/test/cli.test.js b/test/cli.test.js new file mode 100644 index 0000000..2f34fb0 --- /dev/null +++ b/test/cli.test.js @@ -0,0 +1,191 @@ +#!/usr/bin/env node +/** + * Minimal test runner (no framework) for the CLI helpers: + * node test/cli.test.js + * + * Covers host normalisation and ~/.fleetbase/config.json read/write. + * FLEETBASE_HOME is pointed at a temp dir before index.js is loaded so the + * real config file is never touched. + */ +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'flb-test-')); +process.env.FLEETBASE_HOME = tmpHome; + +const cli = require('../index.js'); + +const tests = []; +const test = (name, fn) => tests.push({ name, fn }); + +// ─── normalizeHost ──────────────────────────────────────────────────────────── + +test('normalizeHost keeps an existing scheme', () => { + assert.strictEqual(cli.normalizeHost('https://api.fleetbase.io'), 'https://api.fleetbase.io'); + assert.strictEqual(cli.normalizeHost('http://api.example.com'), 'http://api.example.com'); + assert.strictEqual(cli.normalizeHost('http://localhost:8000'), 'http://localhost:8000'); + assert.strictEqual(cli.normalizeHost('HTTPS://Api.Example.com'), 'HTTPS://Api.Example.com'); +}); + +test('normalizeHost uses http:// for localhost and loopback addresses', () => { + assert.strictEqual(cli.normalizeHost('localhost'), 'http://localhost'); + assert.strictEqual(cli.normalizeHost('localhost:8000'), 'http://localhost:8000'); + assert.strictEqual(cli.normalizeHost('127.0.0.1'), 'http://127.0.0.1'); + assert.strictEqual(cli.normalizeHost('127.0.0.1:8000'), 'http://127.0.0.1:8000'); + assert.strictEqual(cli.normalizeHost('[::1]:8000'), 'http://[::1]:8000'); + assert.strictEqual(cli.normalizeHost('api.localhost'), 'http://api.localhost'); +}); + +test('normalizeHost uses http:// for a host with an explicit port and no scheme', () => { + assert.strictEqual(cli.normalizeHost('fleetbase.example.com:8000'), 'http://fleetbase.example.com:8000'); + assert.strictEqual(cli.normalizeHost('10.0.0.5:8000'), 'http://10.0.0.5:8000'); +}); + +test('normalizeHost uses https:// otherwise', () => { + assert.strictEqual(cli.normalizeHost('api.fleetbase.io'), 'https://api.fleetbase.io'); + assert.strictEqual(cli.normalizeHost('api.myfleetbase.com'), 'https://api.myfleetbase.com'); + assert.strictEqual(cli.normalizeHost('10.0.0.5'), 'https://10.0.0.5'); +}); + +test('normalizeHost strips trailing slashes and whitespace, passes through empty / non-string values', () => { + assert.strictEqual(cli.normalizeHost('https://api.fleetbase.io/'), 'https://api.fleetbase.io'); + assert.strictEqual(cli.normalizeHost(' localhost:8000/// '), 'http://localhost:8000'); + assert.strictEqual(cli.normalizeHost(''), ''); + assert.strictEqual(cli.normalizeHost(undefined), undefined); + assert.strictEqual(cli.normalizeHost(null), null); +}); + +test('hostKey returns host[:port] regardless of scheme or path', () => { + assert.strictEqual(cli.hostKey('https://api.fleetbase.io'), 'api.fleetbase.io'); + assert.strictEqual(cli.hostKey('localhost:8000'), 'localhost:8000'); + assert.strictEqual(cli.hostKey('http://localhost:8000/'), 'localhost:8000'); + assert.strictEqual(cli.hostKey('https://registry.fleetbase.io/some/path'), 'registry.fleetbase.io'); +}); + +test('registryApi builds URLs from the host', () => { + assert.strictEqual(cli.registryApi('localhost:8000', 'lookup'), 'http://localhost:8000/~registry/v1/lookup'); + assert.strictEqual(cli.registryApi('https://api.fleetbase.io/', '/bundle-upload'), 'https://api.fleetbase.io/~registry/v1/bundle-upload'); + assert.strictEqual(cli.registryApi(cli.defaultHost, 'developer-account/register'), 'https://api.fleetbase.io/~registry/v1/developer-account/register'); +}); + +test('isDefaultHost / hostFlag', () => { + assert.strictEqual(cli.isDefaultHost('api.fleetbase.io'), true); + assert.strictEqual(cli.isDefaultHost('https://api.fleetbase.io/'), true); + assert.strictEqual(cli.isDefaultHost('localhost:8000'), false); + assert.strictEqual(cli.hostFlag('https://api.fleetbase.io'), ''); + assert.strictEqual(cli.hostFlag('http://localhost:8000'), ' --host http://localhost:8000'); +}); + +test('maskToken never reveals the whole token', () => { + assert.strictEqual(cli.maskToken('short'), '********'); + const masked = cli.maskToken('flb_abcdefghijklmnopqrstuvwxyz'); + assert.ok(masked.startsWith('flb_')); + assert.ok(masked.endsWith('wxyz')); + assert.ok(!masked.includes('abcdefghijklmnop')); + assert.strictEqual(cli.maskToken(undefined), '********'); +}); + +// ─── config read / write ────────────────────────────────────────────────────── + +test('config file path lives under FLEETBASE_HOME', () => { + assert.strictEqual(cli.configFilePath, path.join(tmpHome, 'config.json')); +}); + +test('readConfig returns {} when no config file exists', async () => { + assert.ok(!fs.existsSync(cli.configFilePath)); + assert.deepStrictEqual(await cli.readConfig(), {}); +}); + +test('writeConfig creates the directory and file with mode 0600; readConfig reads it back', async () => { + const config = { host: 'http://localhost:8000', registry: 'http://localhost:8000', tokens: { 'localhost:8000': 'tok' }, defaultOrganization: 'org_123' }; + await cli.writeConfig(config); + assert.ok(fs.existsSync(cli.configFilePath)); + if (process.platform !== 'win32') { + assert.strictEqual(fs.statSync(cli.configFilePath).mode & 0o777, 0o600); + } + assert.deepStrictEqual(await cli.readConfig(), config); + assert.deepStrictEqual(JSON.parse(fs.readFileSync(cli.configFilePath, 'utf8')), config); +}); + +test('saveToken keys tokens by host[:port] and preserves other keys', async () => { + await cli.writeConfig({ host: 'http://localhost:8000', defaultOrganization: 'org_123' }); + await cli.saveToken('https://api.fleetbase.io/', 'cloud-token'); + await cli.saveToken('localhost:8000', 'local-token'); + const config = await cli.readConfig(); + assert.deepStrictEqual(config, { + host: 'http://localhost:8000', + defaultOrganization: 'org_123', + tokens: { 'api.fleetbase.io': 'cloud-token', 'localhost:8000': 'local-token' }, + }); +}); + +test('getStoredToken returns the first match in order and null when none', async () => { + await cli.writeConfig({ tokens: { 'api.fleetbase.io': 'cloud-token', 'localhost:8000': 'local-token' } }); + assert.strictEqual(await cli.getStoredToken('http://localhost:8000'), 'local-token'); + assert.strictEqual(await cli.getStoredToken('https://registry.fleetbase.io', 'api.fleetbase.io'), 'cloud-token'); + assert.strictEqual(await cli.getStoredToken(undefined, null, 'localhost:8000'), 'local-token'); + assert.strictEqual(await cli.getStoredToken('https://registry.fleetbase.io'), null); + assert.strictEqual(await cli.getStoredToken(), null); +}); + +test('resolveHost precedence: flag > config file > default', async () => { + fs.rmSync(cli.configFilePath, { force: true }); + assert.strictEqual(await cli.resolveHost(), cli.defaultHost); + assert.strictEqual(await cli.resolveHost(undefined, null), cli.defaultHost); + + await cli.writeConfig({ host: 'localhost:8000' }); + assert.strictEqual(await cli.resolveHost(), 'http://localhost:8000'); + assert.strictEqual(await cli.resolveHost('api.example.com'), 'https://api.example.com'); + assert.strictEqual(await cli.resolveHost(undefined, '127.0.0.1:9000'), 'http://127.0.0.1:9000'); +}); + +test('readConfig tolerates a corrupt or non-object config file', async () => { + const originalWarn = console.warn; + console.warn = () => {}; + try { + fs.writeFileSync(cli.configFilePath, '{ not json'); + assert.deepStrictEqual(await cli.readConfig(), {}); + fs.writeFileSync(cli.configFilePath, '[1,2,3]'); + assert.deepStrictEqual(await cli.readConfig(), {}); + } finally { + console.warn = originalWarn; + } +}); + +test('global --host and per-command --host are both registered', () => { + const globalHost = cli.program.options.find(o => o.long === '--host'); + assert.ok(globalHost, 'global --host option'); + for (const name of ['install', 'uninstall', 'bundle', 'bundle-upload', 'search', 'register', 'verify', 'resend-verification', 'generate-token', 'login', 'set-auth']) { + const command = cli.program.commands.find(c => c.name() === name); + assert.ok(command, `command ${name}`); + assert.ok(command.options.some(o => o.long === '--host'), `${name} has --host`); + } + for (const name of ['verify', 'generate-token']) { + const command = cli.program.commands.find(c => c.name() === name); + assert.ok(command.options.some(o => o.long === '--show-token'), `${name} has --show-token`); + } + const scaffold = cli.program.commands.find(c => c.name() === 'scaffold'); + const shortFlags = scaffold.options.map(o => o.short).filter(Boolean); + assert.strictEqual(new Set(shortFlags).size, shortFlags.length, 'scaffold short flags are unique'); + assert.strictEqual(scaffold.options.find(o => o.long === '--repo').defaultValue, undefined, 'scaffold --repo has no default'); +}); + +// ─── runner ─────────────────────────────────────────────────────────────────── + +(async () => { + let failed = 0; + for (const { name, fn } of tests) { + try { + await fn(); + console.log(` ✓ ${name}`); + } catch (error) { + failed++; + console.error(` ✗ ${name}\n ${error.message}`); + } + } + fs.rmSync(tmpHome, { recursive: true, force: true }); + console.log(`\n${tests.length - failed}/${tests.length} passed`); + process.exit(failed ? 1 : 0); +})();