@solidactions/cli 1.7.1 → 1.9.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.
@@ -113,6 +113,17 @@ async function pushYamlDeclarations(config, projectSlug, yamlConfig) {
113
113
  }
114
114
  async function deploy(projectName, sourcePath, options = {}) {
115
115
  const config = await (0, api_1.requireConfigWithWorkspace)();
116
+ let workspaceMismatchPrinted = false;
117
+ const printWorkspaceMismatchOnce = (error) => {
118
+ if (workspaceMismatchPrinted)
119
+ return;
120
+ const msg = error.response?.data?.message;
121
+ if (typeof msg === 'string' && /Project .+ not found in your active workspace/.test(msg)) {
122
+ console.error(chalk_1.default.yellow(msg));
123
+ console.error('');
124
+ workspaceMismatchPrinted = true;
125
+ }
126
+ };
116
127
  const sourceDir = sourcePath ? path_1.default.resolve(sourcePath) : process.cwd();
117
128
  // Capture whether -e was explicitly passed by the caller. Commander leaves
118
129
  // options.env as undefined when no default is set and the flag is omitted.
@@ -138,6 +149,11 @@ async function deploy(projectName, sourcePath, options = {}) {
138
149
  catch (error) {
139
150
  if (error.response?.status === 404) {
140
151
  productionExists = false;
152
+ // App PR #128 returns "Project '<slug>' not found in your active workspace '<ws>'." on 404.
153
+ // The axios interceptor (utils/api.ts) already appended the "Did you mean to switch workspaces?"
154
+ // hint. Surface that augmented message so the user sees the hint before falling through to
155
+ // the first-deploy flow.
156
+ printWorkspaceMismatchOnce(error);
141
157
  }
142
158
  else {
143
159
  // 5xx, network error, auth failure, etc. — fail conservatively rather
@@ -211,6 +227,10 @@ async function deploy(projectName, sourcePath, options = {}) {
211
227
  }
212
228
  catch (error) {
213
229
  if (error.response?.status === 404) {
230
+ // App PR #128 returns "Project '<slug>' not found in your active workspace '<ws>'." on 404.
231
+ // Surface the augmented message (axios interceptor already appended the hint) before
232
+ // falling through to the --create / first-deploy flow.
233
+ printWorkspaceMismatchOnce(error);
214
234
  // For non-production environments, require --create or give a clear hint
215
235
  if (environment !== 'production' && !options.create) {
216
236
  console.error(chalk_1.default.red(`\nProject "${projectName}" doesn't have a ${environment} environment.\n`));
@@ -9,13 +9,11 @@ exports.clearConfig = clearConfig;
9
9
  exports.login = login;
10
10
  exports.logout = logout;
11
11
  exports.whoami = whoami;
12
- const fs_1 = __importDefault(require("fs"));
13
- const path_1 = __importDefault(require("path"));
14
- const readline_1 = __importDefault(require("readline"));
15
12
  const chalk_1 = __importDefault(require("chalk"));
16
13
  const api_1 = require("../utils/api");
17
14
  const workspaces_1 = require("./workspaces");
18
15
  const config_1 = require("../utils/config");
16
+ const config_write_target_1 = require("../utils/config-write-target");
19
17
  function getConfig() {
20
18
  const resolved = (0, config_1.resolveConfig)();
21
19
  return resolved ? resolved.config : null;
@@ -28,65 +26,6 @@ function saveConfig(config) {
28
26
  function clearConfig() {
29
27
  (0, config_1.removeConfigFile)((0, config_1.getGlobalConfigPath)());
30
28
  }
31
- async function promptLocation() {
32
- const rl = readline_1.default.createInterface({ input: process.stdin, output: process.stdout });
33
- try {
34
- while (true) {
35
- const answer = await new Promise((resolve) => {
36
- rl.question(chalk_1.default.blue('Save config locally (./.solidactions) or globally (~/.solidactions)? [global] '), resolve);
37
- });
38
- const normalized = answer.trim().toLowerCase();
39
- if (normalized === '' || normalized === 'global' || normalized === 'g')
40
- return 'global';
41
- if (normalized === 'local' || normalized === 'l')
42
- return 'local';
43
- console.log(chalk_1.default.yellow("Please answer 'local' or 'global' (or press Enter for global)."));
44
- }
45
- }
46
- finally {
47
- rl.close();
48
- }
49
- }
50
- async function ensureGitignoreCovers(targetDir, auto) {
51
- const gitignorePath = path_1.default.join(targetDir, '.gitignore');
52
- const patternToAdd = '.solidactions/';
53
- let existing = '';
54
- if (fs_1.default.existsSync(gitignorePath)) {
55
- existing = fs_1.default.readFileSync(gitignorePath, 'utf-8');
56
- const lines = existing.split('\n').map((l) => l.trim());
57
- const isCovered = lines.some((line) => {
58
- const normalized = line
59
- .replace(/^\*\*\//, '')
60
- .replace(/^\//, '')
61
- .replace(/\/(\*\*|\*)?$/, '');
62
- return normalized === '.solidactions';
63
- });
64
- if (isCovered) {
65
- return; // already covered
66
- }
67
- }
68
- let shouldAdd = auto;
69
- if (!shouldAdd && process.stdin.isTTY) {
70
- const rl = readline_1.default.createInterface({ input: process.stdin, output: process.stdout });
71
- const answer = await new Promise((resolve) => {
72
- rl.question(chalk_1.default.yellow(`Local config contains an API key. Add \`.solidactions/\` to ${gitignorePath}? [Y/n] `), resolve);
73
- });
74
- rl.close();
75
- shouldAdd = !(answer.trim().toLowerCase().startsWith('n'));
76
- }
77
- if (!shouldAdd) {
78
- console.log(chalk_1.default.yellow(`Skipping .gitignore update. Remember: ${path_1.default.join(targetDir, '.solidactions', 'config.json')} contains your API key.`));
79
- return;
80
- }
81
- const prefix = existing.length > 0 && !existing.endsWith('\n') ? '\n' : '';
82
- try {
83
- fs_1.default.writeFileSync(gitignorePath, `${existing}${prefix}${patternToAdd}\n`);
84
- console.log(chalk_1.default.green(`Added \`${patternToAdd}\` to ${gitignorePath}.`));
85
- }
86
- catch (err) {
87
- console.log(chalk_1.default.yellow(`Could not update ${gitignorePath}: ${err.message}. Add \`.solidactions/\` to it manually — ${path_1.default.join(targetDir, '.solidactions', 'config.json')} contains your API key.`));
88
- }
89
- }
90
29
  async function login(apiKey, options) {
91
30
  let host;
92
31
  if (options.host) {
@@ -104,25 +43,8 @@ async function login(apiKey, options) {
104
43
  process.exit(1);
105
44
  }
106
45
  // Determine target location.
107
- let target;
108
- if (options.local && options.global) {
109
- console.error(chalk_1.default.red('Error: --local and --global are mutually exclusive.'));
110
- process.exit(1);
111
- }
112
- else if (options.local) {
113
- target = 'local';
114
- }
115
- else if (options.global) {
116
- target = 'global';
117
- }
118
- else if (process.stdin.isTTY) {
119
- target = await promptLocation();
120
- }
121
- else {
122
- console.error(chalk_1.default.red('Refusing to init non-interactively. Pass --local or --global.'));
123
- process.exit(1);
124
- }
125
- const targetPath = target === 'local' ? (0, config_1.getLocalConfigPath)() : (0, config_1.getGlobalConfigPath)();
46
+ const target = await (0, config_write_target_1.decideWriteTarget)({ local: options.local, global: options.global });
47
+ const targetPath = (0, config_write_target_1.pathForTarget)(target);
126
48
  console.log(chalk_1.default.blue(`Initializing SolidActions CLI...`));
127
49
  console.log(chalk_1.default.gray(`Host: ${host}`));
128
50
  if ((0, config_1.readConfigFile)(targetPath)) {
@@ -134,7 +56,7 @@ async function login(apiKey, options) {
134
56
  };
135
57
  (0, config_1.writeConfigFile)(targetPath, config);
136
58
  if (target === 'local') {
137
- await ensureGitignoreCovers(process.cwd(), !!options.gitignore);
59
+ await (0, config_write_target_1.ensureGitignoreCovers)(process.cwd(), !!options.gitignore);
138
60
  }
139
61
  console.log(chalk_1.default.green('Logged in successfully!'));
140
62
  console.log(chalk_1.default.gray(`Configuration saved to ${targetPath}`));
@@ -204,12 +126,19 @@ function whoami() {
204
126
  const fmt = (src) => {
205
127
  if (src === 'env')
206
128
  return chalk_1.default.gray('(from $SOLIDACTIONS_* env var)');
129
+ if (src === 'cli')
130
+ return chalk_1.default.gray('(from -w flag)');
207
131
  if (src === null)
208
132
  return chalk_1.default.gray('(unset)');
209
133
  return chalk_1.default.gray(`(from ${src})`);
210
134
  };
135
+ const workspaceLabel = config.workspace
136
+ ? `${config.workspace}${config.workspaceId ? ` (${config.workspaceId})` : ''}`
137
+ : config.workspaceId
138
+ ? `${config.workspaceId} (slug unknown — run 'workspace set <slug>' to populate)`
139
+ : '';
211
140
  console.log(chalk_1.default.blue('Current configuration:'));
212
- console.log(` Host: ${config.host.padEnd(40)} ${fmt(sources.host)}`);
213
- console.log(` API Key: ${maskedKey.padEnd(40)} ${fmt(sources.apiKey)}`);
214
- console.log(` Workspace: ${(config.workspaceId ?? '').padEnd(40)} ${fmt(sources.workspaceId)}`);
141
+ console.log(` Host: ${config.host.padEnd(50)} ${fmt(sources.host)}`);
142
+ console.log(` API Key: ${maskedKey.padEnd(50)} ${fmt(sources.apiKey)}`);
143
+ console.log(` Workspace: ${workspaceLabel.padEnd(50)} ${fmt(sources.workspaceId)}`);
215
144
  }
@@ -7,11 +7,19 @@ exports.webhookList = webhookList;
7
7
  const axios_1 = __importDefault(require("axios"));
8
8
  const chalk_1 = __importDefault(require("chalk"));
9
9
  const api_1 = require("../utils/api");
10
+ const webhook_formatters_1 = require("../utils/webhook-formatters");
10
11
  async function webhookList(projectName, options = {}) {
12
+ const format = options.format ?? 'table';
13
+ if (format !== 'table' && format !== 'json') {
14
+ console.error(chalk_1.default.red(`Invalid --format: ${options.format}. Expected 'table' or 'json'.`));
15
+ process.exit(1);
16
+ }
11
17
  const config = await (0, api_1.requireConfigWithWorkspace)();
12
18
  const environment = options.env || 'dev';
13
19
  const projectSlug = environment === 'production' ? projectName : `${projectName}-${environment}`;
14
- console.log(chalk_1.default.blue(`Webhooks for project "${projectName}"${environment !== 'production' ? ` (${environment})` : ''}:`));
20
+ if (format === 'table') {
21
+ console.log(chalk_1.default.blue(`Webhooks for project "${projectName}"${environment !== 'production' ? ` (${environment})` : ''}:`));
22
+ }
15
23
  try {
16
24
  const params = {};
17
25
  if (options.showSecrets) {
@@ -22,27 +30,18 @@ async function webhookList(projectName, options = {}) {
22
30
  params,
23
31
  });
24
32
  const webhooks = response.data.data || [];
33
+ const showSecrets = options.showSecrets === true;
34
+ if (format === 'json') {
35
+ console.log((0, webhook_formatters_1.formatJson)(webhooks, { showSecrets }));
36
+ return;
37
+ }
38
+ // table mode
25
39
  if (webhooks.length === 0) {
26
40
  console.log(chalk_1.default.yellow('No webhooks found for project "' + projectName + '".'));
27
41
  return;
28
42
  }
29
43
  console.log('');
30
- if (options.showSecrets) {
31
- console.log(chalk_1.default.gray('WORKFLOW'.padEnd(30) + 'URL'.padEnd(60) + 'SECRET'));
32
- console.log(chalk_1.default.gray('-'.repeat(120)));
33
- }
34
- else {
35
- console.log(chalk_1.default.gray('WORKFLOW'.padEnd(30) + 'URL'));
36
- console.log(chalk_1.default.gray('-'.repeat(90)));
37
- }
38
- for (const webhook of webhooks) {
39
- const name = webhook.workflow_name || webhook.workflow_slug || '?';
40
- const url = webhook.webhook_path_url || webhook.webhook_url || '?';
41
- let line = name.padEnd(30) + chalk_1.default.cyan(url.padEnd(60));
42
- if (options.showSecrets) {
43
- const secret = webhook.webhook_secret || '-';
44
- line += chalk_1.default.gray(secret);
45
- }
44
+ for (const line of (0, webhook_formatters_1.formatTable)(webhooks, { showSecrets })) {
46
45
  console.log(line);
47
46
  }
48
47
  console.log('');
@@ -9,6 +9,8 @@ const axios_1 = __importDefault(require("axios"));
9
9
  const chalk_1 = __importDefault(require("chalk"));
10
10
  const api_1 = require("../utils/api");
11
11
  const config_1 = require("../utils/config");
12
+ const config_write_target_1 = require("../utils/config-write-target");
13
+ const workspace_lookup_1 = require("../utils/workspace-lookup");
12
14
  async function workspacesList() {
13
15
  const config = (0, api_1.requireConfig)();
14
16
  try {
@@ -52,43 +54,20 @@ async function workspacesList() {
52
54
  process.exit(1);
53
55
  }
54
56
  }
55
- async function workspaceSet(workspaceId) {
57
+ async function workspaceSet(input, options = {}) {
56
58
  if (process.env.SOLIDACTIONS_WORKSPACE_ID) {
57
59
  console.error(chalk_1.default.red('SOLIDACTIONS_WORKSPACE_ID is set in the environment; the change would not take effect. ' +
58
60
  'Unset the env var or edit the config file directly.'));
59
61
  process.exit(1);
60
62
  }
61
- const resolved = (0, api_1.requireResolvedConfig)();
62
- const config = resolved.config;
63
- try {
64
- const response = await axios_1.default.get(`${config.host}/api/v1/workspaces`, {
65
- headers: {
66
- 'Authorization': `Bearer ${config.apiKey}`,
67
- 'Accept': 'application/json',
68
- },
69
- });
70
- const grouped = response.data.workspaces || response.data.data || response.data;
71
- let allWorkspaces = [];
72
- if (typeof grouped === 'object' && !Array.isArray(grouped)) {
73
- for (const orgWorkspaces of Object.values(grouped)) {
74
- allWorkspaces.push(...orgWorkspaces);
75
- }
76
- }
77
- else if (Array.isArray(grouped)) {
78
- allWorkspaces = grouped;
79
- }
80
- const workspace = allWorkspaces.find((w) => w.id === workspaceId || w.slug === workspaceId || w.name === workspaceId);
81
- if (!workspace) {
82
- console.error(chalk_1.default.red(`Workspace "${workspaceId}" not found. Run \`solidactions workspace list\` to list available workspaces.`));
83
- process.exit(1);
84
- }
85
- const updated = { ...config, workspaceId: workspace.id };
86
- (0, config_1.writeConfigFile)(resolved.activePath, updated);
87
- console.log(chalk_1.default.green(`Workspace set to: ${workspace.name} (${workspace.id})`));
88
- console.log(chalk_1.default.gray(`Saved to ${resolved.activePath}`));
89
- }
90
- catch (error) {
91
- console.error(chalk_1.default.red('Failed to set workspace:'), error.response?.data?.message || error.message);
92
- process.exit(1);
63
+ const config = (0, api_1.requireResolvedConfig)().config;
64
+ const workspace = await (0, workspace_lookup_1.resolveWorkspaceInput)(config, input);
65
+ const target = await (0, config_write_target_1.decideWriteTarget)({ local: options.local, global: options.global });
66
+ const targetPath = (0, config_write_target_1.pathForTarget)(target);
67
+ (0, config_1.writeWorkspaceToFile)(targetPath, workspace.slug ?? workspace.name, workspace.id);
68
+ if (target === 'local') {
69
+ await (0, config_write_target_1.ensureGitignoreCovers)(process.cwd(), !!options.gitignore);
93
70
  }
71
+ console.log(chalk_1.default.green(`Workspace set to: ${workspace.name} (${workspace.id})`));
72
+ console.log(chalk_1.default.gray(`Saved to ${targetPath}`));
94
73
  }
package/dist/index.js CHANGED
@@ -29,6 +29,7 @@ const dev_1 = require("./commands/dev");
29
29
  const ai_init_1 = require("./commands/ai-init");
30
30
  const ai_examples_1 = require("./commands/ai-examples");
31
31
  const workspaces_1 = require("./commands/workspaces");
32
+ const config_1 = require("./utils/config");
32
33
  // eslint-disable-next-line @typescript-eslint/no-var-requires
33
34
  const pkg = require('../package.json');
34
35
  const program = new commander_1.Command();
@@ -58,6 +59,22 @@ program
58
59
  .name('solidactions')
59
60
  .description('SolidActions CLI - Deploy and manage workflow automation')
60
61
  .version(pkg.version);
62
+ program.option('-w, --workspace <id-or-slug-or-name>', 'override active workspace for this command');
63
+ program.hook('preAction', (thisCommand, actionCommand) => {
64
+ const opts = thisCommand.opts();
65
+ const wsOverride = opts.workspace;
66
+ if (wsOverride) {
67
+ // workspace set is a write — -w is for read-paths only.
68
+ const fullName = actionCommand.name();
69
+ const parentName = actionCommand.parent?.name?.();
70
+ const isWorkspaceSet = fullName === 'set' && parentName === 'workspace';
71
+ if (isWorkspaceSet) {
72
+ console.error(chalk_1.default.yellow('warn:') + ' -w/--workspace is ignored on `workspace set`; the positional argument is the new workspace.');
73
+ return;
74
+ }
75
+ (0, config_1.setCliWorkspaceOverride)(wsOverride);
76
+ }
77
+ });
61
78
  // =============================================================================
62
79
  // Top-level commands
63
80
  // =============================================================================
@@ -296,6 +313,7 @@ webhook
296
313
  .argument('<project>', 'Project name')
297
314
  .option('-e, --env <environment>', 'Environment (production/staging/dev)')
298
315
  .option('--show-secrets', 'Show webhook secrets')
316
+ .option('--format <format>', 'Output format: table or json', 'table')
299
317
  .action((projectName, options) => {
300
318
  (0, webhook_list_1.webhookList)(projectName, options);
301
319
  });
@@ -311,10 +329,13 @@ workspace
311
329
  });
312
330
  workspace
313
331
  .command('set')
314
- .description('Set the active workspace for CLI operations')
315
- .argument('<workspace-id>', 'Workspace ID, slug, or name')
316
- .action((workspaceId) => {
317
- (0, workspaces_1.workspaceSet)(workspaceId);
332
+ .description('Set the active workspace and pin it')
333
+ .argument('<id-or-slug-or-name>', 'Workspace ID, slug, or name')
334
+ .option('--local', 'pin to ./.solidactions/config.json')
335
+ .option('--global', 'pin to ~/.solidactions/config.json')
336
+ .option('--gitignore', 'auto-add .solidactions/ to local .gitignore (skip prompt)')
337
+ .action(async (input, opts) => {
338
+ await (0, workspaces_1.workspaceSet)(input, opts);
318
339
  });
319
340
  // =============================================================================
320
341
  // ai <subcommand>
package/dist/utils/api.js CHANGED
@@ -3,6 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.augmentNotFoundMessage = augmentNotFoundMessage;
6
7
  exports.getApiHeaders = getApiHeaders;
7
8
  exports.requireResolvedConfig = requireResolvedConfig;
8
9
  exports.requireConfig = requireConfig;
@@ -12,6 +13,28 @@ const axios_1 = __importDefault(require("axios"));
12
13
  const chalk_1 = __importDefault(require("chalk"));
13
14
  const readline_1 = __importDefault(require("readline"));
14
15
  const config_1 = require("./config");
16
+ const workspace_lookup_1 = require("./workspace-lookup");
17
+ // Backend (solidactions-app PR #128) returns: "Project '<slug>' not found in your active workspace '<workspace-slug>'."
18
+ // We require the literal single-quotes around the slug so plausible future error messages
19
+ // like "Project files not found ..." don't false-match.
20
+ const NOT_FOUND_IN_WORKSPACE = /Project '.+' not found in your active workspace/;
21
+ /**
22
+ * Inspect an axios error and, if its response message matches the new
23
+ * workspace-not-found 404 from solidactions-app PR #128, append a
24
+ * remediation hint. Exported for unit testing; the live interceptor
25
+ * below calls it.
26
+ */
27
+ function augmentNotFoundMessage(error) {
28
+ const msg = error?.response?.data?.message;
29
+ if (typeof msg === 'string'
30
+ && NOT_FOUND_IN_WORKSPACE.test(msg)
31
+ && !msg.includes('Did you mean to switch workspaces?')) {
32
+ const hint = "Did you mean to switch workspaces? Run 'solidactions workspace set <name> --local' to pin this directory.";
33
+ error.response.data.message = `${msg}\n\n${hint}`;
34
+ }
35
+ return error;
36
+ }
37
+ axios_1.default.interceptors.response.use((response) => response, (error) => Promise.reject(augmentNotFoundMessage(error)));
15
38
  function getApiHeaders(config, contentType) {
16
39
  const headers = {
17
40
  'Authorization': `Bearer ${config.apiKey}`,
@@ -115,6 +138,15 @@ async function ensureWorkspaceSelected(config) {
115
138
  return config;
116
139
  }
117
140
  async function requireConfigWithWorkspace() {
118
- const config = requireConfig();
141
+ const resolved = requireResolvedConfig();
142
+ let config = resolved.config;
143
+ // -w override path: source label 'cli' on the workspace field means
144
+ // setCliWorkspaceOverride was called. workspaceId was cleared by resolveConfig
145
+ // because we don't yet know if the input was a slug or UUID. Resolve now.
146
+ if (resolved.sources.workspace === 'cli' && !config.workspaceId) {
147
+ const ws = await (0, workspace_lookup_1.resolveWorkspaceInput)(config, config.workspace);
148
+ config = { ...config, workspace: ws.slug ?? ws.name, workspaceId: ws.id };
149
+ return config;
150
+ }
119
151
  return ensureWorkspaceSelected(config);
120
152
  }
@@ -0,0 +1,101 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.decideWriteTarget = decideWriteTarget;
7
+ exports.pathForTarget = pathForTarget;
8
+ exports.ensureGitignoreCovers = ensureGitignoreCovers;
9
+ const fs_1 = __importDefault(require("fs"));
10
+ const path_1 = __importDefault(require("path"));
11
+ const readline_1 = __importDefault(require("readline"));
12
+ const chalk_1 = __importDefault(require("chalk"));
13
+ const config_1 = require("./config");
14
+ /**
15
+ * Decide whether a write should target the local or global config file,
16
+ * mirroring the contract used by `login`:
17
+ * - --local / --global mutually exclusive (errors if both set).
18
+ * - If exactly one is set, use it.
19
+ * - Else if TTY, prompt.
20
+ * - Else error and exit.
21
+ */
22
+ async function decideWriteTarget(options, promptLabel = 'Save config locally (./.solidactions) or globally (~/.solidactions)? [global] ') {
23
+ if (options.local && options.global) {
24
+ console.error(chalk_1.default.red('Error: --local and --global are mutually exclusive.'));
25
+ process.exit(1);
26
+ }
27
+ if (options.local)
28
+ return 'local';
29
+ if (options.global)
30
+ return 'global';
31
+ if (process.stdin.isTTY) {
32
+ const rl = readline_1.default.createInterface({ input: process.stdin, output: process.stdout });
33
+ try {
34
+ while (true) {
35
+ const answer = await new Promise((resolve) => rl.question(chalk_1.default.blue(promptLabel), resolve));
36
+ const normalized = answer.trim().toLowerCase();
37
+ if (normalized === '' || normalized === 'global' || normalized === 'g')
38
+ return 'global';
39
+ if (normalized === 'local' || normalized === 'l')
40
+ return 'local';
41
+ console.log(chalk_1.default.yellow("Please answer 'local' or 'global' (or press Enter for global)."));
42
+ }
43
+ }
44
+ finally {
45
+ rl.close();
46
+ }
47
+ }
48
+ console.error(chalk_1.default.red('Refusing to write config non-interactively. Pass --local or --global.'));
49
+ process.exit(1);
50
+ }
51
+ function pathForTarget(target) {
52
+ return target === 'local' ? (0, config_1.getLocalConfigPath)() : (0, config_1.getGlobalConfigPath)();
53
+ }
54
+ /**
55
+ * Ensure `.solidactions/` is in the target directory's `.gitignore`.
56
+ * Idempotent. Skips silently if pattern is already covered.
57
+ */
58
+ async function ensureGitignoreCovers(targetDir, auto) {
59
+ const gitignorePath = path_1.default.join(targetDir, '.gitignore');
60
+ const patternToAdd = '.solidactions/';
61
+ let existing = '';
62
+ if (fs_1.default.existsSync(gitignorePath)) {
63
+ existing = fs_1.default.readFileSync(gitignorePath, 'utf-8');
64
+ const lines = existing.split('\n').map((l) => l.trim());
65
+ const isCovered = lines.some((line) => {
66
+ const normalized = line
67
+ .replace(/^\*\*\//, '')
68
+ .replace(/^\//, '')
69
+ .replace(/\/(\*\*|\*)?$/, '');
70
+ return normalized === '.solidactions';
71
+ });
72
+ if (isCovered)
73
+ return;
74
+ }
75
+ let shouldAdd = auto;
76
+ if (!shouldAdd && process.stdin.isTTY) {
77
+ const rl = readline_1.default.createInterface({ input: process.stdin, output: process.stdout });
78
+ let answer;
79
+ try {
80
+ answer = await new Promise((resolve) => {
81
+ rl.question(chalk_1.default.yellow(`Local config directory may contain secrets. Add \`.solidactions/\` to ${gitignorePath}? [Y/n] `), resolve);
82
+ });
83
+ }
84
+ finally {
85
+ rl.close();
86
+ }
87
+ shouldAdd = !(answer.trim().toLowerCase().startsWith('n'));
88
+ }
89
+ if (!shouldAdd) {
90
+ console.log(chalk_1.default.yellow(`Skipping .gitignore update. Remember: ${path_1.default.join(targetDir, '.solidactions', 'config.json')} may contain your API key.`));
91
+ return;
92
+ }
93
+ const prefix = existing.length > 0 && !existing.endsWith('\n') ? '\n' : '';
94
+ try {
95
+ fs_1.default.writeFileSync(gitignorePath, `${existing}${prefix}${patternToAdd}\n`);
96
+ console.log(chalk_1.default.green(`Added \`${patternToAdd}\` to ${gitignorePath}.`));
97
+ }
98
+ catch (err) {
99
+ console.log(chalk_1.default.yellow(`Could not update ${gitignorePath}: ${err.message}. Add \`.solidactions/\` to it manually.`));
100
+ }
101
+ }
@@ -3,38 +3,60 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.setCliWorkspaceOverride = setCliWorkspaceOverride;
6
7
  exports.getGlobalConfigPath = getGlobalConfigPath;
7
8
  exports.getLocalConfigPath = getLocalConfigPath;
8
9
  exports.findLocalConfigPath = findLocalConfigPath;
9
10
  exports.readConfigFile = readConfigFile;
10
11
  exports.writeConfigFile = writeConfigFile;
12
+ exports.writeWorkspaceToFile = writeWorkspaceToFile;
11
13
  exports.removeConfigFile = removeConfigFile;
14
+ exports.mergeConfigs = mergeConfigs;
12
15
  exports.resolveConfig = resolveConfig;
13
16
  // src/utils/config.ts
14
17
  const fs_1 = __importDefault(require("fs"));
15
18
  const os_1 = __importDefault(require("os"));
16
19
  const path_1 = __importDefault(require("path"));
17
- const GLOBAL_DIR = path_1.default.join(os_1.default.homedir(), '.solidactions');
18
- const GLOBAL_FILE = path_1.default.join(GLOBAL_DIR, 'config.json');
19
20
  const LOCAL_DIR_NAME = '.solidactions';
20
21
  const LOCAL_FILE_NAME = 'config.json';
22
+ let cliWorkspaceOverride = undefined;
23
+ /**
24
+ * Set the workspace override from the top-level `-w/--workspace` CLI flag.
25
+ * Module-level state — set once at CLI startup before any subcommand runs.
26
+ * Pass `undefined` to clear (used in tests).
27
+ */
28
+ function setCliWorkspaceOverride(value) {
29
+ cliWorkspaceOverride = value;
30
+ }
21
31
  function getGlobalConfigPath() {
22
- return GLOBAL_FILE;
32
+ return path_1.default.join(os_1.default.homedir(), '.solidactions', 'config.json');
23
33
  }
24
34
  function getLocalConfigPath(cwd = process.cwd()) {
25
35
  return path_1.default.join(cwd, LOCAL_DIR_NAME, LOCAL_FILE_NAME);
26
36
  }
27
37
  /**
28
38
  * Walk up from startDir looking for `.solidactions/config.json`.
29
- * Stops at filesystem root. **Skips `$HOME` itself** so the global config
30
- * at `~/.solidactions/config.json` is never matched as a local hit.
39
+ * Stops at filesystem root.
40
+ *
41
+ * Skips both `os.homedir()` (HOME-respecting) and `os.userInfo().homedir`
42
+ * (OS-level real home) so the global config is never matched as a local hit,
43
+ * even when `$HOME` is redirected (test fixtures, sandboxes). In normal runtime
44
+ * the two paths are the same and the Set collapses to one entry.
45
+ *
31
46
  * Returns the absolute path of the nearest local config, or null.
32
47
  */
33
48
  function findLocalConfigPath(startDir = process.cwd()) {
34
- const home = os_1.default.homedir();
49
+ const skip = new Set([os_1.default.homedir()]);
50
+ try {
51
+ skip.add(os_1.default.userInfo().homedir);
52
+ }
53
+ catch {
54
+ // os.userInfo() can throw on some platforms (e.g., uid not in /etc/passwd
55
+ // inside containers). Treat it as best-effort — fall back to just os.homedir().
56
+ }
35
57
  let dir = path_1.default.resolve(startDir);
36
58
  while (true) {
37
- if (dir !== home) {
59
+ if (!skip.has(dir)) {
38
60
  const candidate = path_1.default.join(dir, LOCAL_DIR_NAME, LOCAL_FILE_NAME);
39
61
  if (fs_1.default.existsSync(candidate)) {
40
62
  return candidate;
@@ -80,6 +102,15 @@ function writeConfigFile(filePath, config) {
80
102
  fs_1.default.writeFileSync(tmp, JSON.stringify(config, null, 2), { mode: 0o600 });
81
103
  fs_1.default.renameSync(tmp, filePath);
82
104
  }
105
+ /**
106
+ * Write a workspace pin to the given config file path. Shallow-merges over
107
+ * any existing keys (preserves host/apiKey if already present). Re-uses
108
+ * writeConfigFile's atomic-write + 0o600 mode contract.
109
+ */
110
+ function writeWorkspaceToFile(filePath, workspace, workspaceId) {
111
+ const existing = readConfigFile(filePath) ?? {};
112
+ writeConfigFile(filePath, { ...existing, workspace, workspaceId });
113
+ }
83
114
  function removeConfigFile(filePath) {
84
115
  if (!fs_1.default.existsSync(filePath)) {
85
116
  return false;
@@ -98,26 +129,29 @@ function readEnvOverrides() {
98
129
  return env;
99
130
  }
100
131
  /**
101
- * Resolve config by merging three layers field-by-field (env > local > global).
102
- * Returns null only when no source contributes an apiKey AND host (i.e. nothing usable).
103
- * `activePath` is the file a write-mutating command should target: nearest local if present, else global.
132
+ * Pure merge of three config layers. Picks each Config field from the highest
133
+ * layer that defines it: env > local > global. Returns null only when no source
134
+ * contributes a host or apiKey (i.e. nothing usable).
135
+ *
136
+ * Invariant the caller must uphold: if `local` is non-null, `localPath` must
137
+ * also be non-null. (`local` is the parsed contents of a file at `localPath`;
138
+ * the path is required for source attribution.) `global` may be null even when
139
+ * `globalPath` is provided — `globalPath` is only used as a source label when
140
+ * `global` contributes a value.
104
141
  */
105
- function resolveConfig(cwd = process.cwd()) {
106
- const env = readEnvOverrides();
107
- const localPath = findLocalConfigPath(cwd);
108
- const local = localPath ? readConfigFile(localPath) : null;
109
- const global = readConfigFile(getGlobalConfigPath());
142
+ function mergeConfigs(env, local, localPath, global, globalPath) {
110
143
  const pick = (key) => {
111
144
  if (env[key] !== undefined)
112
145
  return { value: env[key], source: 'env' };
113
146
  if (local && local[key] !== undefined)
114
147
  return { value: local[key], source: localPath };
115
148
  if (global && global[key] !== undefined)
116
- return { value: global[key], source: getGlobalConfigPath() };
149
+ return { value: global[key], source: globalPath };
117
150
  return { value: undefined, source: null };
118
151
  };
119
152
  const host = pick('host');
120
153
  const apiKey = pick('apiKey');
154
+ const workspace = pick('workspace');
121
155
  const workspaceId = pick('workspaceId');
122
156
  if (!host.value && !apiKey.value) {
123
157
  return null;
@@ -126,13 +160,40 @@ function resolveConfig(cwd = process.cwd()) {
126
160
  config: {
127
161
  host: (host.value ?? ''),
128
162
  apiKey: (apiKey.value ?? ''),
163
+ workspace: workspace.value,
129
164
  workspaceId: workspaceId.value,
130
165
  },
131
166
  sources: {
132
167
  host: host.source,
133
168
  apiKey: apiKey.source,
169
+ workspace: workspace.source,
134
170
  workspaceId: workspaceId.source,
135
171
  },
136
- activePath: localPath ?? getGlobalConfigPath(),
172
+ };
173
+ }
174
+ /**
175
+ * Resolve config by merging three layers field-by-field (env > local > global).
176
+ * Returns null only when no source contributes an apiKey AND host (i.e. nothing usable).
177
+ * `activePath` is the file a write-mutating command should target: nearest local if present, else global.
178
+ */
179
+ function resolveConfig(cwd = process.cwd()) {
180
+ const env = readEnvOverrides();
181
+ const localPath = findLocalConfigPath(cwd);
182
+ const local = localPath ? readConfigFile(localPath) : null;
183
+ const globalPath = getGlobalConfigPath();
184
+ const global = readConfigFile(globalPath);
185
+ const merged = mergeConfigs(env, local, localPath, global, globalPath);
186
+ if (!merged)
187
+ return null;
188
+ if (cliWorkspaceOverride !== undefined) {
189
+ merged.config.workspace = cliWorkspaceOverride;
190
+ merged.config.workspaceId = undefined;
191
+ merged.sources.workspace = 'cli';
192
+ merged.sources.workspaceId = 'cli';
193
+ }
194
+ return {
195
+ config: merged.config,
196
+ sources: merged.sources,
197
+ activePath: localPath ?? globalPath,
137
198
  };
138
199
  }
@@ -0,0 +1,49 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.formatTable = formatTable;
7
+ exports.formatJson = formatJson;
8
+ // src/utils/webhook-formatters.ts
9
+ const chalk_1 = __importDefault(require("chalk"));
10
+ function columnWidth(min, values) {
11
+ return Math.max(min, ...values.map(v => v.length)) + 2;
12
+ }
13
+ function formatTable(webhooks, opts) {
14
+ const names = webhooks.map(w => w.workflow_name || w.workflow_slug || '?');
15
+ const urls = webhooks.map(w => w.webhook_path_url || w.webhook_url || '?');
16
+ const nameWidth = columnWidth(30, names);
17
+ const urlWidth = columnWidth(60, urls);
18
+ const headerPlain = opts.showSecrets
19
+ ? 'WORKFLOW'.padEnd(nameWidth) + 'URL'.padEnd(urlWidth) + 'SECRET'
20
+ : 'WORKFLOW'.padEnd(nameWidth) + 'URL';
21
+ const dividerWidth = opts.showSecrets ? nameWidth + urlWidth + 64 : nameWidth + urlWidth;
22
+ const lines = [];
23
+ lines.push(chalk_1.default.gray(headerPlain));
24
+ lines.push(chalk_1.default.gray('-'.repeat(dividerWidth)));
25
+ for (let i = 0; i < webhooks.length; i++) {
26
+ const name = names[i];
27
+ const url = urls[i];
28
+ let line = name.padEnd(nameWidth) + chalk_1.default.cyan(url.padEnd(urlWidth));
29
+ if (opts.showSecrets) {
30
+ const secret = webhooks[i].webhook_secret || '-';
31
+ line += chalk_1.default.gray(secret);
32
+ }
33
+ lines.push(line);
34
+ }
35
+ return lines;
36
+ }
37
+ function formatJson(webhooks, opts) {
38
+ const rows = webhooks.map(w => {
39
+ const base = {
40
+ workflow: w.workflow_name ?? w.workflow_slug ?? null,
41
+ url: w.webhook_path_url ?? w.webhook_url ?? null,
42
+ };
43
+ if (opts.showSecrets) {
44
+ base.secret = w.webhook_secret ?? '';
45
+ }
46
+ return base;
47
+ });
48
+ return JSON.stringify(rows, null, 2);
49
+ }
@@ -0,0 +1,62 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.matchWorkspace = matchWorkspace;
7
+ exports.fetchWorkspaces = fetchWorkspaces;
8
+ exports.resolveWorkspaceInput = resolveWorkspaceInput;
9
+ const axios_1 = __importDefault(require("axios"));
10
+ const chalk_1 = __importDefault(require("chalk"));
11
+ /**
12
+ * Pure: given a list of workspaces and an input string, return the matching
13
+ * workspace. Match order: id, slug, name (first match wins). Cross-tenant
14
+ * collisions resolve to the first match in the list (pre-existing behavior;
15
+ * see sol-r0b-test-todo.md for follow-up).
16
+ */
17
+ function matchWorkspace(input, workspaces) {
18
+ return workspaces.find((w) => w.id === input || w.slug === input || w.name === input);
19
+ }
20
+ /**
21
+ * Network: fetch the full list of workspaces the current API key can see.
22
+ * Normalizes the API's grouped/array response shapes.
23
+ */
24
+ async function fetchWorkspaces(config) {
25
+ const response = await axios_1.default.get(`${config.host}/api/v1/workspaces`, {
26
+ headers: {
27
+ 'Authorization': `Bearer ${config.apiKey}`,
28
+ 'Accept': 'application/json',
29
+ },
30
+ });
31
+ const grouped = response.data.workspaces || response.data.teams || response.data.data || response.data;
32
+ const out = [];
33
+ if (typeof grouped === 'object' && !Array.isArray(grouped)) {
34
+ for (const orgWorkspaces of Object.values(grouped)) {
35
+ out.push(...orgWorkspaces);
36
+ }
37
+ }
38
+ else if (Array.isArray(grouped)) {
39
+ out.push(...grouped);
40
+ }
41
+ return out;
42
+ }
43
+ /**
44
+ * High-level: fetch + match. Errors with a clear CLI message and exits
45
+ * non-zero if no match is found.
46
+ */
47
+ async function resolveWorkspaceInput(config, input) {
48
+ let workspaces;
49
+ try {
50
+ workspaces = await fetchWorkspaces(config);
51
+ }
52
+ catch (error) {
53
+ console.error(chalk_1.default.red('Failed to list workspaces:'), error.response?.data?.message || error.message);
54
+ process.exit(1);
55
+ }
56
+ const match = matchWorkspace(input, workspaces);
57
+ if (!match) {
58
+ console.error(chalk_1.default.red(`Workspace "${input}" not found. Run \`solidactions workspace list\` to list available workspaces.`));
59
+ process.exit(1);
60
+ }
61
+ return match;
62
+ }
package/package.json CHANGED
@@ -1,59 +1,62 @@
1
1
  {
2
- "name": "@solidactions/cli",
3
- "version": "1.7.1",
4
- "description": "SolidActions CLI - Deploy and manage workflow automation",
5
- "main": "dist/index.js",
6
- "bin": {
7
- "solidactions": "./dist/index.js"
8
- },
9
- "files": [
10
- "dist"
11
- ],
12
- "publishConfig": {
13
- "access": "public"
14
- },
15
- "repository": {
16
- "type": "git",
17
- "url": "https://github.com/SolidActions/solidactions-cli.git"
18
- },
19
- "homepage": "https://github.com/SolidActions/solidactions-cli",
20
- "bugs": {
21
- "url": "https://github.com/SolidActions/solidactions-cli/issues"
22
- },
23
- "license": "MIT",
24
- "engines": {
25
- "node": ">=18.0.0"
26
- },
27
- "keywords": [
28
- "solidactions",
29
- "cli",
30
- "workflow",
31
- "automation",
32
- "deploy"
33
- ],
34
- "scripts": {
35
- "build": "tsc",
36
- "prepublishOnly": "npm run build"
37
- },
38
- "dependencies": {
39
- "archiver": "^7.0.0",
40
- "axios": "^1.6.0",
41
- "chalk": "^4.1.2",
42
- "commander": "^11.0.0",
43
- "dotenv": "^17.3.1",
44
- "form-data": "^4.0.0",
45
- "fs-extra": "^11.0.0",
46
- "js-yaml": "^4.1.0",
47
- "prompts": "^2.4.2",
48
- "tar": "^7.5.12"
49
- },
50
- "devDependencies": {
51
- "@types/archiver": "^6.0.0",
52
- "@types/fs-extra": "^11.0.0",
53
- "@types/js-yaml": "^4.0.9",
54
- "@types/node": "^20.0.0",
55
- "@types/prompts": "^2.4.9",
56
- "@types/tar": "^6.1.13",
57
- "typescript": "^5.0.0"
58
- }
2
+ "name": "@solidactions/cli",
3
+ "version": "1.9.0",
4
+ "description": "SolidActions CLI - Deploy and manage workflow automation",
5
+ "main": "dist/index.js",
6
+ "bin": {
7
+ "solidactions": "./dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "https://github.com/SolidActions/solidactions-cli.git"
18
+ },
19
+ "homepage": "https://github.com/SolidActions/solidactions-cli",
20
+ "bugs": {
21
+ "url": "https://github.com/SolidActions/solidactions-cli/issues"
22
+ },
23
+ "license": "MIT",
24
+ "engines": {
25
+ "node": ">=18.0.0"
26
+ },
27
+ "keywords": [
28
+ "solidactions",
29
+ "cli",
30
+ "workflow",
31
+ "automation",
32
+ "deploy"
33
+ ],
34
+ "scripts": {
35
+ "build": "tsc",
36
+ "prepublishOnly": "npm run build",
37
+ "test": "vitest run",
38
+ "test:watch": "vitest"
39
+ },
40
+ "dependencies": {
41
+ "archiver": "^7.0.0",
42
+ "axios": "^1.6.0",
43
+ "chalk": "^4.1.2",
44
+ "commander": "^11.0.0",
45
+ "dotenv": "^17.3.1",
46
+ "form-data": "^4.0.0",
47
+ "fs-extra": "^11.0.0",
48
+ "js-yaml": "^4.1.0",
49
+ "prompts": "^2.4.2",
50
+ "tar": "^7.5.12"
51
+ },
52
+ "devDependencies": {
53
+ "@types/archiver": "^6.0.0",
54
+ "@types/fs-extra": "^11.0.0",
55
+ "@types/js-yaml": "^4.0.9",
56
+ "@types/node": "^20.0.0",
57
+ "@types/prompts": "^2.4.9",
58
+ "@types/tar": "^6.1.13",
59
+ "typescript": "^5.0.0",
60
+ "vitest": "^4.1.5"
61
+ }
59
62
  }