hostfn 0.1.5 → 0.1.7

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.
Files changed (143) hide show
  1. package/README.md +1 -2
  2. package/dist/index.js +0 -0
  3. package/package.json +14 -3
  4. package/src/__tests__/core/sync.test.ts +42 -0
  5. package/src/__tests__/core/workspace.test.ts +180 -0
  6. package/src/commands/deploy.ts +122 -83
  7. package/src/commands/init.ts +1 -1
  8. package/src/config/loader.ts +1 -1
  9. package/src/config/schema.ts +1 -1
  10. package/src/core/ssh.ts +2 -0
  11. package/src/core/sync.ts +64 -23
  12. package/src/core/workspace.ts +111 -6
  13. package/dist/__tests__/core/backup.test.d.ts +0 -2
  14. package/dist/__tests__/core/backup.test.d.ts.map +0 -1
  15. package/dist/__tests__/core/backup.test.js +0 -108
  16. package/dist/__tests__/core/backup.test.js.map +0 -1
  17. package/dist/__tests__/core/health.test.d.ts +0 -2
  18. package/dist/__tests__/core/health.test.d.ts.map +0 -1
  19. package/dist/__tests__/core/health.test.js +0 -97
  20. package/dist/__tests__/core/health.test.js.map +0 -1
  21. package/dist/__tests__/core/lock.test.d.ts +0 -2
  22. package/dist/__tests__/core/lock.test.d.ts.map +0 -1
  23. package/dist/__tests__/core/lock.test.js +0 -136
  24. package/dist/__tests__/core/lock.test.js.map +0 -1
  25. package/dist/__tests__/core/nginx-multi-domain.test.d.ts +0 -2
  26. package/dist/__tests__/core/nginx-multi-domain.test.d.ts.map +0 -1
  27. package/dist/__tests__/core/nginx-multi-domain.test.js +0 -158
  28. package/dist/__tests__/core/nginx-multi-domain.test.js.map +0 -1
  29. package/dist/__tests__/runtimes/pm2.test.d.ts +0 -2
  30. package/dist/__tests__/runtimes/pm2.test.d.ts.map +0 -1
  31. package/dist/__tests__/runtimes/pm2.test.js +0 -111
  32. package/dist/__tests__/runtimes/pm2.test.js.map +0 -1
  33. package/dist/__tests__/utils/validation.test.d.ts +0 -2
  34. package/dist/__tests__/utils/validation.test.d.ts.map +0 -1
  35. package/dist/__tests__/utils/validation.test.js +0 -136
  36. package/dist/__tests__/utils/validation.test.js.map +0 -1
  37. package/dist/commands/deploy.d.ts +0 -11
  38. package/dist/commands/deploy.d.ts.map +0 -1
  39. package/dist/commands/deploy.js +0 -636
  40. package/dist/commands/deploy.js.map +0 -1
  41. package/dist/commands/env.d.ts +0 -21
  42. package/dist/commands/env.d.ts.map +0 -1
  43. package/dist/commands/env.js +0 -317
  44. package/dist/commands/env.js.map +0 -1
  45. package/dist/commands/expose.d.ts +0 -6
  46. package/dist/commands/expose.d.ts.map +0 -1
  47. package/dist/commands/expose.js +0 -379
  48. package/dist/commands/expose.js.map +0 -1
  49. package/dist/commands/init.d.ts +0 -2
  50. package/dist/commands/init.d.ts.map +0 -1
  51. package/dist/commands/init.js +0 -175
  52. package/dist/commands/init.js.map +0 -1
  53. package/dist/commands/logs.d.ts +0 -10
  54. package/dist/commands/logs.d.ts.map +0 -1
  55. package/dist/commands/logs.js +0 -75
  56. package/dist/commands/logs.js.map +0 -1
  57. package/dist/commands/rollback.d.ts +0 -6
  58. package/dist/commands/rollback.d.ts.map +0 -1
  59. package/dist/commands/rollback.js +0 -113
  60. package/dist/commands/rollback.js.map +0 -1
  61. package/dist/commands/server/info.d.ts +0 -2
  62. package/dist/commands/server/info.d.ts.map +0 -1
  63. package/dist/commands/server/info.js +0 -104
  64. package/dist/commands/server/info.js.map +0 -1
  65. package/dist/commands/server/setup.d.ts +0 -11
  66. package/dist/commands/server/setup.d.ts.map +0 -1
  67. package/dist/commands/server/setup.js +0 -161
  68. package/dist/commands/server/setup.js.map +0 -1
  69. package/dist/commands/status.d.ts +0 -6
  70. package/dist/commands/status.d.ts.map +0 -1
  71. package/dist/commands/status.js +0 -120
  72. package/dist/commands/status.js.map +0 -1
  73. package/dist/config/loader.d.ts +0 -21
  74. package/dist/config/loader.d.ts.map +0 -1
  75. package/dist/config/loader.js +0 -54
  76. package/dist/config/loader.js.map +0 -1
  77. package/dist/config/schema.d.ts +0 -323
  78. package/dist/config/schema.d.ts.map +0 -1
  79. package/dist/config/schema.js +0 -108
  80. package/dist/config/schema.js.map +0 -1
  81. package/dist/core/backup.d.ts +0 -34
  82. package/dist/core/backup.d.ts.map +0 -1
  83. package/dist/core/backup.js +0 -95
  84. package/dist/core/backup.js.map +0 -1
  85. package/dist/core/health.d.ts +0 -31
  86. package/dist/core/health.d.ts.map +0 -1
  87. package/dist/core/health.js +0 -78
  88. package/dist/core/health.js.map +0 -1
  89. package/dist/core/local.d.ts +0 -19
  90. package/dist/core/local.d.ts.map +0 -1
  91. package/dist/core/local.js +0 -50
  92. package/dist/core/local.js.map +0 -1
  93. package/dist/core/lock.d.ts +0 -28
  94. package/dist/core/lock.d.ts.map +0 -1
  95. package/dist/core/lock.js +0 -89
  96. package/dist/core/lock.js.map +0 -1
  97. package/dist/core/nginx.d.ts +0 -43
  98. package/dist/core/nginx.d.ts.map +0 -1
  99. package/dist/core/nginx.js +0 -131
  100. package/dist/core/nginx.js.map +0 -1
  101. package/dist/core/ssh.d.ts +0 -79
  102. package/dist/core/ssh.d.ts.map +0 -1
  103. package/dist/core/ssh.js +0 -264
  104. package/dist/core/ssh.js.map +0 -1
  105. package/dist/core/sync.d.ts +0 -25
  106. package/dist/core/sync.d.ts.map +0 -1
  107. package/dist/core/sync.js +0 -117
  108. package/dist/core/sync.js.map +0 -1
  109. package/dist/core/workspace.d.ts +0 -13
  110. package/dist/core/workspace.d.ts.map +0 -1
  111. package/dist/core/workspace.js +0 -141
  112. package/dist/core/workspace.js.map +0 -1
  113. package/dist/index.d.ts +0 -3
  114. package/dist/index.d.ts.map +0 -1
  115. package/dist/index.js.map +0 -1
  116. package/dist/runtimes/base.d.ts +0 -115
  117. package/dist/runtimes/base.d.ts.map +0 -1
  118. package/dist/runtimes/base.js +0 -16
  119. package/dist/runtimes/base.js.map +0 -1
  120. package/dist/runtimes/nodejs/detector.d.ts +0 -47
  121. package/dist/runtimes/nodejs/detector.d.ts.map +0 -1
  122. package/dist/runtimes/nodejs/detector.js +0 -143
  123. package/dist/runtimes/nodejs/detector.js.map +0 -1
  124. package/dist/runtimes/nodejs/index.d.ts +0 -14
  125. package/dist/runtimes/nodejs/index.d.ts.map +0 -1
  126. package/dist/runtimes/nodejs/index.js +0 -213
  127. package/dist/runtimes/nodejs/index.js.map +0 -1
  128. package/dist/runtimes/nodejs/pm2.d.ts +0 -17
  129. package/dist/runtimes/nodejs/pm2.d.ts.map +0 -1
  130. package/dist/runtimes/nodejs/pm2.js +0 -60
  131. package/dist/runtimes/nodejs/pm2.js.map +0 -1
  132. package/dist/runtimes/registry.d.ts +0 -34
  133. package/dist/runtimes/registry.d.ts.map +0 -1
  134. package/dist/runtimes/registry.js +0 -58
  135. package/dist/runtimes/registry.js.map +0 -1
  136. package/dist/utils/logger.d.ts +0 -47
  137. package/dist/utils/logger.d.ts.map +0 -1
  138. package/dist/utils/logger.js +0 -76
  139. package/dist/utils/logger.js.map +0 -1
  140. package/dist/utils/validation.d.ts +0 -32
  141. package/dist/utils/validation.d.ts.map +0 -1
  142. package/dist/utils/validation.js +0 -125
  143. package/dist/utils/validation.js.map +0 -1
package/README.md CHANGED
@@ -178,7 +178,7 @@ Create `hostfn.config.json` in your project root:
178
178
  "build": {
179
179
  "command": "npm run build",
180
180
  "directory": "dist",
181
- "nodeModules": "production"
181
+ "nodeModules": "all"
182
182
  },
183
183
  "start": {
184
184
  "command": "npm start",
@@ -1133,4 +1133,3 @@ MIT
1133
1133
 
1134
1134
 
1135
1135
 
1136
-
package/dist/index.js CHANGED
File without changes
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "hostfn",
3
- "version": "0.1.5",
4
- "license": "Apache-2.0",
3
+ "version": "0.1.7",
4
+ "license": "MIT",
5
5
  "description": "Universal application deployment CLI",
6
6
  "type": "module",
7
7
  "bin": {
@@ -21,12 +21,14 @@
21
21
  "chalk": "^5.3.0",
22
22
  "ora": "^7.0.1",
23
23
  "inquirer": "^9.2.12",
24
+ "semver": "^7.7.2",
24
25
  "zod": "^3.22.4",
25
26
  "ssh2": "^1.15.0",
26
27
  "dotenv": "^16.3.1",
27
28
  "execa": "^8.0.1"
28
29
  },
29
30
  "devDependencies": {
31
+ "@types/semver": "^7.7.1",
30
32
  "@types/node": "^20.10.0",
31
33
  "@types/ssh2": "^1.15.0",
32
34
  "@types/inquirer": "^9.0.7",
@@ -39,7 +41,8 @@
39
41
  "author": "21n",
40
42
  "repository": {
41
43
  "type": "git",
42
- "url": "git+https://github.com/21nCo/super-functions.git"
44
+ "url": "git+https://github.com/21nCo/super-functions.git",
45
+ "directory": "hostfn/cli"
43
46
  },
44
47
  "bugs": {
45
48
  "url": "https://github.com/21nCo/super-functions/issues"
@@ -50,5 +53,13 @@
50
53
  ],
51
54
  "engines": {
52
55
  "node": ">=18.0.0"
56
+ },
57
+ "publishConfig": {
58
+ "access": "public"
59
+ },
60
+ "superfunctions": {
61
+ "initFunction": "hostFn",
62
+ "schemaVersion": 1,
63
+ "namespace": "hostfn"
53
64
  }
54
65
  }
@@ -0,0 +1,42 @@
1
+ import { describe, expect, it, vi, beforeEach } from 'vitest';
2
+ import { FileSync } from '../../core/sync.js';
3
+
4
+ const { execaMock } = vi.hoisted(() => ({
5
+ execaMock: vi.fn(),
6
+ }));
7
+
8
+ vi.mock('execa', () => ({
9
+ execa: execaMock,
10
+ }));
11
+
12
+ describe('FileSync.syncLocal', () => {
13
+ beforeEach(() => {
14
+ execaMock.mockReset();
15
+ execaMock.mockResolvedValue({
16
+ exitCode: 0,
17
+ stdout: '',
18
+ stderr: '',
19
+ });
20
+ });
21
+
22
+ it('applies include filters before exclude filters so rsync overrides work', async () => {
23
+ await FileSync.syncLocal('/tmp/source', '/tmp/destination', {
24
+ include: ['.env.production'],
25
+ exclude: ['.env.*'],
26
+ });
27
+
28
+ expect(execaMock).toHaveBeenCalledWith(
29
+ 'rsync',
30
+ expect.arrayContaining([
31
+ '--include',
32
+ '.env.production',
33
+ '--exclude',
34
+ '.env.*',
35
+ ]),
36
+ expect.any(Object),
37
+ );
38
+
39
+ const args = execaMock.mock.calls[0][1] as string[];
40
+ expect(args.indexOf('--include')).toBeLessThan(args.indexOf('--exclude'));
41
+ });
42
+ });
@@ -0,0 +1,180 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { tmpdir } from 'os';
5
+ import { WorkspaceManager } from '../../core/workspace.js';
6
+
7
+ const execSyncMock = vi.fn();
8
+
9
+ vi.mock('child_process', () => ({
10
+ execSync: execSyncMock,
11
+ }));
12
+
13
+ describe('WorkspaceManager.generateLockfile', () => {
14
+ let rootDir: string;
15
+ let serviceDir: string;
16
+ let targetDir: string;
17
+
18
+ beforeEach(() => {
19
+ execSyncMock.mockReset();
20
+ rootDir = mkdtempSync(join(tmpdir(), 'hostfn-workspace-root-'));
21
+ serviceDir = join(rootDir, 'services', 'api');
22
+ targetDir = mkdtempSync(join(tmpdir(), 'hostfn-workspace-target-'));
23
+
24
+ mkdirSync(serviceDir, { recursive: true });
25
+
26
+ writeFileSync(
27
+ join(rootDir, 'package.json'),
28
+ JSON.stringify({
29
+ private: true,
30
+ workspaces: ['services/*'],
31
+ }, null, 2),
32
+ );
33
+
34
+ writeFileSync(
35
+ join(serviceDir, 'package.json'),
36
+ JSON.stringify({
37
+ name: '@hostfn/api',
38
+ dependencies: {
39
+ commander: '^12.0.0',
40
+ chalk: '^5.0.0',
41
+ 'zero-lib': '^0.2.3',
42
+ },
43
+ }, null, 2),
44
+ );
45
+
46
+ writeFileSync(
47
+ join(rootDir, 'package-lock.json'),
48
+ JSON.stringify({
49
+ name: 'workspace-root',
50
+ lockfileVersion: 3,
51
+ packages: {
52
+ '': { name: 'workspace-root' },
53
+ 'node_modules/commander': { version: '4.1.1', name: 'commander' },
54
+ 'services/api/node_modules/commander': { version: '12.1.0', name: 'commander' },
55
+ 'node_modules/chalk': { version: '5.3.0', name: 'chalk' },
56
+ 'node_modules/zero-lib': { version: '0.5.0', name: 'zero-lib' },
57
+ 'services/api/node_modules/zero-lib': { version: '0.2.8', name: 'zero-lib' },
58
+ },
59
+ }, null, 2),
60
+ );
61
+
62
+ writeFileSync(
63
+ join(targetDir, 'package.json'),
64
+ JSON.stringify({
65
+ name: '@hostfn/api',
66
+ dependencies: {
67
+ commander: '^12.0.0',
68
+ chalk: '^5.0.0',
69
+ 'zero-lib': '^0.2.3',
70
+ },
71
+ }, null, 2),
72
+ );
73
+ });
74
+
75
+ afterEach(() => {
76
+ rmSync(rootDir, { recursive: true, force: true });
77
+ rmSync(targetDir, { recursive: true, force: true });
78
+ });
79
+
80
+ it('pins package.json to the exact workspace-scoped versions used for lockfile generation', async () => {
81
+ execSyncMock.mockImplementation(() => {
82
+ const pinnedPackageJson = JSON.parse(readFileSync(join(targetDir, 'package.json'), 'utf-8'));
83
+ expect(pinnedPackageJson.dependencies.commander).toBe('12.1.0');
84
+ expect(pinnedPackageJson.dependencies.chalk).toBe('5.3.0');
85
+ expect(pinnedPackageJson.dependencies['zero-lib']).toBe('0.2.8');
86
+ });
87
+
88
+ const manager = new WorkspaceManager();
89
+ await manager.detectWorkspace(serviceDir);
90
+ await manager.generateLockfile(targetDir, serviceDir);
91
+
92
+ expect(execSyncMock).toHaveBeenCalledWith(
93
+ 'npm install --package-lock-only --ignore-scripts',
94
+ expect.objectContaining({
95
+ cwd: targetDir,
96
+ stdio: 'ignore',
97
+ }),
98
+ );
99
+
100
+ const deployedPackageJson = JSON.parse(readFileSync(join(targetDir, 'package.json'), 'utf-8'));
101
+ expect(deployedPackageJson.dependencies.commander).toBe('12.1.0');
102
+ expect(deployedPackageJson.dependencies.chalk).toBe('5.3.0');
103
+ expect(deployedPackageJson.dependencies['zero-lib']).toBe('0.2.8');
104
+ });
105
+ });
106
+
107
+ describe('WorkspaceManager.rewritePackageJson', () => {
108
+ let rootDir: string;
109
+ let serviceDir: string;
110
+ let toolingDir: string;
111
+ let targetDir: string;
112
+
113
+ beforeEach(async () => {
114
+ rootDir = mkdtempSync(join(tmpdir(), 'hostfn-workspace-root-'));
115
+ serviceDir = join(rootDir, 'services', 'api');
116
+ toolingDir = join(rootDir, 'services', 'tooling');
117
+ targetDir = mkdtempSync(join(tmpdir(), 'hostfn-workspace-target-'));
118
+
119
+ mkdirSync(serviceDir, { recursive: true });
120
+ mkdirSync(toolingDir, { recursive: true });
121
+
122
+ writeFileSync(
123
+ join(rootDir, 'package.json'),
124
+ JSON.stringify({
125
+ private: true,
126
+ workspaces: ['services/*'],
127
+ }, null, 2),
128
+ );
129
+
130
+ writeFileSync(
131
+ join(toolingDir, 'package.json'),
132
+ JSON.stringify({
133
+ name: '@hostfn/tooling',
134
+ version: '1.0.0',
135
+ }, null, 2),
136
+ );
137
+
138
+ writeFileSync(
139
+ join(serviceDir, 'package.json'),
140
+ JSON.stringify({
141
+ name: '@hostfn/api',
142
+ dependencies: {
143
+ '@hostfn/tooling': 'workspace:*',
144
+ },
145
+ devDependencies: {
146
+ '@hostfn/tooling': 'workspace:*',
147
+ },
148
+ }, null, 2),
149
+ );
150
+
151
+ writeFileSync(
152
+ join(targetDir, 'package.json'),
153
+ JSON.stringify({
154
+ name: '@hostfn/api',
155
+ dependencies: {
156
+ '@hostfn/tooling': 'workspace:*',
157
+ },
158
+ devDependencies: {
159
+ '@hostfn/tooling': 'workspace:*',
160
+ },
161
+ }, null, 2),
162
+ );
163
+ });
164
+
165
+ afterEach(() => {
166
+ rmSync(rootDir, { recursive: true, force: true });
167
+ rmSync(targetDir, { recursive: true, force: true });
168
+ });
169
+
170
+ it('preserves workspace devDependencies as file references in the deployment bundle', async () => {
171
+ const manager = new WorkspaceManager();
172
+ await manager.detectWorkspace(serviceDir);
173
+
174
+ manager.rewritePackageJson(serviceDir, targetDir);
175
+
176
+ const rewrittenPackageJson = JSON.parse(readFileSync(join(targetDir, 'package.json'), 'utf-8'));
177
+ expect(rewrittenPackageJson.dependencies['@hostfn/tooling']).toBe('file:./__workspace__/@hostfn/tooling');
178
+ expect(rewrittenPackageJson.devDependencies['@hostfn/tooling']).toBe('file:./__workspace__/@hostfn/tooling');
179
+ });
180
+ });
@@ -1,7 +1,7 @@
1
1
  import ora from 'ora';
2
2
  import { tmpdir } from 'os';
3
3
  import { join } from 'path';
4
- import { mkdtempSync, rmSync, cpSync } from 'fs';
4
+ import { mkdtempSync, rmSync, cpSync, existsSync } from 'fs';
5
5
  import { Logger } from '../utils/logger.js';
6
6
  import { ConfigLoader } from '../config/loader.js';
7
7
  import { createSSHConnection, SSHConnection } from '../core/ssh.js';
@@ -25,6 +25,37 @@ interface DeployOptions {
25
25
  all?: boolean;
26
26
  }
27
27
 
28
+ interface InstallPlan {
29
+ nodeModulesMode: 'all' | 'production' | 'none';
30
+ useCi: boolean;
31
+ installCmd: string | null;
32
+ }
33
+
34
+ function computeInstallPlan(
35
+ config: HostfnConfig,
36
+ hasLockFile: boolean,
37
+ ): InstallPlan {
38
+ const nodeModulesMode =
39
+ config.build?.nodeModules ?? (config.build?.command ? 'all' : 'production');
40
+
41
+ const installCmd =
42
+ nodeModulesMode === 'none'
43
+ ? null
44
+ : hasLockFile
45
+ ? (nodeModulesMode === 'all'
46
+ ? 'npm ci --install-links'
47
+ : 'npm ci --production --install-links')
48
+ : (nodeModulesMode === 'all'
49
+ ? 'npm install --install-links'
50
+ : 'npm install --production --install-links');
51
+
52
+ return {
53
+ nodeModulesMode,
54
+ useCi: hasLockFile,
55
+ installCmd,
56
+ };
57
+ }
58
+
28
59
  export async function deployCommand(
29
60
  environment: string,
30
61
  options: DeployOptions
@@ -310,8 +341,16 @@ async function deploySingleService(
310
341
  Logger.section('Pre-flight Checks');
311
342
  Logger.br();
312
343
 
313
- // For local mode, skip rsync and SSH connection
344
+ // Local mode still relies on rsync so include the same pre-flight check.
314
345
  if (options.local) {
346
+ const rsyncSpinner = ora('Checking rsync availability...').start();
347
+ const hasRsync = await FileSync.isRsyncAvailable();
348
+ if (!hasRsync) {
349
+ rsyncSpinner.fail('rsync not installed');
350
+ throw new Error('rsync is required for deployment. Please install it first.');
351
+ }
352
+ rsyncSpinner.succeed('rsync available');
353
+
315
354
  const localSpinner = ora('Initializing local deployment...').start();
316
355
  ssh = new LocalExecutor();
317
356
  localSpinner.succeed('Local mode ready');
@@ -404,6 +443,19 @@ async function deploySingleService(
404
443
  bundleSpinner.text = 'Bundling workspace dependencies...';
405
444
  await workspaceManager.bundleWorkspaceDependencies(sourceDir, bundleDir);
406
445
 
446
+ // Rewrite package.json to use file: references for workspace deps
447
+ workspaceManager.rewritePackageJson(sourceDir, bundleDir);
448
+
449
+ const { nodeModulesMode } = computeInstallPlan(
450
+ config,
451
+ existsSync(join(bundleDir, 'package-lock.json')),
452
+ );
453
+
454
+ if (nodeModulesMode !== 'none') {
455
+ bundleSpinner.text = 'Generating lockfile...';
456
+ await workspaceManager.generateLockfile(bundleDir, sourceDir);
457
+ }
458
+
407
459
  bundleSpinner.succeed('Workspace dependencies bundled');
408
460
  Logger.br();
409
461
 
@@ -416,84 +468,58 @@ async function deploySingleService(
416
468
  Logger.br();
417
469
 
418
470
  if (options.local) {
419
- // Local mode: copy files directly
471
+ // Local mode: use rsync locally so include/exclude behavior stays aligned
472
+ // with the remote deploy path.
420
473
  const syncSpinner = ora('Copying files locally...').start();
421
-
422
- cpSync(actualSourceDir, remoteDir, {
423
- recursive: true,
424
- filter: (src) => {
425
- const relativePath = src.replace(actualSourceDir, '');
426
- const excludePatterns = config.sync?.exclude || [
427
- 'node_modules',
428
- '.git',
429
- 'dist',
430
- '.env',
431
- '*.log',
432
- ];
433
- return !excludePatterns.some(pattern => relativePath.includes(pattern));
434
- },
474
+ const rawExcludes = config.sync?.exclude || [
475
+ 'node_modules',
476
+ '.git',
477
+ '.github',
478
+ 'dist',
479
+ 'build',
480
+ '.env',
481
+ '.env.*',
482
+ '*.log',
483
+ '.turbo',
484
+ '.wrangler',
485
+ ];
486
+ const localExcludes = bundleDir
487
+ ? rawExcludes.map((pattern) => (pattern === 'dist' || pattern === 'build') ? `/${pattern}` : pattern)
488
+ : rawExcludes;
489
+
490
+ await FileSync.syncLocal(actualSourceDir, remoteDir, {
491
+ exclude: localExcludes,
492
+ include: config.sync?.include,
493
+ verbose: false,
435
494
  });
436
495
 
437
496
  syncSpinner.succeed('Files copied successfully');
438
-
439
- // Copy workspace dependencies if they exist
440
- if (bundleDir) {
441
- const bundledNodeModules = join(bundleDir, 'node_modules');
442
- const { existsSync } = await import('fs');
443
-
444
- if (existsSync(bundledNodeModules)) {
445
- const uploadSpinner = ora('Copying workspace dependencies...').start();
446
- cpSync(bundledNodeModules, join(remoteDir, 'node_modules'), { recursive: true });
447
- uploadSpinner.succeed('Workspace dependencies copied');
448
- }
449
- }
450
497
  } else {
451
498
  // Remote mode: use rsync
452
499
  const syncSpinner = ora('Syncing files to server...').start();
500
+
501
+ // When syncing a workspace bundle, anchor 'dist' to root (/dist) so it only
502
+ // excludes the service's own dist folder and not workspace deps' compiled output
503
+ // (e.g. __workspace__/@21n/email/dist which is needed for type declarations).
504
+ const rawExcludes = config.sync?.exclude || [
505
+ 'node_modules', '.git', '.github', 'dist', 'build', '.env', '.env.*', '*.log', '.turbo', '.wrangler',
506
+ ];
507
+ const syncExcludes = bundleDir
508
+ ? rawExcludes.map(p => (p === 'dist' || p === 'build') ? `/${p}` : p)
509
+ : rawExcludes;
453
510
 
454
511
  await FileSync.sync(
455
512
  actualSourceDir,
456
513
  remoteDir,
457
514
  host,
458
515
  {
459
- exclude: config.sync?.exclude || [
460
- 'node_modules',
461
- '.git',
462
- 'dist',
463
- '.env',
464
- '*.log',
465
- ],
516
+ exclude: syncExcludes,
517
+ include: config.sync?.include,
466
518
  verbose: false,
467
519
  }
468
520
  );
469
521
 
470
522
  syncSpinner.succeed('Files synced successfully');
471
-
472
- // Upload bundled workspace dependencies if they exist (before npm install)
473
- if (bundleDir) {
474
- const bundledNodeModules = join(bundleDir, 'node_modules');
475
- const { existsSync } = await import('fs');
476
-
477
- if (existsSync(bundledNodeModules)) {
478
- Logger.section('Uploading Workspace Dependencies');
479
- Logger.br();
480
-
481
- const uploadSpinner = ora('Uploading bundled workspace dependencies...').start();
482
-
483
- await FileSync.sync(
484
- bundledNodeModules,
485
- join(remoteDir, 'node_modules'),
486
- host,
487
- {
488
- exclude: [],
489
- verbose: false,
490
- }
491
- );
492
-
493
- uploadSpinner.succeed('Workspace dependencies uploaded');
494
- Logger.br();
495
- }
496
- }
497
523
  }
498
524
 
499
525
  Logger.br();
@@ -504,30 +530,31 @@ async function deploySingleService(
504
530
 
505
531
  // Install dependencies
506
532
  const installSpinner = ora('Installing dependencies...').start();
507
-
533
+
508
534
  // Check if package-lock.json exists, use npm ci if available, otherwise npm install
509
535
  const lockFileCheck = await ssh.exec(
510
536
  'test -f package-lock.json && echo "exists"',
511
537
  { cwd: remoteDir, streaming: false }
512
538
  );
513
539
  const hasLockFile = lockFileCheck.stdout.trim() === 'exists';
514
-
515
- // If build command exists, install all dependencies (including dev); otherwise production only
516
- const needsDevDeps = !!config.build?.command;
517
- const installCmd = hasLockFile
518
- ? (needsDevDeps ? 'npm ci --install-links' : 'npm ci --production --install-links')
519
- : (needsDevDeps ? 'npm install --install-links' : 'npm install --production --install-links');
520
-
521
- const installResult = await ssh.exec(
522
- installCmd,
523
- { cwd: remoteDir, streaming: false }
524
- );
525
-
526
- if (installResult.exitCode !== 0) {
527
- installSpinner.fail('Dependency installation failed');
528
- throw new Error(`${installCmd} failed: ${installResult.stderr}`);
540
+
541
+ const { installCmd } = computeInstallPlan(config, hasLockFile);
542
+
543
+ if (installCmd) {
544
+ const installResult = await ssh.exec(
545
+ installCmd,
546
+ { cwd: remoteDir, streaming: false }
547
+ );
548
+
549
+ if (installResult.exitCode !== 0) {
550
+ installSpinner.fail('Dependency installation failed');
551
+ const errorOutput = installResult.stderr || installResult.stdout;
552
+ throw new Error(`${installCmd} failed: ${errorOutput}`);
553
+ }
554
+ installSpinner.succeed('Dependencies installed');
555
+ } else {
556
+ installSpinner.info('Skipping dependency installation (build.nodeModules=none)');
529
557
  }
530
- installSpinner.succeed('Dependencies installed');
531
558
 
532
559
  // Build application
533
560
  if (config.build?.command) {
@@ -684,8 +711,9 @@ EOF`);
684
711
 
685
712
  const healthSpinner = ora('Waiting for service to be ready...').start();
686
713
  const healthPath = config.health?.path || '/health';
714
+ const timeout = config.health?.timeout || 60;
687
715
  const retries = config.health?.retries || 10;
688
- const interval = config.health?.interval || 3000;
716
+ const interval = config.health?.interval || 3;
689
717
 
690
718
  let healthy = false;
691
719
  for (let i = 0; i < retries; i++) {
@@ -693,7 +721,7 @@ EOF`);
693
721
 
694
722
  // Check health via SSH using curl on localhost
695
723
  const healthCheckResult = await ssh.exec(
696
- `curl -sf http://localhost:${envConfig.port}${healthPath}`,
724
+ `curl --max-time ${timeout} -sf http://localhost:${envConfig.port}${healthPath}`,
697
725
  { cwd: remoteDir, streaming: false }
698
726
  );
699
727
 
@@ -704,7 +732,7 @@ EOF`);
704
732
  }
705
733
 
706
734
  if (i < retries - 1) {
707
- await new Promise(resolve => setTimeout(resolve, interval));
735
+ await new Promise(resolve => setTimeout(resolve, interval * 1000));
708
736
  }
709
737
  }
710
738
 
@@ -804,8 +832,19 @@ async function dryRunDeploy(
804
832
  Logger.log(` → Exclude: ${config.sync?.exclude?.join(', ')}`);
805
833
  Logger.br();
806
834
 
835
+ const hasLocalLockFile = existsSync(join(process.cwd(), 'package-lock.json'));
836
+ const workspaceManager = new WorkspaceManager();
837
+ const isWorkspace = await workspaceManager.detectWorkspace(process.cwd());
838
+ const hasBundledWorkspaceDeps =
839
+ isWorkspace && workspaceManager.getWorkspaceDependencies(process.cwd()).length > 0;
840
+ const { installCmd } = computeInstallPlan(config, hasLocalLockFile || hasBundledWorkspaceDeps);
841
+
807
842
  Logger.log('3. Remote Build');
808
- Logger.log(' → npm ci --production');
843
+ if (installCmd) {
844
+ Logger.log(` → ${installCmd}`);
845
+ } else {
846
+ Logger.log(' → Skip dependency installation (build.nodeModules=none)');
847
+ }
809
848
  if (config.build?.command) {
810
849
  Logger.log(` → ${config.build.command}`);
811
850
  }
@@ -134,7 +134,7 @@ export async function initCommand(): Promise<void> {
134
134
  build: {
135
135
  command: buildAnswers.buildCommand,
136
136
  directory: 'dist',
137
- nodeModules: 'production',
137
+ nodeModules: 'all',
138
138
  },
139
139
  start: {
140
140
  command: buildAnswers.startCommand,
@@ -37,7 +37,7 @@ export class ConfigLoader {
37
37
  const result = HostfnConfigSchema.safeParse(config);
38
38
 
39
39
  if (!result.success) {
40
- const errors = result.error.errors
40
+ const errors = result.error.issues
41
41
  .map(err => ` - ${err.path.join('.')}: ${err.message}`)
42
42
  .join('\n');
43
43
 
@@ -19,7 +19,7 @@ export type EnvironmentConfig = z.infer<typeof EnvironmentConfigSchema>;
19
19
  export const BuildConfigSchema = z.object({
20
20
  command: z.string().describe('Build command to run'),
21
21
  directory: z.string().default('dist').describe('Output directory'),
22
- nodeModules: z.enum(['all', 'production', 'none']).default('production'),
22
+ nodeModules: z.enum(['all', 'production', 'none']).optional(),
23
23
  }).optional();
24
24
 
25
25
  export type BuildConfig = z.infer<typeof BuildConfigSchema>;
package/src/core/ssh.ts CHANGED
@@ -35,6 +35,8 @@ export class SSHConnection {
35
35
  host: options.host,
36
36
  port: options.port || 22,
37
37
  username: options.username,
38
+ keepaliveInterval: 30000, // Send keepalive every 30s to survive long npm installs
39
+ keepaliveCountMax: 10,
38
40
  };
39
41
 
40
42
  // Add authentication method