@solidactions/cli 1.16.0 → 1.17.1

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/README.md CHANGED
@@ -61,6 +61,24 @@ If you run multiple AI coding agents in different project folders simultaneously
61
61
  - Run `solidactions login <key> --local` in each folder so each has its own config, or
62
62
  - Set `SOLIDACTIONS_API_KEY` / `SOLIDACTIONS_WORKSPACE_ID` in the environment each agent uses (no files to share or stomp).
63
63
 
64
+ ### Deploy bundle exclusions (`solidactions.yaml`)
65
+
66
+ When you run `solidactions project deploy`, the CLI bundles your project directory and uploads it. You can control what goes into that bundle with an optional `deploy:` block in `solidactions.yaml`:
67
+
68
+ ```yaml
69
+ deploy:
70
+ exclude: # additive, gitignore-style patterns
71
+ - web/ # a large local-only sub-app you don't deploy
72
+ - "*.tmp"
73
+ gitignore: true # opt-in: also honor this project's .gitignore
74
+ ```
75
+
76
+ - **`deploy.exclude`** — a list of gitignore-style patterns (anchoring, `*.log`-at-any-depth, and `!` negation all work). Use it to keep large local-only directories (e.g. a `.venv` or a legacy `web/` app) out of the upload — this avoids `413 Request Entity Too Large` failures. Additive on top of the always-excluded defaults: `node_modules/`, `.git/`, `dist/`, `vendor/`.
77
+ - **`deploy.gitignore`** — `false` by default. Set to `true` to also apply your project's root `.gitignore` to the bundle. This is opt-in so deploys don't silently change behavior.
78
+ - **`.env` and `.env.*` are always excluded**, regardless of config, and no `!` negation can re-include them. Secrets must come from `solidactions env set` (they are injected at runtime), and must never be baked into the deploy bundle.
79
+
80
+ The CLI prints a one-line summary of what it bundled, e.g. `Bundling 312 files (.env excluded; .gitignore applied; 2 exclude rules)`, and warns about any symlinks it skipped.
81
+
64
82
  ## Commands
65
83
 
66
84
  Use `solidactions <command> --help` for full flag details on any command.
@@ -13,6 +13,7 @@ const chalk_1 = __importDefault(require("chalk"));
13
13
  const js_yaml_1 = __importDefault(require("js-yaml"));
14
14
  const env_1 = require("../utils/env");
15
15
  const api_1 = require("../utils/api");
16
+ const deploy_ignore_1 = require("../utils/deploy-ignore");
16
17
  /**
17
18
  * Validate project structure before deployment.
18
19
  * Checks for required files and SDK dependency.
@@ -282,6 +283,29 @@ async function deploy(projectName, sourcePath, options = {}) {
282
283
  return;
283
284
  }
284
285
  const archivePath = path_1.default.join(sourceDir, '.steps-deploy.tar.gz');
286
+ // Plan the file list BEFORE creating the archive write stream so a walk error
287
+ // (permission error, unreadable dir) aborts cleanly with no orphan tarball.
288
+ let plan;
289
+ try {
290
+ plan = (0, deploy_ignore_1.planDeployFiles)(sourceDir, yamlConfig);
291
+ }
292
+ catch (error) {
293
+ console.error(chalk_1.default.red('Deployment failed:'));
294
+ console.error(error.message);
295
+ process.exit(1);
296
+ }
297
+ // Summary line so silent truncation never reads as "shipped everything".
298
+ const parts = ['.env excluded'];
299
+ if (plan.summary.gitignoreApplied) {
300
+ parts.push('.gitignore applied');
301
+ }
302
+ if (plan.summary.excludeRuleCount > 0) {
303
+ parts.push(`${plan.summary.excludeRuleCount} exclude rule${plan.summary.excludeRuleCount === 1 ? '' : 's'}`);
304
+ }
305
+ console.log(chalk_1.default.gray(`Bundling ${plan.files.length} files (${parts.join('; ')})`));
306
+ if (plan.summary.symlinksSkipped.length > 0) {
307
+ console.log(chalk_1.default.yellow(`⚠ Skipped ${plan.summary.symlinksSkipped.length} symlink(s) (not followed): ${plan.summary.symlinksSkipped.join(', ')}`));
308
+ }
285
309
  const output = fs_1.default.createWriteStream(archivePath);
286
310
  const archive = (0, archiver_1.default)('tar', { gzip: true, gzipOptions: { level: 9 } });
287
311
  output.on('close', async () => {
@@ -386,16 +410,12 @@ async function deploy(projectName, sourcePath, options = {}) {
386
410
  throw err;
387
411
  });
388
412
  archive.pipe(output);
389
- // Glob patterns to ignore
390
- const ignore = ['node_modules/**', '.git/**', '.steps-deploy.tar.gz', '.steps-deploy.zip', 'dist/**', 'vendor/**', '**/node_modules/**'];
391
- // User code goes under tenantcode/ so it never conflicts with our Dockerfile
392
- archive.glob('**/*', {
393
- cwd: sourceDir,
394
- ignore: ignore,
395
- dot: true
396
- }, {
397
- prefix: 'tenantcode'
398
- });
413
+ // User code goes under tenantcode/ so it never conflicts with our Dockerfile.
414
+ // Add each planned file explicitly (per-file walk computed above).
415
+ for (const relPosixPath of plan.files) {
416
+ const absPath = path_1.default.join(sourceDir, relPosixPath);
417
+ archive.file(absPath, { name: 'tenantcode/' + relPosixPath });
418
+ }
399
419
  // Dockerfile always at archive root, referencing tenantcode/
400
420
  const universalDockerfile = [
401
421
  'FROM node:24-alpine',
@@ -0,0 +1,141 @@
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.HARD_DENY_BASENAMES = exports.DEFAULT_DEPLOY_IGNORES = void 0;
7
+ exports.isHardDenied = isHardDenied;
8
+ exports.buildDeployMatcher = buildDeployMatcher;
9
+ exports.isIgnoredDirectory = isIgnoredDirectory;
10
+ exports.planDeployFiles = planDeployFiles;
11
+ const fs_1 = __importDefault(require("fs"));
12
+ const path_1 = __importDefault(require("path"));
13
+ const ignore_1 = __importDefault(require("ignore"));
14
+ /**
15
+ * Layer-2 matcher defaults (gitignore syntax). These are excluded unless a later
16
+ * pattern (deploy.exclude or .gitignore) re-includes them via a `!` negation.
17
+ *
18
+ * `dist/` and `vendor/` are anchored to the project root (`/dist/`, `/vendor/`)
19
+ * so they only strip the project's own build output. An unanchored `dist/` /
20
+ * `vendor/` matches at any depth and wrongly excludes a vendored dependency's
21
+ * own build output — e.g. a `"@solidactions/sdk": "file:./sdk"` dep whose code
22
+ * lives in `sdk/dist/`, which then fails to resolve at build time.
23
+ * `node_modules/` and `.git/` stay unanchored: those are never wanted at any depth.
24
+ */
25
+ exports.DEFAULT_DEPLOY_IGNORES = [
26
+ 'node_modules/',
27
+ '.git/',
28
+ '/dist/',
29
+ '/vendor/',
30
+ ];
31
+ /**
32
+ * Layer-1 hard deny — matched against the *basename* of a path. Never
33
+ * re-includable by any matcher negation. Covers:
34
+ * - `.env` and any `.env.*` (secret-leak fix). Anchored so `.envrc` and
35
+ * `environment.ts` do NOT match.
36
+ * - `.steps-deploy.tar.gz` / `.steps-deploy.zip` (our own deploy artifacts,
37
+ * written into sourceDir while we walk).
38
+ */
39
+ exports.HARD_DENY_BASENAMES = /^(?:\.env(?:$|\..*)|\.steps-deploy\.(?:tar\.gz|zip))$/;
40
+ /**
41
+ * True if a path is hard-denied (Layer 1). Checked before the matcher so that
42
+ * no `!` negation in deploy.exclude or .gitignore can re-include it.
43
+ * Tests the basename of the (POSIX) path.
44
+ */
45
+ function isHardDenied(relPosixPath) {
46
+ const base = relPosixPath.split('/').pop() ?? '';
47
+ return exports.HARD_DENY_BASENAMES.test(base);
48
+ }
49
+ /**
50
+ * Build the Layer-2 ignore matcher from defaults + deploy.exclude + optional
51
+ * .gitignore. Patterns are added in order; with negations, later patterns win
52
+ * (standard ordered gitignore semantics).
53
+ */
54
+ function buildDeployMatcher(sourceDir, config) {
55
+ const matcher = (0, ignore_1.default)();
56
+ // 1. Defaults.
57
+ matcher.add(exports.DEFAULT_DEPLOY_IGNORES);
58
+ // 2. deploy.exclude entries.
59
+ const exclude = config?.deploy?.exclude ?? [];
60
+ const excludeRuleCount = exclude.length;
61
+ if (excludeRuleCount > 0) {
62
+ matcher.add(exclude);
63
+ }
64
+ // 3. .gitignore, only when opted in and the file exists.
65
+ let gitignoreApplied = false;
66
+ if (config?.deploy?.gitignore === true) {
67
+ const gitignorePath = path_1.default.join(sourceDir, '.gitignore');
68
+ if (fs_1.default.existsSync(gitignorePath)) {
69
+ matcher.add(fs_1.default.readFileSync(gitignorePath, 'utf8'));
70
+ gitignoreApplied = true;
71
+ }
72
+ }
73
+ return { matcher, summary: { gitignoreApplied, excludeRuleCount } };
74
+ }
75
+ /**
76
+ * True if a directory should be pruned (not descended into). The `ignore`
77
+ * package only matches trailing-slash dir patterns (e.g. `node_modules/`) when
78
+ * the tested path itself carries a trailing slash, so we normalize the dir path
79
+ * to exactly one trailing slash before testing. This also still matches plain
80
+ * (non-trailing-slash) patterns like a bare `foo`.
81
+ */
82
+ function isIgnoredDirectory(relPosixDir, matcher) {
83
+ const stripped = relPosixDir.replace(/\/+$/, '');
84
+ if (stripped === '') {
85
+ return false;
86
+ }
87
+ return matcher.ignores(stripped + '/');
88
+ }
89
+ /**
90
+ * Walk sourceDir, returning { files, summary }. files = relative POSIX paths to
91
+ * include, with Layer-1 hard deny + Layer-2 matcher applied and ignored
92
+ * directories pruned (so we never read into a 400 MB .venv). Symlinks (file and
93
+ * directory) are skipped and collected in summary.symlinksSkipped.
94
+ */
95
+ function planDeployFiles(sourceDir, config) {
96
+ const { matcher, summary } = buildDeployMatcher(sourceDir, config);
97
+ const files = [];
98
+ const symlinksSkipped = [];
99
+ const toPosix = (p) => p.split(path_1.default.sep).join('/');
100
+ const walk = (absDir, relDir) => {
101
+ const entries = fs_1.default.readdirSync(absDir, { withFileTypes: true });
102
+ for (const entry of entries) {
103
+ const relPath = relDir ? `${relDir}/${entry.name}` : entry.name;
104
+ const relPosix = toPosix(relPath);
105
+ const absPath = path_1.default.join(absDir, entry.name);
106
+ // Layer 1 — hard deny (files and dirs), independent of the matcher.
107
+ if (isHardDenied(relPosix)) {
108
+ continue;
109
+ }
110
+ if (entry.isSymbolicLink()) {
111
+ // Skip symlinks (both file and dir); never follow.
112
+ symlinksSkipped.push(relPosix);
113
+ continue;
114
+ }
115
+ if (entry.isDirectory()) {
116
+ // Layer 2 — prune ignored directories before descending.
117
+ if (isIgnoredDirectory(relPosix, matcher)) {
118
+ continue;
119
+ }
120
+ walk(absPath, relPath);
121
+ }
122
+ else if (entry.isFile()) {
123
+ // Layer 2 — matcher for files.
124
+ if (matcher.ignores(relPosix)) {
125
+ continue;
126
+ }
127
+ files.push(relPosix);
128
+ }
129
+ // Other entry types (sockets, fifos, etc.) are ignored.
130
+ }
131
+ };
132
+ walk(sourceDir, '');
133
+ return {
134
+ files,
135
+ summary: {
136
+ gitignoreApplied: summary.gitignoreApplied,
137
+ excludeRuleCount: summary.excludeRuleCount,
138
+ symlinksSkipped,
139
+ },
140
+ };
141
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidactions/cli",
3
- "version": "1.16.0",
3
+ "version": "1.17.1",
4
4
  "description": "SolidActions CLI - Deploy and manage workflow automation",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -45,6 +45,7 @@
45
45
  "dotenv": "^17.3.1",
46
46
  "form-data": "^4.0.0",
47
47
  "fs-extra": "^11.0.0",
48
+ "ignore": "^7.0.5",
48
49
  "js-yaml": "^4.1.0",
49
50
  "prompts": "^2.4.2",
50
51
  "tar": "^7.5.12"