@lamalibre/portlama-agent 1.0.2 → 1.0.4

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/LICENSE.md CHANGED
@@ -13,6 +13,7 @@ You may use, copy, modify, and distribute this software for any **noncommercial*
13
13
  Commercial use of this software requires a commercial license, available by contacting licence@codelama.com.tr.
14
14
 
15
15
  Commercial use includes, but is not limited to:
16
+
16
17
  - Using Portlama in a business to serve data to clients or partners
17
18
  - Deploying Portlama as part of a revenue-generating service or product
18
19
  - Using Portlama internally at a for-profit organization
@@ -85,7 +86,7 @@ The first time you are notified in writing that you have violated any of these t
85
86
 
86
87
  ### No Liability
87
88
 
88
- ***As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will not be liable to you for any damages arising out of these terms or the use or nature of the software, under any kind of legal claim.***
89
+ **_As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will not be liable to you for any damages arising out of these terms or the use or nature of the software, under any kind of legal claim._**
89
90
 
90
91
  ### Definitions
91
92
 
package/README.md CHANGED
@@ -15,17 +15,17 @@ connection details and an agent-scoped mTLS certificate.
15
15
 
16
16
  ## Commands
17
17
 
18
- | Command | Description |
19
- | ----------- | ---------------------------------------- |
20
- | `setup` | Install Chisel and configure the tunnel |
21
- | `update` | Update Chisel binary to latest version |
22
- | `uninstall` | Remove Chisel, plist, and configuration |
23
- | `status` | Show tunnel connection status |
24
- | `logs` | Display recent tunnel logs |
25
- | `sites` | List all static sites |
26
- | `sites create <name>` | Create a new static site (admin cert only) |
27
- | `sites delete <name-or-id>` | Delete a static site (admin cert only) |
28
- | `deploy <name-or-id> <local-path>` | Deploy a local directory to a site |
18
+ | Command | Description |
19
+ | ---------------------------------- | ------------------------------------------ |
20
+ | `setup` | Install Chisel and configure the tunnel |
21
+ | `update` | Update Chisel binary to latest version |
22
+ | `uninstall` | Remove Chisel, plist, and configuration |
23
+ | `status` | Show tunnel connection status |
24
+ | `logs` | Display recent tunnel logs |
25
+ | `sites` | List all static sites |
26
+ | `sites create <name>` | Create a new static site (admin cert only) |
27
+ | `sites delete <name-or-id>` | Delete a static site (admin cert only) |
28
+ | `deploy <name-or-id> <local-path>` | Deploy a local directory to a site |
29
29
 
30
30
  ### Sites Command
31
31
 
@@ -57,12 +57,12 @@ portlama-agent sites create docs --spa --auth
57
57
  portlama-agent sites create myblog --type custom --domain myblog.com
58
58
  ```
59
59
 
60
- | Flag | Default | Description |
61
- | ---- | ------- | ----------- |
62
- | `--type <managed\|custom>` | `managed` | Site type: managed subdomain or custom domain |
63
- | `--domain <fqdn>` | — | Custom domain (required when `--type custom`) |
64
- | `--spa` | off | Enable SPA mode (serve `index.html` for all routes) |
65
- | `--auth` | off | Enable Authelia protection |
60
+ | Flag | Default | Description |
61
+ | -------------------------- | --------- | --------------------------------------------------- |
62
+ | `--type <managed\|custom>` | `managed` | Site type: managed subdomain or custom domain |
63
+ | `--domain <fqdn>` | — | Custom domain (required when `--type custom`) |
64
+ | `--spa` | off | Enable SPA mode (serve `index.html` for all routes) |
65
+ | `--auth` | off | Enable Authelia protection |
66
66
 
67
67
  **Delete a site:**
68
68
 
@@ -102,10 +102,10 @@ portlama-agent deploy blog ./dist
102
102
 
103
103
  ## Requirements
104
104
 
105
- | Requirement | Details |
106
- | ----------- | --------------- |
107
- | OS | macOS |
108
- | Node.js | >= 20.0.0 |
105
+ | Requirement | Details |
106
+ | ----------- | ------------------------------- |
107
+ | OS | macOS |
108
+ | Node.js | >= 20.0.0 |
109
109
  | Access | User account (no root required) |
110
110
 
111
111
  ## How It Works
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamalibre/portlama-agent",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "Mac agent for Portlama — installs Chisel tunnel client and manages launchd agent",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
@@ -35,7 +35,8 @@
35
35
  "dependencies": {
36
36
  "chalk": "^5.3.0",
37
37
  "execa": "^9.0.0",
38
- "listr2": "^8.0.0"
38
+ "listr2": "^8.0.0",
39
+ "ws": "^8.18.0"
39
40
  },
40
41
  "engines": {
41
42
  "node": ">=20.0.0"
@@ -0,0 +1,196 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { stat } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import chalk from 'chalk';
5
+ import { assertMacOS } from '../lib/platform.js';
6
+ import { requireAgentConfig } from '../lib/config.js';
7
+ import { downloadRemoteFile, uploadRemoteFile } from '../lib/panel-api.js';
8
+
9
+ /**
10
+ * Parse a cp argument to determine if it's a remote path (agent-label:/path)
11
+ * or a local path.
12
+ * @param {string} arg
13
+ * @returns {{ isRemote: boolean, agentLabel?: string, path: string }}
14
+ */
15
+ function parseLocation(arg) {
16
+ // Match pattern: label:/path (label must be lowercase alphanumeric with hyphens, matching server validation)
17
+ const match = arg.match(/^([a-z0-9-]+):(.+)$/);
18
+ if (match) {
19
+ return { isRemote: true, agentLabel: match[1], path: match[2] };
20
+ }
21
+ return { isRemote: false, path: arg };
22
+ }
23
+
24
+ /**
25
+ * Run the file copy command.
26
+ * Supports download (remote → local) and upload (local → remote).
27
+ * @param {string[]} args
28
+ */
29
+ export async function runCp(args) {
30
+ assertMacOS();
31
+ const config = await requireAgentConfig();
32
+
33
+ if (args.length < 2) {
34
+ printUsage();
35
+ process.exit(1);
36
+ }
37
+
38
+ const source = parseLocation(args[0]);
39
+ const dest = parseLocation(args[1]);
40
+
41
+ // Validate: exactly one side must be remote
42
+ if (source.isRemote && dest.isRemote) {
43
+ console.error(
44
+ chalk.red('\n Cannot copy between two remote agents. One side must be a local path.\n'),
45
+ );
46
+ process.exit(1);
47
+ }
48
+
49
+ if (!source.isRemote && !dest.isRemote) {
50
+ console.error(
51
+ chalk.red(
52
+ '\n Both paths are local. Use the system cp command for local copies.\n' +
53
+ ' For remote transfers, prefix with agent-label: (e.g. myagent:/path/to/file)\n',
54
+ ),
55
+ );
56
+ process.exit(1);
57
+ }
58
+
59
+ if (source.isRemote) {
60
+ await runDownload(config, source, dest);
61
+ } else {
62
+ await runUpload(config, source, dest);
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Download a file from a remote agent.
68
+ * @param {object} config
69
+ * @param {{ agentLabel: string, path: string }} source
70
+ * @param {{ path: string }} dest
71
+ */
72
+ async function runDownload(config, source, dest) {
73
+ const agentLabel = source.agentLabel;
74
+ const remotePath = source.path;
75
+ let localPath = path.resolve(dest.path);
76
+
77
+ // If dest is a directory, use the remote filename
78
+ try {
79
+ const destStat = await stat(localPath);
80
+ if (destStat.isDirectory()) {
81
+ const remoteBasename = path.basename(remotePath);
82
+ localPath = path.join(localPath, remoteBasename);
83
+ }
84
+ } catch {
85
+ // Path doesn't exist yet — that's fine, we'll create the file
86
+ // Ensure the parent directory exists
87
+ const parentDir = path.dirname(localPath);
88
+ if (!existsSync(parentDir)) {
89
+ console.error(chalk.red(`\n Parent directory does not exist: ${parentDir}\n`));
90
+ process.exit(1);
91
+ }
92
+ }
93
+
94
+ console.log('');
95
+ console.log(chalk.dim(` Downloading ${chalk.bold(agentLabel)}:${remotePath} → ${localPath}`));
96
+
97
+ try {
98
+ await downloadRemoteFile(
99
+ config.panelUrl,
100
+ config.p12Path,
101
+ config.p12Password,
102
+ agentLabel,
103
+ remotePath,
104
+ localPath,
105
+ );
106
+ } catch (err) {
107
+ console.error(chalk.red(`\n Download failed: ${err.message}\n`));
108
+ process.exit(1);
109
+ }
110
+
111
+ console.log(` ${chalk.green('✓')} Downloaded to ${chalk.cyan(localPath)}`);
112
+ console.log('');
113
+ }
114
+
115
+ /**
116
+ * Upload a local file to a remote agent.
117
+ * @param {object} config
118
+ * @param {{ path: string }} source
119
+ * @param {{ agentLabel: string, path: string }} dest
120
+ */
121
+ async function runUpload(config, source, dest) {
122
+ const localPath = path.resolve(source.path);
123
+ const agentLabel = dest.agentLabel;
124
+ const remotePath = dest.path;
125
+
126
+ // Validate local file exists
127
+ if (!existsSync(localPath)) {
128
+ console.error(chalk.red(`\n Local file not found: ${localPath}\n`));
129
+ process.exit(1);
130
+ }
131
+
132
+ let localStat;
133
+ try {
134
+ localStat = await stat(localPath);
135
+ } catch (err) {
136
+ console.error(chalk.red(`\n Cannot read file: ${err.message}\n`));
137
+ process.exit(1);
138
+ }
139
+
140
+ if (!localStat.isFile()) {
141
+ console.error(
142
+ chalk.red('\n Only single file transfers are currently supported.\n') +
143
+ chalk.dim(
144
+ ' To transfer a directory, archive it first (e.g. tar -czf archive.tar.gz dir/).\n',
145
+ ),
146
+ );
147
+ process.exit(1);
148
+ }
149
+
150
+ console.log('');
151
+ console.log(chalk.dim(` Uploading ${localPath} → ${chalk.bold(agentLabel)}:${remotePath}`));
152
+
153
+ try {
154
+ await uploadRemoteFile(
155
+ config.panelUrl,
156
+ config.p12Path,
157
+ config.p12Password,
158
+ agentLabel,
159
+ remotePath,
160
+ localPath,
161
+ );
162
+ } catch (err) {
163
+ console.error(chalk.red(`\n Upload failed: ${err.message}\n`));
164
+ process.exit(1);
165
+ }
166
+
167
+ console.log(` ${chalk.green('✓')} Uploaded to ${chalk.cyan(`${agentLabel}:${remotePath}`)}`);
168
+ console.log('');
169
+ }
170
+
171
+ /**
172
+ * Print usage information.
173
+ */
174
+ function printUsage() {
175
+ const c = chalk.cyan;
176
+ const d = chalk.dim;
177
+
178
+ console.error(`
179
+ ${chalk.bold('Usage:')}
180
+
181
+ ${c('portlama-agent cp')} ${d('<source> <destination>')}
182
+
183
+ ${chalk.bold('Download from agent:')}
184
+
185
+ ${c('portlama-agent cp myagent:/var/log/app.log ./app.log')}
186
+
187
+ ${chalk.bold('Upload to agent:')}
188
+
189
+ ${c('portlama-agent cp ./config.json myagent:/etc/app/config.json')}
190
+
191
+ ${chalk.bold('Notes:')}
192
+
193
+ Remote paths use the format: ${c('agent-label:/absolute/path')}
194
+ Only single file transfers are supported.
195
+ `);
196
+ }
@@ -26,7 +26,7 @@ async function scanDirectory(dir, base = '') {
26
26
  const fullPath = path.join(dir, entry.name);
27
27
  const relPath = base ? path.join(base, entry.name) : entry.name;
28
28
  if (entry.isDirectory()) {
29
- results.push(...await scanDirectory(fullPath, relPath));
29
+ results.push(...(await scanDirectory(fullPath, relPath)));
30
30
  } else if (entry.isFile()) {
31
31
  const s = await stat(fullPath);
32
32
  results.push({ relativePath: relPath, absolutePath: fullPath, size: s.size });
@@ -112,7 +112,9 @@ export async function runDeploy(args) {
112
112
  const ext = path.extname(f.relativePath) || '(no extension)';
113
113
  console.error(` ${chalk.yellow(ext)} ${f.relativePath}`);
114
114
  }
115
- console.error(`\n ${chalk.dim('Only static web assets are allowed. Remove these files and retry.')}\n`);
115
+ console.error(
116
+ `\n ${chalk.dim('Only static web assets are allowed. Remove these files and retry.')}\n`,
117
+ );
116
118
  process.exit(1);
117
119
  }
118
120
 
@@ -160,12 +162,20 @@ export async function runDeploy(args) {
160
162
  title: 'Clearing remote files',
161
163
  task: async (_ctx, task) => {
162
164
  const { files: remoteFiles } = await fetchSiteFiles(
163
- config.panelUrl, config.p12Path, config.p12Password, siteId, '.',
165
+ config.panelUrl,
166
+ config.p12Path,
167
+ config.p12Password,
168
+ siteId,
169
+ '.',
164
170
  );
165
171
  for (const f of remoteFiles) {
166
172
  task.output = `Removing ${f.name}`;
167
173
  await deleteSiteFile(
168
- config.panelUrl, config.p12Path, config.p12Password, siteId, f.name,
174
+ config.panelUrl,
175
+ config.p12Path,
176
+ config.p12Password,
177
+ siteId,
178
+ f.name,
169
179
  );
170
180
  }
171
181
  },
@@ -190,8 +200,11 @@ export async function runDeploy(args) {
190
200
  const batch = groupFiles.slice(i, i + 10);
191
201
  const uploadDir = dir === '.' ? '.' : dir;
192
202
  await uploadSiteFiles(
193
- config.panelUrl, config.p12Path, config.p12Password,
194
- siteId, uploadDir,
203
+ config.panelUrl,
204
+ config.p12Path,
205
+ config.p12Password,
206
+ siteId,
207
+ uploadDir,
195
208
  batch.map((f) => f.absolutePath),
196
209
  );
197
210
  uploaded += batch.length;
@@ -208,7 +221,11 @@ export async function runDeploy(args) {
208
221
  let remoteCount = 0;
209
222
  const countRemote = async (dirPath) => {
210
223
  const { files: entries } = await fetchSiteFiles(
211
- config.panelUrl, config.p12Path, config.p12Password, siteId, dirPath,
224
+ config.panelUrl,
225
+ config.p12Path,
226
+ config.p12Password,
227
+ siteId,
228
+ dirPath,
212
229
  );
213
230
  for (const entry of entries) {
214
231
  if (entry.type === 'directory') {
@@ -223,7 +240,9 @@ export async function runDeploy(args) {
223
240
  const localCount = files.length;
224
241
  if (remoteCount !== localCount) {
225
242
  task.output = `Warning: remote has ${remoteCount} files but ${localCount} were uploaded`;
226
- throw new Error(`Verification failed: expected ${localCount} files on remote but found ${remoteCount}`);
243
+ throw new Error(
244
+ `Verification failed: expected ${localCount} files on remote but found ${remoteCount}`,
245
+ );
227
246
  }
228
247
  task.output = `${remoteCount} files verified`;
229
248
  },
@@ -246,6 +265,8 @@ export async function runDeploy(args) {
246
265
  // Print summary
247
266
  const totalSize = files.reduce((sum, f) => sum + f.size, 0);
248
267
  console.log('');
249
- console.log(` ${chalk.green('✓')} Deployed ${chalk.bold(String(files.length))} files (${formatBytes(totalSize)}) to ${chalk.cyan(`https://${siteFqdn}/`)}`);
268
+ console.log(
269
+ ` ${chalk.green('✓')} Deployed ${chalk.bold(String(files.length))} files (${formatBytes(totalSize)}) to ${chalk.cyan(`https://${siteFqdn}/`)}`,
270
+ );
250
271
  console.log('');
251
272
  }
@@ -7,6 +7,7 @@ import chalk from 'chalk';
7
7
  import { assertMacOS, CHISEL_BIN_DIR, LOGS_DIR } from '../lib/platform.js';
8
8
  import { loadAgentConfig, saveAgentConfig } from '../lib/config.js';
9
9
  import { fetchHealth, fetchPlist, fetchTunnels } from '../lib/panel-api.js';
10
+ import { extractPemFromP12, cleanupPemFiles } from '../lib/ws-helpers.js';
10
11
  import { installChisel } from '../lib/chisel.js';
11
12
  import { rewritePlist, writePlistFile } from '../lib/plist.js';
12
13
  import { isAgentLoaded, unloadAgent, loadAgent, getAgentPid } from '../lib/launchctl.js';
@@ -58,10 +59,7 @@ export async function runSetup() {
58
59
  console.log(chalk.dim(' Panel → Certificates → Agent Certificates → Generate'));
59
60
  console.log('');
60
61
 
61
- const panelUrl = await prompt(
62
- 'Panel URL (e.g. https://1.2.3.4:9292)',
63
- existingConfig?.panelUrl,
64
- );
62
+ const panelUrl = await prompt('Panel URL (e.g. https://1.2.3.4:9292)', existingConfig?.panelUrl);
65
63
  if (!panelUrl) {
66
64
  throw new Error('Panel URL is required.');
67
65
  }
@@ -98,19 +96,33 @@ export async function runSetup() {
98
96
  const tasks = new Listr(
99
97
  [
100
98
  {
101
- title: 'Verifying panel connectivity',
99
+ title: 'Creating directories',
100
+ task: async () => {
101
+ await mkdir(CHISEL_BIN_DIR, { recursive: true });
102
+ await mkdir(LOGS_DIR, { recursive: true });
103
+ },
104
+ },
105
+ {
106
+ title: 'Extracting certificates from P12',
102
107
  task: async (_ctx, task) => {
103
- const health = await fetchHealth(ctx.panelUrl, ctx.p12Path, ctx.p12Password);
104
- task.output = `Panel is reachable (status: ${health.status || 'ok'})`;
108
+ const pem = await extractPemFromP12(ctx.p12Path, ctx.p12Password);
109
+ if (pem.caPath) {
110
+ task.output = `mTLS CA certificate saved to ${pem.caPath}`;
111
+ } else {
112
+ task.output = 'No CA certificate found in P12';
113
+ }
114
+ // Clean up temporary PEM cert/key files — they are only needed transiently
115
+ await cleanupPemFiles(pem);
105
116
  },
106
117
  rendererOptions: { persistentOutput: true },
107
118
  },
108
119
  {
109
- title: 'Creating directories',
110
- task: async () => {
111
- await mkdir(CHISEL_BIN_DIR, { recursive: true });
112
- await mkdir(LOGS_DIR, { recursive: true });
120
+ title: 'Verifying panel connectivity',
121
+ task: async (_ctx, task) => {
122
+ const health = await fetchHealth(ctx.panelUrl, ctx.p12Path, ctx.p12Password);
123
+ task.output = `Panel is reachable (status: ${health.status || 'ok'})`;
113
124
  },
125
+ rendererOptions: { persistentOutput: true },
114
126
  },
115
127
  {
116
128
  title: 'Installing Chisel',
@@ -179,9 +191,7 @@ export async function runSetup() {
179
191
  if (loaded) {
180
192
  task.output = 'Agent loaded (process starting...)';
181
193
  } else {
182
- throw new Error(
183
- 'Agent failed to load. Check logs with: portlama-agent logs',
184
- );
194
+ throw new Error('Agent failed to load. Check logs with: portlama-agent logs');
185
195
  }
186
196
  }
187
197
  },
@@ -230,30 +240,66 @@ function printSetupSummary(ctx) {
230
240
 
231
241
  console.log('');
232
242
  console.log(c(' ╔══════════════════════════════════════════════════════════╗'));
233
- console.log(c(' ║') + ` ${g.bold('Portlama Agent installed successfully!')}` + ' '.repeat(17) + c('║'));
243
+ console.log(
244
+ c(' ║') + ` ${g.bold('Portlama Agent installed successfully!')}` + ' '.repeat(17) + c('║'),
245
+ );
234
246
  console.log(c(' ╠══════════════════════════════════════════════════════════╣'));
235
247
 
236
248
  if (ctx.domain) {
237
- console.log(c(' ║') + ` ${b('Domain:')} ${c(ctx.domain)}` + ' '.repeat(Math.max(0, 46 - ctx.domain.length)) + c('║'));
249
+ console.log(
250
+ c(' ║') +
251
+ ` ${b('Domain:')} ${c(ctx.domain)}` +
252
+ ' '.repeat(Math.max(0, 46 - ctx.domain.length)) +
253
+ c('║'),
254
+ );
238
255
  }
239
256
 
240
- console.log(c(' ║') + ` ${b('Chisel:')} ${ctx.chiselVersion}` + ' '.repeat(Math.max(0, 46 - (ctx.chiselVersion || '').length)) + c('║'));
241
- console.log(c(' ║') + ` ${b('Tunnels:')} ${ctx.tunnels.length} configured` + ' '.repeat(33) + c('║'));
257
+ console.log(
258
+ c(' ║') +
259
+ ` ${b('Chisel:')} ${ctx.chiselVersion}` +
260
+ ' '.repeat(Math.max(0, 46 - (ctx.chiselVersion || '').length)) +
261
+ c('║'),
262
+ );
263
+ console.log(
264
+ c(' ║') + ` ${b('Tunnels:')} ${ctx.tunnels.length} configured` + ' '.repeat(33) + c('║'),
265
+ );
242
266
  console.log(c(' ║') + ' '.repeat(58) + c('║'));
243
267
 
244
268
  if (ctx.tunnels.length > 0) {
245
269
  for (const t of ctx.tunnels) {
246
270
  const line = `${t.subdomain} → localhost:${t.port}`;
247
- console.log(c(' ║') + ` ${d('•')} ${line}` + ' '.repeat(Math.max(0, 54 - line.length)) + c('║'));
271
+ console.log(
272
+ c(' ║') + ` ${d('•')} ${line}` + ' '.repeat(Math.max(0, 54 - line.length)) + c('║'),
273
+ );
248
274
  }
249
275
  console.log(c(' ║') + ' '.repeat(58) + c('║'));
250
276
  }
251
277
 
252
278
  console.log(c(' ║') + ` ${b('Commands:')}` + ' '.repeat(47) + c('║'));
253
- console.log(c(' ║') + ` ${d('portlama-agent status')} ${d('— check agent health')}` + ' '.repeat(11) + c('║'));
254
- console.log(c(' ║') + ` ${d('portlama-agent logs')} ${d('— stream chisel logs')}` + ' '.repeat(11) + c('║'));
255
- console.log(c(' ║') + ` ${d('portlama-agent update')} ${d('— refresh tunnel config')}` + ' '.repeat(8) + c('║'));
256
- console.log(c(' ║') + ` ${d('portlama-agent uninstall')} ${d('— remove everything')}` + ' '.repeat(12) + c('║'));
279
+ console.log(
280
+ c(' ║') +
281
+ ` ${d('portlama-agent status')} ${d('— check agent health')}` +
282
+ ' '.repeat(11) +
283
+ c('║'),
284
+ );
285
+ console.log(
286
+ c(' ║') +
287
+ ` ${d('portlama-agent logs')} ${d('— stream chisel logs')}` +
288
+ ' '.repeat(11) +
289
+ c('║'),
290
+ );
291
+ console.log(
292
+ c(' ║') +
293
+ ` ${d('portlama-agent update')} ${d('— refresh tunnel config')}` +
294
+ ' '.repeat(8) +
295
+ c('║'),
296
+ );
297
+ console.log(
298
+ c(' ║') +
299
+ ` ${d('portlama-agent uninstall')} ${d('— remove everything')}` +
300
+ ' '.repeat(12) +
301
+ c('║'),
302
+ );
257
303
  console.log(c(' ║') + ' '.repeat(58) + c('║'));
258
304
  console.log(c(' ╚══════════════════════════════════════════════════════════╝'));
259
305
  console.log('');