@solidactions/cli 1.19.0 → 1.21.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.
@@ -3,9 +3,13 @@ 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.cacheBusterEntry = cacheBusterEntry;
7
+ exports.shouldPrintWorkspaceMismatch = shouldPrintWorkspaceMismatch;
6
8
  exports.deploy = deploy;
9
+ exports.shouldPrintWebhookSecretNotice = shouldPrintWebhookSecretNotice;
7
10
  const fs_1 = __importDefault(require("fs"));
8
11
  const path_1 = __importDefault(require("path"));
12
+ const crypto_1 = require("crypto");
9
13
  const archiver_1 = __importDefault(require("archiver"));
10
14
  const axios_1 = __importDefault(require("axios"));
11
15
  const form_data_1 = __importDefault(require("form-data"));
@@ -87,6 +91,21 @@ function validateProject(sourceDir) {
87
91
  }
88
92
  return { valid: errors.length === 0, errors, warnings };
89
93
  }
94
+ /**
95
+ * When `noCache` is true, returns an archive entry with a unique name and
96
+ * random content that busts the build-layer content hash (Blaxel/Daytona S3
97
+ * MD5 and BuildKit COPY . layer). Returns null when noCache is false/undefined.
98
+ */
99
+ function cacheBusterEntry(noCache) {
100
+ if (!noCache) {
101
+ return null;
102
+ }
103
+ const uuid = (0, crypto_1.randomUUID)();
104
+ return {
105
+ name: `tenantcode/sa-nocache-${uuid}`,
106
+ content: `force-rebuild ${uuid} ${Date.now()}`,
107
+ };
108
+ }
90
109
  /**
91
110
  * Push YAML env declarations to the project.
92
111
  * This registers all YAML-declared vars and their mappings.
@@ -113,6 +132,15 @@ async function pushYamlDeclarations(config, projectSlug, yamlConfig) {
113
132
  console.error(chalk_1.default.yellow('Warning: Failed to sync YAML declarations:'), error.response?.data?.message || error.message);
114
133
  }
115
134
  }
135
+ /**
136
+ * Returns true only when a 404 on the environment-specific project lookup
137
+ * will cause the deploy command to abort (non-production environment and
138
+ * --create was not passed). On these paths the workspace-mismatch warning
139
+ * should print. On all success/create paths it must be suppressed.
140
+ */
141
+ function shouldPrintWorkspaceMismatch(environment, willCreate) {
142
+ return environment !== 'production' && !willCreate;
143
+ }
116
144
  async function deploy(projectName, sourcePath, options = {}) {
117
145
  const config = await (0, api_1.requireConfigWithWorkspace)();
118
146
  let workspaceMismatchPrinted = false;
@@ -135,9 +163,10 @@ async function deploy(projectName, sourcePath, options = {}) {
135
163
  process.exit(1);
136
164
  }
137
165
  // -------------------------------------------------------------------------
138
- // Production-existence check — must happen BEFORE we apply any env default
139
- // so that first-time deploys without -e get a helpful error instead of
140
- // silently creating a dev-only project with no production root.
166
+ // First-deploy check — must happen BEFORE we apply any env default so that
167
+ // first-time deploys without -e get a helpful error prompting a deliberate
168
+ // environment choice, instead of silently defaulting. (Any environment can
169
+ // stand alone — production is not required to exist first.)
141
170
  // -------------------------------------------------------------------------
142
171
  let productionExists = null; // null = unknown (error path)
143
172
  let productionSlug = null;
@@ -151,11 +180,8 @@ async function deploy(projectName, sourcePath, options = {}) {
151
180
  catch (error) {
152
181
  if (error.response?.status === 404) {
153
182
  productionExists = false;
154
- // App PR #128 returns "Project '<slug>' not found in your active workspace '<ws>'." on 404.
155
- // The axios interceptor (utils/api.ts) already appended the "Did you mean to switch workspaces?"
156
- // hint. Surface that augmented message so the user sees the hint before falling through to
157
- // the first-deploy flow.
158
- printWorkspaceMismatchOnce(error);
183
+ // Project doesn't exist yet this is the normal first-deploy path.
184
+ // Do NOT print a warning here; the deploy proceeds to create/deploy successfully.
159
185
  }
160
186
  else {
161
187
  // 5xx, network error, auth failure, etc. — fail conservatively rather
@@ -164,14 +190,15 @@ async function deploy(projectName, sourcePath, options = {}) {
164
190
  process.exit(1);
165
191
  }
166
192
  }
167
- // Decision: if production does not exist and no -e flag was given, the user
168
- // must pick an environment explicitly to avoid creating a broken dev-only project.
193
+ // Decision: if this is the first deploy (project doesn't exist yet) and no -e
194
+ // flag was given, prompt the user to choose an environment explicitly so the
195
+ // first deploy is deliberate. Any environment can be the first one.
169
196
  if (productionExists === false && explicitEnv === undefined) {
170
197
  console.error(chalk_1.default.red(`\nThis is the first deploy of "${projectName}" — please pick an environment explicitly:\n`));
171
198
  console.error(` solidactions project deploy ${projectName} <path> -e production # most projects start here`);
172
- console.error(` solidactions project deploy ${projectName} <path> -e dev # dev-only (uncommon; no production root will exist)`);
173
- console.error(` solidactions project deploy ${projectName} <path> -e staging # staging-only (uncommon)`);
174
- console.error(chalk_1.default.gray('\nTip: most projects should start with `-e production`. A project needs a production root before dev/staging children can attach to it.'));
199
+ console.error(` solidactions project deploy ${projectName} <path> -e dev # start in dev (no production required first)`);
200
+ console.error(` solidactions project deploy ${projectName} <path> -e staging # start in staging`);
201
+ console.error(chalk_1.default.gray('\nTip: production is the usual default, but any environment can stand alone pick whichever you want this project to start with.'));
175
202
  process.exit(1);
176
203
  }
177
204
  // Apply default: if production already exists and the user didn't pass -e,
@@ -229,12 +256,11 @@ async function deploy(projectName, sourcePath, options = {}) {
229
256
  }
230
257
  catch (error) {
231
258
  if (error.response?.status === 404) {
232
- // App PR #128 returns "Project '<slug>' not found in your active workspace '<ws>'." on 404.
233
- // Surface the augmented message (axios interceptor already appended the hint) before
234
- // falling through to the --create / first-deploy flow.
235
- printWorkspaceMismatchOnce(error);
236
- // For non-production environments, require --create or give a clear hint
237
- if (environment !== 'production' && !options.create) {
259
+ // For non-production environments, require --create or give a clear hint.
260
+ // Only print the workspace-mismatch warning on this abort path not on
261
+ // the success/create path (shouldPrintWorkspaceMismatch guards this).
262
+ if (shouldPrintWorkspaceMismatch(environment, options.create ?? false)) {
263
+ printWorkspaceMismatchOnce(error);
238
264
  console.error(chalk_1.default.red(`\nProject "${projectName}" doesn't have a ${environment} environment.\n`));
239
265
  console.error(` If production is the intended target:`);
240
266
  console.error(` solidactions project deploy ${projectName} <path> -e production`);
@@ -363,6 +389,12 @@ async function deploy(projectName, sourcePath, options = {}) {
363
389
  if (yamlConfig) {
364
390
  await pushYamlDeclarations(config, projectSlug, yamlConfig);
365
391
  }
392
+ if (yamlConfig && shouldPrintWebhookSecretNotice(yamlConfig.workflows ?? [])) {
393
+ const envFlag = environment !== 'dev' ? ` -e ${environment}` : '';
394
+ console.log('');
395
+ console.log(chalk_1.default.blue(`ℹ Webhook secret: run \`solidactions webhook secret ${projectName}${envFlag}\` to retrieve the generated secret.`));
396
+ console.log(chalk_1.default.gray(` Set the same value in your sender (e.g. Telegram setWebhook secret_token).`));
397
+ }
366
398
  fs_1.default.unlinkSync(archivePath);
367
399
  process.exit(0);
368
400
  }
@@ -427,5 +459,27 @@ async function deploy(projectName, sourcePath, options = {}) {
427
459
  'RUN npm run build',
428
460
  ].join('\n') + '\n';
429
461
  archive.append(universalDockerfile, { name: 'Dockerfile' });
462
+ // --no-cache / --force-rebuild: inject a unique random-content file so all
463
+ // cache layers (Blaxel content hash, S3 context.tar MD5, BuildKit COPY .)
464
+ // see a new directory fingerprint and are forced to rebuild from scratch.
465
+ const buster = cacheBusterEntry(options.noCache ?? false);
466
+ if (buster) {
467
+ console.log(chalk_1.default.yellow('🔄 --no-cache: injecting cache-buster, forcing a fresh build'));
468
+ archive.append(buster.content, { name: buster.name });
469
+ }
430
470
  await archive.finalize();
431
471
  }
472
+ /**
473
+ * Returns true if any workflow in the project has a webhook trigger that
474
+ * uses HMAC or header authentication (i.e. requires a shared secret).
475
+ * Used to gate the post-deploy notice pointing authors to `webhook secret`.
476
+ */
477
+ function shouldPrintWebhookSecretNotice(workflows) {
478
+ return workflows.some(wf => {
479
+ if (wf.trigger !== 'webhook') {
480
+ return false;
481
+ }
482
+ const auth = wf.webhook?.auth ?? 'hmac';
483
+ return auth === 'hmac' || auth === 'header';
484
+ });
485
+ }
@@ -0,0 +1,194 @@
1
+ /**
2
+ * dev-shim.mjs — ESM subprocess entrypoint for `solidactions dev`.
3
+ *
4
+ * WHY THIS FILE IS .mjs (NOT .ts):
5
+ * The CLI tsconfig compiles "module": "commonjs". tsc rewrites await import(x)
6
+ * to Promise.resolve(x).then(s => require(s)) in CJS output (confirmed in
7
+ * dist/commands/dev.js:217). A .ts shim compiled by this tsconfig would also
8
+ * produce require(), which runs under tsx's CJS hook — the CJS hook does NOT
9
+ * remap .js→.ts. A hand-written .mjs file bypasses tsc entirely, so its
10
+ * await import() stays a real ESM dynamic import. tsx loads .mjs files under
11
+ * the ESM loader, which DOES remap .js→.ts for all transitive imports.
12
+ *
13
+ * HOW IT IS SPAWNED:
14
+ * npx tsx dist/commands/dev-shim.mjs <userEntry.ts>
15
+ *
16
+ * CONTEXT (from parent, via env var — no secrets to disk):
17
+ * SOLIDACTIONS_DEV_SHIM_CONTEXT: JSON-encoded DevShimContext object (see below).
18
+ *
19
+ * RESULT (written to temp file, path in context):
20
+ * The result file path is passed in context.resultPath. A private temp file
21
+ * (mode 0o600, random UUID name) is written by the parent before spawn; the
22
+ * shim writes its result JSON there; the parent reads it and unlinks it.
23
+ *
24
+ * This file is copied to dist/commands/dev-shim.mjs by the build script.
25
+ */
26
+
27
+ import path from 'path';
28
+ import fs from 'fs';
29
+ import { createRequire } from 'module';
30
+ import { randomUUID } from 'crypto';
31
+
32
+ /**
33
+ * Serialize an unknown error value to a plain JSON-serialisable object.
34
+ * JSON.stringify(new Error('x')) produces {} — this ensures the message
35
+ * is preserved so the CLI's r.error?.message reads correctly.
36
+ *
37
+ * @param {unknown} e
38
+ * @returns {{ message: string; name: string; stack?: string }}
39
+ */
40
+ function serializeError(e) {
41
+ if (e instanceof Error) {
42
+ return { message: e.message, name: e.name, stack: e.stack };
43
+ }
44
+ return { message: String(e), name: 'Error' };
45
+ }
46
+
47
+ async function main() {
48
+ const contextJson = process.env['SOLIDACTIONS_DEV_SHIM_CONTEXT'];
49
+
50
+ if (!contextJson) {
51
+ process.stderr.write('dev-shim: missing SOLIDACTIONS_DEV_SHIM_CONTEXT env var\n');
52
+ process.exit(1);
53
+ }
54
+
55
+ let ctx;
56
+ try {
57
+ ctx = JSON.parse(contextJson);
58
+ } catch (e) {
59
+ process.stderr.write(`dev-shim: failed to parse context JSON: ${e?.message ?? e}\n`);
60
+ process.exit(1);
61
+ }
62
+
63
+ const { entryPath, input, vars, mockBaseUrl, mockApiKey, runUuid, workerSessionId, resultPath } = ctx;
64
+
65
+ // Resolve SDK from the user entry's directory — ensures we hit the same SDK
66
+ // registry instance the workflow populated, not the CLI's own linked copy.
67
+ const _require = createRequire(entryPath);
68
+
69
+ let shimResult;
70
+
71
+ try {
72
+ // Import the user entry file. Under tsx's ESM loader (active because this
73
+ // file is .mjs), dynamic import() correctly remaps .js-extension imports
74
+ // to .ts files for all transitive requires in the user project.
75
+ const mod = await import(entryPath);
76
+
77
+ let descriptor;
78
+
79
+ if (mod?.default && typeof mod.default?.run === 'function') {
80
+ descriptor = mod.default;
81
+ } else {
82
+ const sdkMain = _require.resolve('@solidactions/sdk');
83
+ const registryPath = path.resolve(sdkMain, '..', 'invoke', 'registry.js');
84
+ const registry = _require(registryPath);
85
+ descriptor = registry.__getRegisteredWorkflows()[0];
86
+ }
87
+
88
+ if (!descriptor) {
89
+ shimResult = {
90
+ result: {
91
+ status: 'failed',
92
+ error: serializeError(new Error('no workflow registered after importing the entry file')),
93
+ phase: 'run',
94
+ },
95
+ };
96
+ fs.writeFileSync(resultPath, JSON.stringify(shimResult), { mode: 0o600 });
97
+ process.exit(1);
98
+ return;
99
+ }
100
+
101
+ // Create the run-row BEFORE invoking (descriptor is now available for
102
+ // workflowName — preserves current behaviour where descriptor.name is
103
+ // used at src/commands/dev.ts:319).
104
+ const sdkMainForInvoke = _require.resolve('@solidactions/sdk');
105
+ const httpClientPath = path.resolve(sdkMainForInvoke, '..', 'http_client.js');
106
+ const { HttpClient } = _require(httpClientPath);
107
+ try {
108
+ const client = new HttpClient({ baseUrl: mockBaseUrl, apiKey: mockApiKey });
109
+ await client.post('/runs/status', {
110
+ workflowUUID: runUuid,
111
+ status: 'PENDING',
112
+ workflowName: descriptor.name ?? '',
113
+ workflowClassName: '',
114
+ workflowConfigName: '',
115
+ output: null,
116
+ error: null,
117
+ authenticatedUser: '',
118
+ assumedRole: '',
119
+ authenticatedRoles: [],
120
+ request: {},
121
+ executorId: 'local-dev',
122
+ applicationVersion: '0',
123
+ applicationID: 'local-dev',
124
+ createdAt: Date.now(),
125
+ priority: 0,
126
+ ownerXid: randomUUID(),
127
+ options: {},
128
+ });
129
+ } catch (_e) {
130
+ // Best-effort, symmetric with the parent's swallow.
131
+ }
132
+
133
+ // Build InvokeCtx using the context passed from the parent.
134
+ const invokeCtx = {
135
+ input: JSON.parse(input || '{}'),
136
+ vars: Object.freeze(vars),
137
+ run: {
138
+ triggerId: 'local-dev',
139
+ runUuid,
140
+ runSecret: 'local-dev',
141
+ workerSessionId,
142
+ },
143
+ app: {
144
+ appVersion: '0',
145
+ appId: 'local-dev',
146
+ tenantId: 'local-dev',
147
+ },
148
+ api: {
149
+ url: mockBaseUrl,
150
+ key: mockApiKey,
151
+ },
152
+ mode: 'local',
153
+ };
154
+
155
+ // Invoke via internal SDK path (same resolution as runDev).
156
+ const invokePath = path.resolve(sdkMainForInvoke, '..', 'invoke', 'invoke.js');
157
+ const { invoke } = _require(invokePath);
158
+ const result = await invoke(descriptor, invokeCtx);
159
+
160
+ // Serialize Error objects before JSON.stringify (Error → {} otherwise).
161
+ const serializedResult = {
162
+ ...result,
163
+ ...(result?.error ? { error: serializeError(result.error) } : {}),
164
+ };
165
+
166
+ shimResult = { result: serializedResult };
167
+ } catch (e) {
168
+ // Surface module-resolution failures with an actionable message.
169
+ let errorObj = serializeError(e);
170
+ if (e?.code === 'MODULE_NOT_FOUND' || /Cannot find module/i.test(errorObj.message)) {
171
+ const attempted = e?.requireStack?.[0] ?? entryPath;
172
+ errorObj = {
173
+ ...errorObj,
174
+ message:
175
+ `Cannot find module: ${errorObj.message}\n` +
176
+ `\nThis usually means a TypeScript file uses a .js-extension import (e.g. './lib/foo.js')\n` +
177
+ `but the real file is foo.ts and could not be found by the loader.\n` +
178
+ `\nVerify that your project's tsconfig.json sets "moduleResolution": "NodeNext" or "Bundler"\n` +
179
+ `and that all imported files exist as .ts sources in: ${path.dirname(attempted)}`,
180
+ };
181
+ }
182
+ shimResult = {
183
+ result: { status: 'failed', error: errorObj, phase: 'run' },
184
+ };
185
+ }
186
+
187
+ fs.writeFileSync(resultPath, JSON.stringify(shimResult), { mode: 0o600 });
188
+ process.exit(shimResult.result?.status === 'failed' ? 1 : 0);
189
+ }
190
+
191
+ main().catch((e) => {
192
+ process.stderr.write(`dev-shim: unexpected top-level error: ${e?.message ?? e}\n`);
193
+ process.exit(1);
194
+ });
@@ -42,6 +42,7 @@ exports.dev = dev;
42
42
  const child_process_1 = require("child_process");
43
43
  const path_1 = __importDefault(require("path"));
44
44
  const fs_1 = __importDefault(require("fs"));
45
+ const os_1 = __importDefault(require("os"));
45
46
  const chalk_1 = __importDefault(require("chalk"));
46
47
  const js_yaml_1 = __importDefault(require("js-yaml"));
47
48
  const crypto_1 = require("crypto");
@@ -89,6 +90,127 @@ function findSolidActionsRoot(startPath) {
89
90
  }
90
91
  return null;
91
92
  }
93
+ /**
94
+ * Run the user workflow entry by spawning `npx tsx dist/commands/dev-shim.mjs`.
95
+ *
96
+ * WHY THE CHILD PROCESS (not in-process import):
97
+ * The CLI tsconfig compiles "module": "commonjs", so tsc rewrites await import(x)
98
+ * to require(x) in every .ts file it compiles. tsx's CJS hook does NOT remap
99
+ * .js→.ts in require() calls. A hand-written .mjs shim bypasses tsc entirely,
100
+ * so its import() stays a real ESM dynamic import. When tsx loads .mjs, it uses
101
+ * the ESM loader, which DOES remap .js→.ts for all transitive imports.
102
+ *
103
+ * SECRETS: context is passed as a JSON env var — OAuth proxyToken values never
104
+ * touch disk. The result uses a private temp file (mode 0o600, UUID name).
105
+ */
106
+ async function runDevViaShim(entryPath, input, vars, mockBaseUrl, runUuid, workerSessionId, stdoutLines, stderrLines) {
107
+ const shimPath = path_1.default.resolve(__dirname, 'dev-shim.mjs');
108
+ // Private temp file for the result (mode 0o600, UUID name).
109
+ const resultPath = path_1.default.join(os_1.default.tmpdir(), `sa-dev-result-${runUuid}-${(0, crypto_1.randomUUID)()}.json`);
110
+ // Write placeholder so the shim can overwrite it.
111
+ fs_1.default.writeFileSync(resultPath, '', { mode: 0o600 });
112
+ const shimCtx = {
113
+ entryPath,
114
+ input,
115
+ vars,
116
+ mockBaseUrl,
117
+ mockApiKey: 'local-dev',
118
+ runUuid,
119
+ workerSessionId,
120
+ resultPath,
121
+ };
122
+ try {
123
+ // Use async spawn (NOT spawnSync): the SDK mock backend runs in THIS
124
+ // process's event loop, and the shim makes HTTP calls back to it
125
+ // (run-row creation, step/sleep/recv routes). spawnSync would block the
126
+ // parent's event loop, deadlocking the mock server — so we await an
127
+ // async child and pump its stdout/stderr while the loop stays live.
128
+ let timedOut = false;
129
+ const spawnError = await new Promise((resolve) => {
130
+ const child = (0, child_process_1.spawn)('npx', ['tsx', shimPath], {
131
+ stdio: ['ignore', 'pipe', 'pipe'],
132
+ env: {
133
+ ...process.env,
134
+ SOLIDACTIONS_DEV_SHIM_CONTEXT: JSON.stringify(shimCtx),
135
+ },
136
+ });
137
+ let stdoutBuf = '';
138
+ let stderrBuf = '';
139
+ child.stdout.setEncoding('utf8');
140
+ child.stderr.setEncoding('utf8');
141
+ child.stdout.on('data', (chunk) => { stdoutBuf += chunk; });
142
+ child.stderr.on('data', (chunk) => { stderrBuf += chunk; });
143
+ const timer = setTimeout(() => {
144
+ timedOut = true;
145
+ child.kill('SIGKILL');
146
+ }, 120_000);
147
+ child.on('error', (e) => {
148
+ clearTimeout(timer);
149
+ if (stdoutBuf.trim()) {
150
+ stdoutLines.push(stdoutBuf.trimEnd());
151
+ }
152
+ if (stderrBuf.trim()) {
153
+ stderrLines.push(stderrBuf.trimEnd());
154
+ }
155
+ resolve(e);
156
+ });
157
+ child.on('close', () => {
158
+ clearTimeout(timer);
159
+ if (stdoutBuf.trim()) {
160
+ stdoutLines.push(stdoutBuf.trimEnd());
161
+ }
162
+ if (stderrBuf.trim()) {
163
+ stderrLines.push(stderrBuf.trimEnd());
164
+ }
165
+ resolve(undefined);
166
+ });
167
+ });
168
+ if (spawnError) {
169
+ const code = spawnError.code;
170
+ const msg = code === 'ENOENT'
171
+ ? 'npx not found. Make sure Node.js is installed.'
172
+ : `Failed to spawn tsx shim: ${spawnError.message}`;
173
+ return {
174
+ result: { status: 'failed', error: { message: msg, name: 'Error' }, phase: 'run' },
175
+ };
176
+ }
177
+ let resultContent = '';
178
+ try {
179
+ resultContent = fs_1.default.readFileSync(resultPath, 'utf8');
180
+ }
181
+ catch {
182
+ // shim crashed before writing — stderr already captured above
183
+ return {
184
+ result: {
185
+ status: 'failed',
186
+ error: { message: 'dev-shim did not write a result file (check stderr above)', name: 'Error' },
187
+ phase: 'run',
188
+ },
189
+ };
190
+ }
191
+ if (!resultContent.trim()) {
192
+ const msg = timedOut
193
+ ? 'dev-shim timed out after 120s'
194
+ : 'dev-shim wrote an empty result file (check stderr above)';
195
+ return {
196
+ result: {
197
+ status: 'failed',
198
+ error: { message: msg, name: 'Error' },
199
+ phase: 'run',
200
+ },
201
+ };
202
+ }
203
+ const shimResult = JSON.parse(resultContent);
204
+ return { result: shimResult.result };
205
+ }
206
+ finally {
207
+ // Clean up result temp file on all exit paths.
208
+ try {
209
+ fs_1.default.unlinkSync(resultPath);
210
+ }
211
+ catch { /* ignore */ }
212
+ }
213
+ }
92
214
  /**
93
215
  * Run a workflow entry file in local-dev mode:
94
216
  * 1. Fetch declared vars + connections from the platform (via api seam).
@@ -208,8 +330,33 @@ async function runDev(opts) {
208
330
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
209
331
  const { createMockServer } = _require(sdkTestingMain);
210
332
  const mockServer = await createMockServer();
333
+ // Pre-generate run IDs so they can be passed to the shim (for .ts entries)
334
+ // or used in-process (for .js/.mjs entries).
335
+ const runUuid = (0, crypto_1.randomUUID)();
336
+ const workerSessionId = (0, crypto_1.randomUUID)();
211
337
  try {
212
- // 7. Import the workflow entry file. Prefer the module's default export
338
+ // 7. Load and invoke the workflow.
339
+ //
340
+ // For .ts entries: spawn `npx tsx dist/commands/dev-shim.mjs`. The .mjs shim
341
+ // is NOT compiled by tsc, so its import() stays a real ESM dynamic import.
342
+ // tsx's ESM loader remaps .js-extension imports to .ts files, fixing the
343
+ // Cannot-find-module bug on multi-file NodeNext projects.
344
+ //
345
+ // The shim creates the run-row (it has the descriptor name) and invokes.
346
+ // stdio: pipe — child stdout/stderr are captured into stdoutLines/stderrLines.
347
+ //
348
+ // For .js/.mjs entries: original in-process import path (no change).
349
+ const isTs = /\.(ts|tsx|mts|cts)$/.test(entryPath);
350
+ if (isTs) {
351
+ const shimInvokeResult = await runDevViaShim(entryPath, opts.input, vars, mockServer.baseUrl, runUuid, workerSessionId, stdoutLines, stderrLines);
352
+ return {
353
+ stdout: stdoutLines.join('\n'),
354
+ stderr: stderrLines.join('\n'),
355
+ result: shimInvokeResult.result,
356
+ };
357
+ }
358
+ // JS/MJS path: in-process import (original behaviour, no change).
359
+ // 8. Import the workflow entry file. Prefer the module's default export
213
360
  // (a WorkflowDescriptor returned by defineWorkflow) so that repeated
214
361
  // runDev() calls in the same test process don't need to clear and
215
362
  // re-register from a cached module (cached CJS modules don't re-run
@@ -236,8 +383,7 @@ async function runDev(opts) {
236
383
  result: { status: 'failed', error: new Error('no workflow registered'), phase: 'run' },
237
384
  };
238
385
  }
239
- // 8. Build InvokeCtx.
240
- const runUuid = (0, crypto_1.randomUUID)();
386
+ // 9. Build InvokeCtx.
241
387
  const ctx = {
242
388
  input: JSON.parse(opts.input || '{}'),
243
389
  vars: Object.freeze(vars),
@@ -245,7 +391,7 @@ async function runDev(opts) {
245
391
  triggerId: 'local-dev',
246
392
  runUuid,
247
393
  runSecret: 'local-dev',
248
- workerSessionId: (0, crypto_1.randomUUID)(),
394
+ workerSessionId,
249
395
  },
250
396
  app: {
251
397
  appVersion: '0',
@@ -258,7 +404,7 @@ async function runDev(opts) {
258
404
  },
259
405
  mode: 'local',
260
406
  };
261
- // 9. Create the run-row BEFORE invoking. invoke() itself never creates
407
+ // 10. Create the run-row BEFORE invoking. invoke() itself never creates
262
408
  // the durable run record — the production launcher (SolidActions.run
263
409
  // → #initOneShotStatusRow) does that first, and step/sleep/recv
264
410
  // sub-routes (`/runs/status/<id>/...`) 404 ("Workflow not found")
@@ -296,7 +442,7 @@ async function runDev(opts) {
296
442
  // Best-effort, symmetric with #initOneShotStatusRow's swallow.
297
443
  err(`failed to create local run-row: ${e?.message ?? e}`);
298
444
  }
299
- // 10. Invoke via internal SDK path (invoke() is not in the public index).
445
+ // 11. Invoke via internal SDK path (invoke() is not in the public index).
300
446
  const invokePath = path_1.default.resolve(sdkMainForInvoke, '..', 'invoke', 'invoke.js');
301
447
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
302
448
  const { invoke } = _require(invokePath);
@@ -320,13 +466,25 @@ const TSX_REEXEC_GUARD = 'SOLIDACTIONS_DEV_TSX_REEXEC';
320
466
  * Detect whether the current Node process already has a TypeScript loader
321
467
  * registered (tsx / ts-node). When true, `await import('<file>.ts')` works
322
468
  * in-process and we can run `runDev` directly without re-exec.
469
+ *
470
+ * NOTE: Boolean(process._preload_modules) is intentionally NOT used here —
471
+ * an empty array is truthy, causing a false positive that always skips the
472
+ * re-exec path even when no TypeScript loader is active.
323
473
  */
324
474
  function hasTsLoader() {
325
475
  if (process.env[TSX_REEXEC_GUARD] === '1') {
326
476
  return true;
327
477
  }
328
478
  const execArgv = process.execArgv.join(' ');
329
- return /tsx|ts-node/.test(execArgv) || Boolean(process._preload_modules);
479
+ if (/tsx|ts-node/.test(execArgv)) {
480
+ return true;
481
+ }
482
+ // Check _preload_modules for tsx/ts-node entries (non-empty array check).
483
+ const preload = process._preload_modules;
484
+ if (Array.isArray(preload) && preload.length > 0) {
485
+ return preload.some((m) => typeof m === 'string' && /tsx|ts-node/i.test(m));
486
+ }
487
+ return false;
330
488
  }
331
489
  /**
332
490
  * Re-exec the CLI under `npx tsx` so that `runDev`'s `await import()` of a
@@ -3,11 +3,16 @@ 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.isNonTty = isNonTty;
6
7
  exports.envSet = envSet;
7
8
  const axios_1 = __importDefault(require("axios"));
8
9
  const chalk_1 = __importDefault(require("chalk"));
9
10
  const prompts_1 = __importDefault(require("prompts"));
10
11
  const api_1 = require("../utils/api");
12
+ /** Returns true when stdin is not an interactive terminal (CI, pipes, scripts). */
13
+ function isNonTty() {
14
+ return !process.stdin.isTTY;
15
+ }
11
16
  async function envSet(keyOrProject, valueOrKey, valueIfProject, options = {}) {
12
17
  const config = await (0, api_1.requireConfigWithWorkspace)();
13
18
  // Detect mode based on arguments
@@ -31,6 +36,11 @@ async function envSet(keyOrProject, valueOrKey, valueIfProject, options = {}) {
31
36
  const mappings = mappingsResponse.data || [];
32
37
  const existing = mappings.find((m) => m.env_name === key);
33
38
  if (existing && existing.has_value) {
39
+ if (isNonTty()) {
40
+ console.error(chalk_1.default.red(`Variable "${key}" already has a value in "${projectName}" (${environment}). ` +
41
+ `Pass -y / --yes to overwrite without confirmation.`));
42
+ process.exit(1);
43
+ }
34
44
  console.log(chalk_1.default.yellow(`Variable "${key}" already has a value in "${projectName}" (${environment}).`));
35
45
  const confirm = await (0, prompts_1.default)({
36
46
  type: 'confirm',
@@ -119,6 +129,11 @@ async function envSet(keyOrProject, valueOrKey, valueIfProject, options = {}) {
119
129
  let action;
120
130
  if (existing) {
121
131
  if (!options.yes) {
132
+ if (isNonTty()) {
133
+ console.error(chalk_1.default.red(`Global variable "${key}" already exists. ` +
134
+ `Pass -y / --yes to overwrite without confirmation.`));
135
+ process.exit(1);
136
+ }
122
137
  console.log(chalk_1.default.yellow(`Global variable "${key}" already exists.`));
123
138
  const confirm = await (0, prompts_1.default)({
124
139
  type: 'confirm',
@@ -69,9 +69,10 @@ async function init(directory, options = {}) {
69
69
  if (directory) {
70
70
  console.log(chalk_1.default.gray(` cd ${directory}`));
71
71
  }
72
- console.log(chalk_1.default.gray(` # Fill in WEBHOOK_SECRET and any other env vars in solidactions.yaml:`));
73
- console.log(chalk_1.default.gray(` solidactions env set ${projectName} WEBHOOK_SECRET <your-secret> -e production`));
74
72
  console.log(chalk_1.default.gray(` solidactions project deploy ${projectName} -e production`));
73
+ console.log(chalk_1.default.gray(` # If your workflow uses a webhook, retrieve its auto-generated secret`));
74
+ console.log(chalk_1.default.gray(` # and set that value in your sender (e.g. Telegram secret_token):`));
75
+ console.log(chalk_1.default.gray(` solidactions webhook secret ${projectName} -e production`));
75
76
  }
76
77
  catch (err) {
77
78
  if (err.message?.includes('rate limit')) {
@@ -3,16 +3,35 @@ 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.buildBuildLogUrl = buildBuildLogUrl;
7
+ exports.buildBuildLogRequest = buildBuildLogRequest;
6
8
  exports.logsBuild = logsBuild;
7
9
  const axios_1 = __importDefault(require("axios"));
8
10
  const chalk_1 = __importDefault(require("chalk"));
9
11
  const api_1 = require("../utils/api");
10
- async function logsBuild(projectName) {
12
+ function buildBuildLogUrl(host, projectName, environment) {
13
+ if (environment) {
14
+ return `${host}/api/v1/projects/resolve/build-log`;
15
+ }
16
+ return `${host}/api/v1/projects/${projectName}/build-log`;
17
+ }
18
+ function buildBuildLogRequest(host, projectName, environment) {
19
+ if (environment) {
20
+ return {
21
+ url: `${host}/api/v1/projects/resolve/build-log`,
22
+ params: { name: projectName, environment },
23
+ };
24
+ }
25
+ return { url: `${host}/api/v1/projects/${projectName}/build-log` };
26
+ }
27
+ async function logsBuild(projectName, environment) {
11
28
  const config = await (0, api_1.requireConfigWithWorkspace)();
12
29
  console.log(chalk_1.default.blue(`Fetching build logs for project "${projectName}"...`));
30
+ const { url, params } = buildBuildLogRequest(config.host, projectName, environment);
13
31
  try {
14
- const response = await axios_1.default.get(`${config.host}/api/v1/projects/${projectName}/build-log`, {
32
+ const response = await axios_1.default.get(url, {
15
33
  headers: (0, api_1.getApiHeaders)(config),
34
+ params,
16
35
  });
17
36
  const buildLog = response.data.build_log || response.data;
18
37
  if (!buildLog || buildLog.length === 0) {
@@ -29,7 +48,11 @@ async function logsBuild(projectName) {
29
48
  console.error(chalk_1.default.red('Authentication failed. Run "solidactions login <api-key>" to re-configure.'));
30
49
  }
31
50
  else if (error.response.status === 404) {
32
- console.error(chalk_1.default.red(`Project "${projectName}" not found.`));
51
+ console.error(chalk_1.default.red(error.response.data?.message ?? `Project "${projectName}" not found.`));
52
+ const envs = error.response.data?.available_environments;
53
+ if (envs && envs.length > 0) {
54
+ console.error(chalk_1.default.yellow(`Available environments: ${envs.join(', ')}. Pass -e <env> to select one.`));
55
+ }
33
56
  }
34
57
  else {
35
58
  console.error(chalk_1.default.red(`Failed: ${error.response.status}`), error.response.data);
@@ -3,38 +3,54 @@ 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.buildRunListParams = buildRunListParams;
6
7
  exports.runs = runs;
7
8
  exports.summaryStatusLabel = summaryStatusLabel;
8
9
  exports.detailedOutcomeTag = detailedOutcomeTag;
9
10
  const axios_1 = __importDefault(require("axios"));
10
11
  const chalk_1 = __importDefault(require("chalk"));
11
12
  const api_1 = require("../utils/api");
12
- async function runs(projectName, options = {}) {
13
- const config = await (0, api_1.requireConfigWithWorkspace)();
14
- // Default limit is lower for --detailed (more data per run)
13
+ /**
14
+ * Build the params object for GET /api/v1/runs.
15
+ * Extracted as a pure function so it can be unit-tested without mocking axios.
16
+ */
17
+ function buildRunListParams(projectName, options = {}) {
15
18
  const defaultLimit = options.detailed ? 5 : 20;
16
19
  const limit = options.limit || defaultLimit;
20
+ const params = { limit };
21
+ if (projectName)
22
+ params.project = projectName;
23
+ if (options.environment)
24
+ params.environment = options.environment;
25
+ if (options.offset)
26
+ params.offset = options.offset;
27
+ if (options.status)
28
+ params.status = options.status;
29
+ if (options.since)
30
+ params.since = parseSince(options.since);
31
+ if (options.workflow)
32
+ params.workflow = options.workflow;
33
+ if (options.detailed)
34
+ params.detailed = '1';
35
+ if (options.hasErrors)
36
+ params.has_errors = '1';
37
+ return params;
38
+ }
39
+ async function runs(projectName, options = {}) {
40
+ const config = await (0, api_1.requireConfigWithWorkspace)();
17
41
  try {
18
- const params = { limit };
19
- if (projectName)
20
- params.project = projectName;
21
- if (options.offset)
22
- params.offset = options.offset;
23
- if (options.status)
24
- params.status = options.status;
25
- if (options.since)
26
- params.since = parseSince(options.since);
27
- if (options.workflow)
28
- params.workflow = options.workflow;
29
- if (options.detailed)
30
- params.detailed = '1';
31
- if (options.hasErrors)
32
- params.has_errors = '1';
42
+ const params = buildRunListParams(projectName, options);
33
43
  const response = await axios_1.default.get(`${config.host}/api/v1/runs`, {
34
44
  headers: (0, api_1.getApiHeaders)(config),
35
45
  params,
36
46
  });
37
47
  const runsList = response.data.data || response.data;
48
+ // Check server-side error fields FIRST (project_not_found comes back as 200 with data:[])
49
+ const serverError = response.data.error;
50
+ if (serverError === 'project_not_found') {
51
+ console.error(chalk_1.default.red(response.data.message || `Project '${projectName}' not found.`));
52
+ process.exit(1);
53
+ }
38
54
  if (!runsList || runsList.length === 0) {
39
55
  if (options.json) {
40
56
  console.log('[]');
@@ -58,7 +74,13 @@ async function runs(projectName, options = {}) {
58
74
  }
59
75
  catch (error) {
60
76
  if (error.response) {
61
- if (error.response.status === 401) {
77
+ // ambiguous_project is 422 — axios throws, so handle here
78
+ if (error.response.status === 422 && error.response.data?.error === 'ambiguous_project') {
79
+ const envs = (error.response.data.environments ?? []).join(', ');
80
+ console.error(chalk_1.default.yellow(`Ambiguous project '${projectName}': found in environments: ${envs}.`));
81
+ console.error(chalk_1.default.yellow(`Pass -e <env> to disambiguate, e.g.: solidactions run list ${projectName} -e dev`));
82
+ }
83
+ else if (error.response.status === 401) {
62
84
  console.error(chalk_1.default.red('Authentication failed. Run "solidactions login <api-key>" to re-configure.'));
63
85
  }
64
86
  else {
@@ -0,0 +1,74 @@
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.formatSecretOutput = formatSecretOutput;
7
+ exports.webhookSecret = webhookSecret;
8
+ const axios_1 = __importDefault(require("axios"));
9
+ const chalk_1 = __importDefault(require("chalk"));
10
+ const api_1 = require("../utils/api");
11
+ function formatSecretOutput(rows, workflowFilter) {
12
+ if (workflowFilter) {
13
+ const match = rows.find((r) => r.workflow_name === workflowFilter);
14
+ if (!match)
15
+ return `No webhook named "${workflowFilter}" found.`;
16
+ return match.webhook_secret ?? '(secret not returned — check your access level)';
17
+ }
18
+ if (rows.length === 1) {
19
+ return rows[0].webhook_secret ?? '(secret not returned — check your access level)';
20
+ }
21
+ const lines = rows.map((r) => `${(r.workflow_name ?? '?').padEnd(32)}${r.webhook_secret ?? '(none)'}`);
22
+ return ['WORKFLOW'.padEnd(32) + 'SECRET', '-'.repeat(80), ...lines].join('\n');
23
+ }
24
+ async function webhookSecret(projectName, options = {}) {
25
+ const format = options.format ?? 'text';
26
+ if (format !== 'text' && format !== 'json') {
27
+ console.error(chalk_1.default.red(`Invalid --format: ${format}. Expected 'text' or 'json'.`));
28
+ process.exit(1);
29
+ }
30
+ const config = await (0, api_1.requireConfigWithWorkspace)();
31
+ const environment = options.env || 'dev';
32
+ const projectSlug = environment === 'production' ? projectName : `${projectName}-${environment}`;
33
+ if (format === 'text') {
34
+ process.stderr.write(chalk_1.default.dim(`(environment: ${environment} — pass -e <env> to change)\n`));
35
+ }
36
+ try {
37
+ const response = await axios_1.default.get(`${config.host}/api/v1/projects/${projectSlug}/webhooks`, {
38
+ headers: (0, api_1.getApiHeaders)(config),
39
+ params: { show_secrets: 'true' },
40
+ });
41
+ const rows = response.data.data ?? [];
42
+ if (rows.length === 0) {
43
+ console.error(chalk_1.default.yellow(`No webhook workflows found for project "${projectName}"${environment !== 'production' ? ` (${environment})` : ''}.`));
44
+ process.exit(1);
45
+ }
46
+ if (format === 'json') {
47
+ const out = options.workflow
48
+ ? rows
49
+ .filter((r) => r.workflow_name === options.workflow)
50
+ .map((r) => ({ workflow: r.workflow_name, secret: r.webhook_secret }))
51
+ : rows.map((r) => ({ workflow: r.workflow_name, secret: r.webhook_secret }));
52
+ console.log(JSON.stringify(out, null, 2));
53
+ return;
54
+ }
55
+ console.log(formatSecretOutput(rows, options.workflow));
56
+ }
57
+ catch (error) {
58
+ if (error.response) {
59
+ if (error.response.status === 401) {
60
+ console.error(chalk_1.default.red('Authentication failed. Run "solidactions login <api-key>" to re-configure.'));
61
+ }
62
+ else if (error.response.status === 404) {
63
+ console.error(chalk_1.default.red(`Project "${projectName}" not found.`));
64
+ }
65
+ else {
66
+ console.error(chalk_1.default.red(`Failed: ${error.response.status}`), error.response.data);
67
+ }
68
+ }
69
+ else {
70
+ console.error(chalk_1.default.red('Connection failed:'), error.message);
71
+ }
72
+ process.exit(1);
73
+ }
74
+ }
package/dist/index.js CHANGED
@@ -26,6 +26,7 @@ const schedule_set_1 = require("./commands/schedule-set");
26
26
  const schedule_list_1 = require("./commands/schedule-list");
27
27
  const schedule_delete_1 = require("./commands/schedule-delete");
28
28
  const webhook_list_1 = require("./commands/webhook-list");
29
+ const webhook_secret_1 = require("./commands/webhook-secret");
29
30
  const dev_1 = require("./commands/dev");
30
31
  const ai_init_1 = require("./commands/ai-init");
31
32
  const ai_examples_1 = require("./commands/ai-examples");
@@ -158,8 +159,13 @@ project
158
159
  .option('-e, --env <environment>', 'Target environment (production/staging/dev). Required on first deploy of a new project.')
159
160
  .option('--create', 'Create environment project if it doesn\'t exist')
160
161
  .option('--config-only', 'Sync YAML env declarations without building/deploying')
162
+ .option('--no-cache', 'Force a fresh build, bypassing all build caches')
163
+ .option('--force-rebuild', 'Force a fresh build, bypassing all build caches (alias for --no-cache)')
161
164
  .action((projectName, path, options) => {
162
- (0, deploy_1.deploy)(projectName, path, options);
165
+ // Commander's negation convention maps --no-cache to options.cache === false
166
+ // (NOT options.noCache). Normalize both flags to a single boolean.
167
+ const noCache = options.cache === false || options.forceRebuild === true;
168
+ (0, deploy_1.deploy)(projectName, path, { ...options, noCache });
163
169
  });
164
170
  project
165
171
  .command('pull')
@@ -173,9 +179,10 @@ project
173
179
  project
174
180
  .command('logs')
175
181
  .description('View build/deployment logs for a project')
176
- .argument('<project>', 'Project name')
177
- .action((projectName) => {
178
- (0, project_logs_1.logsBuild)(projectName);
182
+ .argument('<project>', 'Project name (or family name with -e)')
183
+ .option('-e, --environment <environment>', 'Environment to resolve (production/staging/dev)')
184
+ .action((projectName, options) => {
185
+ (0, project_logs_1.logsBuild)(projectName, options.environment);
179
186
  });
180
187
  project
181
188
  .command('list')
@@ -210,6 +217,7 @@ runCmd
210
217
  .option('--detailed', 'Include timeline, steps, and logs per run (default limit: 5)')
211
218
  .option('--has-errors', 'Show only runs with errors (step errors, retries, or degraded results)')
212
219
  .option('--json', 'Output as JSON')
220
+ .option('-e, --environment <environment>', 'Environment to filter by (production/staging/dev)')
213
221
  .action((projectName, options) => {
214
222
  (0, run_list_1.runs)(projectName, options);
215
223
  });
@@ -340,6 +348,16 @@ webhook
340
348
  .action((projectName, options) => {
341
349
  (0, webhook_list_1.webhookList)(projectName, options);
342
350
  });
351
+ webhook
352
+ .command('secret')
353
+ .description('Print the webhook secret for a project (set this value in your sender)')
354
+ .argument('<project>', 'Project name')
355
+ .option('-e, --env <environment>', 'Environment (production/staging/dev)')
356
+ .option('--workflow <name>', 'Filter to a specific workflow by name')
357
+ .option('--format <format>', 'Output format: text or json', 'text')
358
+ .action((projectName, options) => {
359
+ (0, webhook_secret_1.webhookSecret)(projectName, options);
360
+ });
343
361
  // =============================================================================
344
362
  // workspace <subcommand>
345
363
  // =============================================================================
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidactions/cli",
3
- "version": "1.19.0",
3
+ "version": "1.21.0",
4
4
  "description": "SolidActions CLI - Deploy and manage workflow automation",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -32,7 +32,7 @@
32
32
  "deploy"
33
33
  ],
34
34
  "scripts": {
35
- "build": "tsc",
35
+ "build": "tsc && cp src/commands/dev-shim.mjs dist/commands/dev-shim.mjs",
36
36
  "prepublishOnly": "npm run build",
37
37
  "test": "vitest run",
38
38
  "test:watch": "vitest"