@solidactions/cli 1.23.0 → 1.24.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/dist/utils/api.js CHANGED
@@ -5,8 +5,11 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.augmentNotFoundMessage = augmentNotFoundMessage;
7
7
  exports.getApiHeaders = getApiHeaders;
8
+ exports.describeProjectEnvironments = describeProjectEnvironments;
9
+ exports.formatValidationError = formatValidationError;
8
10
  exports.requireResolvedConfig = requireResolvedConfig;
9
11
  exports.requireConfig = requireConfig;
12
+ exports.authFailureMessage = authFailureMessage;
10
13
  exports.ensureWorkspaceSelected = ensureWorkspaceSelected;
11
14
  exports.requireConfigWithWorkspace = requireConfigWithWorkspace;
12
15
  const axios_1 = __importDefault(require("axios"));
@@ -46,6 +49,59 @@ function getApiHeaders(config, contentType) {
46
49
  headers['X-Workspace-Id'] = config.workspaceId;
47
50
  return headers;
48
51
  }
52
+ /**
53
+ * Best-effort lookup of the environments a project family actually has
54
+ * (e.g. "production, dev"), for a friendlier 404 message. Returns null on
55
+ * any failure — callers should treat that as "no extra detail available."
56
+ */
57
+ async function describeProjectEnvironments(config, projectName) {
58
+ try {
59
+ const res = await axios_1.default.get(`${config.host}/api/v1/projects`, { headers: getApiHeaders(config) });
60
+ const rows = res.data?.data ?? res.data ?? [];
61
+ const hit = rows.find((p) => p.name === projectName || p.slug === projectName);
62
+ const envs = hit?.environments;
63
+ return envs?.length ? envs.join(', ') : null;
64
+ }
65
+ catch {
66
+ return null;
67
+ }
68
+ }
69
+ /**
70
+ * Render a Laravel-style 422 validation error body as plain readable text —
71
+ * never a raw JSON dump. Prefers `data.errors` (flattened, one message per
72
+ * line, with the internal `variables.N.` index prefix stripped and the bare
73
+ * `key` attribute renamed to `variable key` for clarity); falls back to
74
+ * `data.message`.
75
+ */
76
+ function formatValidationError(data) {
77
+ const errors = data?.errors;
78
+ let messages = [];
79
+ if (errors && typeof errors === 'object' && !Array.isArray(errors)) {
80
+ for (const value of Object.values(errors)) {
81
+ if (Array.isArray(value)) {
82
+ messages.push(...value.map((v) => String(v)));
83
+ }
84
+ else if (value) {
85
+ messages.push(String(value));
86
+ }
87
+ }
88
+ }
89
+ if (messages.length === 0) {
90
+ const message = data?.message;
91
+ if (typeof message === 'string' && message) {
92
+ messages = [message];
93
+ }
94
+ }
95
+ if (messages.length === 0) {
96
+ return 'Validation failed.';
97
+ }
98
+ return messages
99
+ .map((msg) => msg
100
+ .replace(/variables\.\d+\.key/gi, 'variable key')
101
+ .replace(/variable key field/gi, 'variable key')
102
+ .replace(/variables\.\d+\.(\w+)/gi, '$1'))
103
+ .join('\n');
104
+ }
49
105
  /**
50
106
  * Get the full resolution (config + sources + activePath). Exits if nothing resolvable.
51
107
  */
@@ -60,6 +116,15 @@ function requireResolvedConfig() {
60
116
  function requireConfig() {
61
117
  return requireResolvedConfig().config;
62
118
  }
119
+ /**
120
+ * Contextual 401 message — names the host being called and where the (now
121
+ * apparently invalid/expired) API key came from, instead of a bare
122
+ * "Authentication failed" that gives no clue which config is at fault.
123
+ */
124
+ function authFailureMessage(config, sources) {
125
+ const keySource = sources?.apiKey ?? 'config';
126
+ return `Authentication failed against ${config.host} (key from ${keySource}). Run \`solidactions login <api-key>\` to re-configure.`;
127
+ }
63
128
  async function ensureWorkspaceSelected(config) {
64
129
  if (config.workspaceId) {
65
130
  return config;
@@ -95,7 +160,7 @@ async function ensureWorkspaceSelected(config) {
95
160
  }
96
161
  catch (error) {
97
162
  if (error.response?.status === 401) {
98
- console.error(chalk_1.default.red('Authentication failed. Run `solidactions login <api-key>` to reconfigure.'));
163
+ console.error(chalk_1.default.red(authFailureMessage(config, resolved?.sources ?? null)));
99
164
  }
100
165
  else {
101
166
  console.error(chalk_1.default.red('Failed to fetch workspaces:'), error.response?.data?.message || error.message);
@@ -148,5 +213,9 @@ async function requireConfigWithWorkspace() {
148
213
  config = { ...config, workspace: ws.slug ?? ws.name, workspaceId: ws.id };
149
214
  return config;
150
215
  }
216
+ if (!config.workspaceId && !process.stdin.isTTY) {
217
+ console.error(chalk_1.default.red('No workspace set for this config. Run `solidactions workspace set <name-or-id> --local` (or --global).'));
218
+ process.exit(1);
219
+ }
151
220
  return ensureWorkspaceSelected(config);
152
221
  }
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.decideWriteTarget = decideWriteTarget;
7
7
  exports.pathForTarget = pathForTarget;
8
+ exports.confirmOverwrite = confirmOverwrite;
8
9
  exports.ensureGitignoreCovers = ensureGitignoreCovers;
9
10
  const fs_1 = __importDefault(require("fs"));
10
11
  const path_1 = __importDefault(require("path"));
@@ -19,7 +20,7 @@ const config_1 = require("./config");
19
20
  * - Else if TTY, prompt.
20
21
  * - Else error and exit.
21
22
  */
22
- async function decideWriteTarget(options, promptLabel = 'Save config locally (./.solidactions) or globally (~/.solidactions)? [global] ') {
23
+ async function decideWriteTarget(options, promptLabel = 'Save config locally (./.solidactions) or globally (~/.solidactions)? [global] ', refusalMessage = 'Refusing to write config non-interactively. Pass --local or --global.') {
23
24
  if (options.local && options.global) {
24
25
  console.error(chalk_1.default.red('Error: --local and --global are mutually exclusive.'));
25
26
  process.exit(1);
@@ -45,17 +46,55 @@ async function decideWriteTarget(options, promptLabel = 'Save config locally (./
45
46
  rl.close();
46
47
  }
47
48
  }
48
- console.error(chalk_1.default.red('Refusing to write config non-interactively. Pass --local or --global.'));
49
+ console.error(chalk_1.default.red(refusalMessage));
49
50
  process.exit(1);
50
51
  }
51
52
  function pathForTarget(target) {
52
53
  return target === 'local' ? (0, config_1.getLocalConfigPath)() : (0, config_1.getGlobalConfigPath)();
53
54
  }
55
+ /**
56
+ * Notify (and, on a TTY, confirm) that an existing config file is about to be
57
+ * overwritten. A backup will be written to `backupPath` regardless of mode —
58
+ * this always says so. Non-interactive callers (CI, agents) proceed
59
+ * automatically so they never wedge; an interactive TTY additionally asks a
60
+ * y/N confirm (default N).
61
+ */
62
+ async function confirmOverwrite(existingPath, backupPath) {
63
+ const notice = `Existing config at ${existingPath} will be overwritten (backup will be saved to ${backupPath}).`;
64
+ if (!process.stdin.isTTY) {
65
+ console.log(chalk_1.default.yellow(notice));
66
+ return true;
67
+ }
68
+ const rl = readline_1.default.createInterface({ input: process.stdin, output: process.stdout });
69
+ try {
70
+ const answer = await new Promise((resolve) => rl.question(chalk_1.default.yellow(`${notice} Continue? [y/N] `), resolve));
71
+ return answer.trim().toLowerCase().startsWith('y');
72
+ }
73
+ finally {
74
+ rl.close();
75
+ }
76
+ }
54
77
  /**
55
78
  * Ensure `.solidactions/` is in the target directory's `.gitignore`.
56
79
  * Idempotent. Skips silently if pattern is already covered.
57
80
  */
81
+ /** Walk up from startDir looking for a `.git` directory. Stops at filesystem root. */
82
+ function findGitRoot(startDir) {
83
+ let dir = path_1.default.resolve(startDir);
84
+ while (true) {
85
+ if (fs_1.default.existsSync(path_1.default.join(dir, '.git'))) {
86
+ return dir;
87
+ }
88
+ const parent = path_1.default.dirname(dir);
89
+ if (parent === dir)
90
+ return null;
91
+ dir = parent;
92
+ }
93
+ }
58
94
  async function ensureGitignoreCovers(targetDir, auto) {
95
+ // Not in a git repo — a .gitignore entry protects nothing here; skip silently.
96
+ if (!findGitRoot(targetDir))
97
+ return;
59
98
  const gitignorePath = path_1.default.join(targetDir, '.gitignore');
60
99
  const patternToAdd = '.solidactions/';
61
100
  let existing = '';
@@ -151,8 +151,23 @@ function mergeConfigs(env, local, localPath, global, globalPath) {
151
151
  };
152
152
  const host = pick('host');
153
153
  const apiKey = pick('apiKey');
154
- const workspace = pick('workspace');
155
- const workspaceId = pick('workspaceId');
154
+ // A local config that defines its own credentials (host or apiKey) is
155
+ // anchored to a different account/tenant than the global config — its
156
+ // workspace/workspaceId must never fall back to global (F-C3).
157
+ // Pure workspace-pin local files (no host/apiKey of their own) keep
158
+ // inheriting global credentials and so may still inherit the workspace.
159
+ const localDefinesCreds = !!(local && (local.host !== undefined || local.apiKey !== undefined));
160
+ const pickWorkspaceField = (key) => {
161
+ if (env[key] !== undefined)
162
+ return { value: env[key], source: 'env' };
163
+ if (local && local[key] !== undefined)
164
+ return { value: local[key], source: localPath };
165
+ if (!localDefinesCreds && global && global[key] !== undefined)
166
+ return { value: global[key], source: globalPath };
167
+ return { value: undefined, source: null };
168
+ };
169
+ const workspace = pickWorkspaceField('workspace');
170
+ const workspaceId = pickWorkspaceField('workspaceId');
156
171
  if (!host.value && !apiKey.value) {
157
172
  return null;
158
173
  }
package/dist/utils/env.js CHANGED
@@ -10,6 +10,8 @@ exports.getYamlDeclaredVars = getYamlDeclaredVars;
10
10
  exports.loadSolidActionsConfig = loadSolidActionsConfig;
11
11
  exports.isReservedEnvName = isReservedEnvName;
12
12
  exports.reservedEnvNameError = reservedEnvNameError;
13
+ exports.isValidEnvName = isValidEnvName;
14
+ exports.envNameError = envNameError;
13
15
  const fs_1 = __importDefault(require("fs"));
14
16
  const js_yaml_1 = __importDefault(require("js-yaml"));
15
17
  /**
@@ -81,7 +83,7 @@ function parseYamlEnvVars(config) {
81
83
  }
82
84
  /**
83
85
  * Extract declared variable keys from solidactions.yaml env config.
84
- * Returns a set of env var keys that are declared in YAML.
86
+ * Returns a set of variable keys that are declared in YAML.
85
87
  */
86
88
  function getYamlDeclaredVars(config) {
87
89
  const parsedVars = parseYamlEnvVars(config);
@@ -103,10 +105,20 @@ function loadSolidActionsConfig(yamlPath) {
103
105
  * matching the server-side rule (App\Support\ReservedEnvNames).
104
106
  */
105
107
  exports.RESERVED_ENV_PREFIX = 'SOLIDACTIONS_';
108
+ /** Case-insensitive: the server rejects any casing of the prefix, not just uppercase. */
106
109
  function isReservedEnvName(key) {
107
- return key.startsWith(exports.RESERVED_ENV_PREFIX);
110
+ return key.toUpperCase().startsWith(exports.RESERVED_ENV_PREFIX);
108
111
  }
109
112
  function reservedEnvNameError(key) {
110
- const suggestion = key.replace(/^SOLIDACTIONS_/, 'MY_');
113
+ const suggestion = key.replace(/^SOLIDACTIONS_/i, 'MY_');
111
114
  return `"${key}" uses the reserved ${exports.RESERVED_ENV_PREFIX} prefix. These names are set by the platform at runtime (API credentials, run context) and a custom variable would clobber them, causing authentication failures. Choose a different name (e.g. "${suggestion}").`;
112
115
  }
116
+ /** Env/variable key naming rule: letters, digits, underscore; no leading digit. */
117
+ function isValidEnvName(key) {
118
+ return /^[A-Za-z_][A-Za-z0-9_]*$/.test(key);
119
+ }
120
+ function envNameError(key) {
121
+ if (key.trim() === '')
122
+ return 'Variable key is required.';
123
+ return `Invalid variable name "${key}" — names must match [A-Za-z_][A-Za-z0-9_]* (letters, digits, underscore; no leading digit).`;
124
+ }
package/dist/utils/mcp.js CHANGED
@@ -46,6 +46,9 @@ const http = __importStar(require("http"));
46
46
  const https = __importStar(require("https"));
47
47
  const url_1 = require("url");
48
48
  const api_1 = require("./api");
49
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
50
+ const pkg = require('../../package.json');
51
+ const CLI_VERSION = pkg.version;
49
52
  /**
50
53
  * Internal: call a single MCP tool on the given endpoint path.
51
54
  *
@@ -85,7 +88,10 @@ async function callMcpTool(config, endpointPath, toolName, args) {
85
88
  let raw = '';
86
89
  res.on('data', (chunk) => { raw += chunk; });
87
90
  res.on('end', () => {
88
- if (res.statusCode && res.statusCode >= 400) {
91
+ if (res.statusCode === 404) {
92
+ reject(new Error(`MCP request failed: ${parsed.host} has no ${endpointPath} endpoint — the server may be older or newer than this CLI (${CLI_VERSION}). Raw: HTTP 404 ${raw}`));
93
+ }
94
+ else if (res.statusCode && res.statusCode >= 400) {
89
95
  reject(new Error(`MCP request failed with HTTP ${res.statusCode}: ${raw}`));
90
96
  }
91
97
  else {
@@ -116,15 +122,19 @@ async function callMcpTool(config, endpointPath, toolName, args) {
116
122
  }
117
123
  return { ok: !isError, data: toolData };
118
124
  }
125
+ const UNIFIED_MCP_PATH = '/mcp';
126
+ // Server consolidated per-domain MCP servers into one endpoint with namespaced tools.
127
+ const CREWS_TOOL_NAMES = { skills: 'crews_skills', roles: 'crews_roles' };
119
128
  /**
120
- * Call a single MCP tool on /mcp/crews.
129
+ * Call a single MCP tool on the unified /mcp endpoint, mapping legacy crews
130
+ * tool names ('skills'/'roles') to their namespaced equivalents.
121
131
  */
122
132
  async function callCrewsTool(config, toolName, args) {
123
- return callMcpTool(config, '/mcp/crews', toolName, args);
133
+ return callMcpTool(config, UNIFIED_MCP_PATH, CREWS_TOOL_NAMES[toolName] ?? toolName, args);
124
134
  }
125
135
  /**
126
- * Call a single MCP tool on /mcp/docs.
136
+ * Call a single MCP tool on the unified /mcp endpoint.
127
137
  */
128
138
  async function callDocsTool(config, toolName, args) {
129
- return callMcpTool(config, '/mcp/docs', toolName, args);
139
+ return callMcpTool(config, UNIFIED_MCP_PATH, toolName, args);
130
140
  }
@@ -0,0 +1,62 @@
1
+ "use strict";
2
+ // src/utils/table.ts
3
+ //
4
+ // Shared plain-text table renderer (extracted from webhook-formatters.ts's
5
+ // columnWidth helper) used by project-list, env-list, and run-list.
6
+ //
7
+ // Operates on PLAIN (uncolored) cell values only — chalk ANSI escape codes
8
+ // count toward a JS string's .length, so colorizing a cell before padding
9
+ // would corrupt the alignment math. Callers that want color should apply it
10
+ // to the whole rendered line (e.g. chalk.gray(lines[i])), not to individual
11
+ // cell values passed in here.
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.sanitizeCell = sanitizeCell;
14
+ exports.truncateCell = truncateCell;
15
+ exports.computeColumnWidths = computeColumnWidths;
16
+ exports.renderTable = renderTable;
17
+ const DEFAULT_MAX_WIDTH = 60;
18
+ /** Escape embedded newlines so a row can never span multiple terminal lines. */
19
+ function sanitizeCell(value) {
20
+ return value.replace(/\r?\n/g, '\\n');
21
+ }
22
+ function truncateCell(value, maxWidth = DEFAULT_MAX_WIDTH) {
23
+ if (value.length <= maxWidth)
24
+ return value;
25
+ return value.slice(0, Math.max(0, maxWidth - 1)) + '…';
26
+ }
27
+ /**
28
+ * Compute per-column widths (`max(minWidth, longest sanitized+truncated cell) + 2`)
29
+ * without rendering — for callers (e.g. run-list) that need to colorize a
30
+ * cell's text with chalk AFTER padding (coloring first would corrupt the
31
+ * alignment math, since ANSI escape codes count toward a JS string's .length).
32
+ */
33
+ function computeColumnWidths(headers, rows, opts = {}) {
34
+ const maxWidth = opts.maxWidth ?? DEFAULT_MAX_WIDTH;
35
+ const minWidths = opts.minWidths ?? [];
36
+ const clean = (v) => truncateCell(sanitizeCell(v), maxWidth);
37
+ return headers.map((h, i) => {
38
+ const longest = Math.max(clean(h).length, ...rows.map((r) => clean(r[i] ?? '').length));
39
+ return Math.max(minWidths[i] ?? 0, longest) + 2;
40
+ });
41
+ }
42
+ /**
43
+ * Render a simple aligned table as an array of lines: `[header, divider, ...rows]`.
44
+ * Column widths are dynamic — `max(minWidth, longest cell in that column) + 2`.
45
+ */
46
+ function renderTable(headers, rows, opts = {}) {
47
+ const maxWidth = opts.maxWidth ?? DEFAULT_MAX_WIDTH;
48
+ const minWidths = opts.minWidths ?? [];
49
+ const cleanHeaders = headers.map((h) => truncateCell(sanitizeCell(h), maxWidth));
50
+ const cleanRows = rows.map((row) => row.map((cell) => truncateCell(sanitizeCell(cell), maxWidth)));
51
+ const widths = cleanHeaders.map((h, i) => {
52
+ const longest = Math.max(h.length, ...cleanRows.map((r) => r[i]?.length ?? 0));
53
+ return Math.max(minWidths[i] ?? 0, longest) + 2;
54
+ });
55
+ const renderRow = (cells) => cells.map((cell, i) => (i === cells.length - 1 ? cell : (cell ?? '').padEnd(widths[i]))).join('');
56
+ const totalWidth = widths.reduce((a, b) => a + b, 0);
57
+ return [
58
+ renderRow(cleanHeaders),
59
+ '-'.repeat(totalWidth),
60
+ ...cleanRows.map(renderRow),
61
+ ];
62
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidactions/cli",
3
- "version": "1.23.0",
3
+ "version": "1.24.0",
4
4
  "description": "SolidActions CLI - Deploy and manage workflow automation",
5
5
  "main": "dist/index.js",
6
6
  "bin": {