@lamalibre/portlama-agent 1.0.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.
package/LICENSE.md ADDED
@@ -0,0 +1,100 @@
1
+ # License
2
+
3
+ Copyright (c) 2026 Code Lama Software
4
+
5
+ ## Noncommercial Use
6
+
7
+ This software is licensed under the [Polyform Noncommercial License 1.0.0](https://polyformproject.org/licenses/noncommercial/1.0.0/).
8
+
9
+ You may use, copy, modify, and distribute this software for any **noncommercial** purpose. This includes personal projects, academic research, education, and evaluation.
10
+
11
+ ## Commercial Use
12
+
13
+ Commercial use of this software requires a commercial license, available by contacting licence@codelama.com.tr.
14
+
15
+ Commercial use includes, but is not limited to:
16
+ - Using Portlama in a business to serve data to clients or partners
17
+ - Deploying Portlama as part of a revenue-generating service or product
18
+ - Using Portlama internally at a for-profit organization
19
+
20
+ Sponsorship tiers and terms are listed on our GitHub Sponsors page. Once you become an active sponsor at a qualifying tier, you are granted a commercial use license for the duration of your sponsorship.
21
+
22
+ ## Questions
23
+
24
+ If you are unsure whether your use qualifies as noncommercial, please open a [GitHub Discussion](https://github.com/lamalibre/portlama/discussions) or contact us directly.
25
+
26
+ ---
27
+
28
+ ## Polyform Noncommercial License 1.0.0
29
+
30
+ <https://polyformproject.org/licenses/noncommercial/1.0.0/>
31
+
32
+ ### Acceptance
33
+
34
+ In order to get any license under these terms, you must agree to them as both strict obligations and conditions to all your licenses.
35
+
36
+ ### Copyright License
37
+
38
+ The licensor grants you a copyright license for the software to do everything you might do with the software that would otherwise infringe the licensor's copyright in it for any permitted purpose. However, you may only distribute the software according to [Distribution License](#distribution-license) and make changes or new works based on the software according to [Changes and New Works License](#changes-and-new-works-license).
39
+
40
+ ### Distribution License
41
+
42
+ The licensor grants you an additional copyright license to distribute copies of the software. Your license to distribute covers distributing the software with changes and new works permitted by [Changes and New Works License](#changes-and-new-works-license).
43
+
44
+ ### Notices
45
+
46
+ You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these terms or the URL for them above, as well as copies of any plain-text lines beginning with `Required Notice:` that the licensor provided with the software. For example:
47
+
48
+ > Required Notice: Copyright (c) 2026 Code Lama Software (https://github.com/lamalibre/portlama)
49
+
50
+ ### Changes and New Works License
51
+
52
+ The licensor grants you an additional copyright license to make changes and new works based on the software for any permitted purpose.
53
+
54
+ ### Patent License
55
+
56
+ The licensor grants you a patent license for the software that covers patent claims the licensor can license, or becomes able to license, that you would infringe by using the software.
57
+
58
+ ### Noncommercial Purposes
59
+
60
+ Any noncommercial purpose is a permitted purpose.
61
+
62
+ ### Personal Uses
63
+
64
+ Personal use for research, experiment, and testing for the benefit of public knowledge, personal study, private entertainment, hobby projects, amateur pursuits, or religious observance, without any anticipated commercial application, is use for a permitted purpose.
65
+
66
+ ### Noncommercial Organizations
67
+
68
+ Use by any charitable organization, educational institution, public research organization, public safety or health organization, environmental protection organization, or government institution is use for a permitted purpose regardless of the source of funding or obligations resulting from the funding.
69
+
70
+ ### Fair Use
71
+
72
+ You may have "fair use" rights for the software under the law. These terms do not limit them.
73
+
74
+ ### No Other Rights
75
+
76
+ These terms do not allow you to sublicense or transfer any of your licenses to anyone else, or prevent the licensor from granting licenses to anyone else. These terms do not imply any other licenses.
77
+
78
+ ### Patent Defense
79
+
80
+ If you make any written claim that the software infringes or contributes to infringement of any patent, your patent license for the software granted under these terms ends immediately. If your company makes such a claim, your patent license ends immediately for work on behalf of your company.
81
+
82
+ ### Violations
83
+
84
+ The first time you are notified in writing that you have violated any of these terms, or any agreement made under them, you have 32 calendar days to come into compliance. If you come into compliance within that time, your licenses under these terms will not be permanently revoked.
85
+
86
+ ### No Liability
87
+
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
+
90
+ ### Definitions
91
+
92
+ The **licensor** is the individual or entity offering these terms, and the **software** is the software the licensor makes available under these terms.
93
+
94
+ **You** refers to the individual or entity agreeing to these terms.
95
+
96
+ **Your company** is any legal entity, sole proprietorship, or other kind of organization that you work for, plus all organizations that have control over, are under the control of, or are under common control with that organization. **Control** means ownership of substantially all the assets of an entity, or the power to direct its management and policies by vote, contract, or otherwise. Control can be direct or indirect.
97
+
98
+ **Your licenses** are all the licenses granted to you for the software under these terms.
99
+
100
+ **Use** means anything you do with the software requiring one of your licenses.
package/README.md ADDED
@@ -0,0 +1,51 @@
1
+ # @lamalibre/portlama-agent
2
+
3
+ Mac tunnel agent for Portlama — installs a Chisel tunnel client and manages it
4
+ as a macOS launchd agent.
5
+
6
+ ## Installation
7
+
8
+ ```bash
9
+ npx @lamalibre/portlama-agent setup
10
+ ```
11
+
12
+ The setup command downloads the Chisel binary, configures the tunnel connection,
13
+ installs a launchd plist, and starts the agent. The panel provides the
14
+ connection details and an agent-scoped mTLS certificate.
15
+
16
+ ## Commands
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
+
26
+ ## Requirements
27
+
28
+ | Requirement | Details |
29
+ | ----------- | --------------- |
30
+ | OS | macOS |
31
+ | Node.js | >= 20.0.0 |
32
+ | Access | User account (no root required) |
33
+
34
+ ## How It Works
35
+
36
+ The agent registers with the Portlama panel using an agent-scoped mTLS
37
+ certificate (not the admin certificate). It connects to the server's Chisel
38
+ endpoint over WebSocket-over-HTTPS and exposes local ports as configured
39
+ in the panel's tunnel settings.
40
+
41
+ The launchd plist ensures the tunnel reconnects automatically after reboot
42
+ or network changes.
43
+
44
+ ## Further Reading
45
+
46
+ See the main repository for architecture, tunnel configuration, and the full
47
+ development plan: <https://github.com/lamalibre/portlama>
48
+
49
+ ## License
50
+
51
+ [Polyform Noncommercial 1.0.0](./LICENSE.md) — see [LICENSE.md](./LICENSE.md) for details.
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { main } from '../src/index.js';
4
+
5
+ try {
6
+ await main();
7
+ } catch (error) {
8
+ console.error('\n');
9
+ console.error(' Portlama Agent failed.');
10
+ console.error(` Error: ${error.message}`);
11
+ console.error('\n');
12
+ process.exit(1);
13
+ }
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@lamalibre/portlama-agent",
3
+ "version": "1.0.0",
4
+ "description": "Mac agent for Portlama — installs Chisel tunnel client and manages launchd agent",
5
+ "type": "module",
6
+ "license": "SEE LICENSE IN LICENSE.md",
7
+ "author": "Code Lama Software",
8
+ "bin": {
9
+ "portlama-agent": "bin/portlama-agent.js"
10
+ },
11
+ "files": [
12
+ "bin",
13
+ "src"
14
+ ],
15
+ "scripts": {
16
+ "lint": "eslint ."
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "https://github.com/lamalibre/portlama.git",
21
+ "directory": "packages/portlama-agent"
22
+ },
23
+ "homepage": "https://github.com/lamalibre/portlama#readme",
24
+ "bugs": {
25
+ "url": "https://github.com/lamalibre/portlama/issues"
26
+ },
27
+ "keywords": [
28
+ "portlama",
29
+ "tunnel",
30
+ "chisel",
31
+ "macos",
32
+ "launchd",
33
+ "agent"
34
+ ],
35
+ "dependencies": {
36
+ "chalk": "^5.3.0",
37
+ "execa": "^9.0.0",
38
+ "listr2": "^8.0.0"
39
+ },
40
+ "engines": {
41
+ "node": ">=20.0.0"
42
+ }
43
+ }
@@ -0,0 +1,33 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { execa } from 'execa';
3
+ import chalk from 'chalk';
4
+ import { assertMacOS, LOG_FILE, ERROR_LOG_FILE } from '../lib/platform.js';
5
+
6
+ /**
7
+ * Stream chisel logs to the terminal.
8
+ * Tails both stdout and stderr log files.
9
+ */
10
+ export async function runLogs() {
11
+ assertMacOS();
12
+
13
+ const files = [];
14
+ if (existsSync(LOG_FILE)) files.push(LOG_FILE);
15
+ if (existsSync(ERROR_LOG_FILE)) files.push(ERROR_LOG_FILE);
16
+
17
+ if (files.length === 0) {
18
+ console.log('');
19
+ console.log(chalk.yellow(' No log files found.'));
20
+ console.log(chalk.dim(` Expected: ${LOG_FILE}`));
21
+ console.log(chalk.dim(` Expected: ${ERROR_LOG_FILE}`));
22
+ console.log(chalk.dim(' Has the agent been started? Run "portlama-agent setup" first.'));
23
+ console.log('');
24
+ process.exit(1);
25
+ }
26
+
27
+ console.log(chalk.dim(` Streaming logs from: ${files.join(', ')}`));
28
+ console.log(chalk.dim(' Press Ctrl+C to stop.'));
29
+ console.log('');
30
+
31
+ // tail -f streams indefinitely — use stdio: 'inherit' to forward to terminal
32
+ await execa('tail', ['-f', ...files], { stdio: 'inherit' });
33
+ }
@@ -0,0 +1,260 @@
1
+ import { createInterface } from 'node:readline';
2
+ import { existsSync } from 'node:fs';
3
+ import { mkdir } from 'node:fs/promises';
4
+ import { resolve } from 'node:path';
5
+ import { Listr } from 'listr2';
6
+ import chalk from 'chalk';
7
+ import { assertMacOS, CHISEL_BIN_DIR, LOGS_DIR } from '../lib/platform.js';
8
+ import { loadAgentConfig, saveAgentConfig } from '../lib/config.js';
9
+ import { fetchHealth, fetchPlist, fetchTunnels } from '../lib/panel-api.js';
10
+ import { installChisel } from '../lib/chisel.js';
11
+ import { rewritePlist, writePlistFile } from '../lib/plist.js';
12
+ import { isAgentLoaded, unloadAgent, loadAgent, getAgentPid } from '../lib/launchctl.js';
13
+
14
+ /**
15
+ * Prompt for user input via readline.
16
+ * @param {string} question
17
+ * @param {string} [defaultValue]
18
+ * @returns {Promise<string>}
19
+ */
20
+ function prompt(question, defaultValue) {
21
+ const rl = createInterface({
22
+ input: process.stdin,
23
+ output: process.stdout,
24
+ });
25
+
26
+ const suffix = defaultValue ? ` ${chalk.dim(`[${defaultValue}]`)}` : '';
27
+
28
+ return new Promise((resolvePromise) => {
29
+ rl.question(` ${question}${suffix}: `, (answer) => {
30
+ rl.close();
31
+ resolvePromise(answer.trim() || defaultValue || '');
32
+ });
33
+ });
34
+ }
35
+
36
+ /**
37
+ * Run the interactive setup flow.
38
+ */
39
+ export async function runSetup() {
40
+ // Step 1: Verify macOS
41
+ assertMacOS();
42
+
43
+ // Check for existing config
44
+ const existingConfig = await loadAgentConfig();
45
+ if (existingConfig) {
46
+ console.log('');
47
+ console.log(chalk.yellow(' An existing agent configuration was found.'));
48
+ console.log(chalk.yellow(' Running setup again will overwrite it.'));
49
+ console.log('');
50
+ }
51
+
52
+ // Step 2: Prompt credentials
53
+ console.log('');
54
+ console.log(chalk.bold(' Portlama Agent Setup'));
55
+ console.log(chalk.dim(' Connect this Mac to your Portlama server.'));
56
+ console.log('');
57
+ console.log(chalk.dim(' The admin must generate an agent certificate from the panel first:'));
58
+ console.log(chalk.dim(' Panel → Certificates → Agent Certificates → Generate'));
59
+ console.log('');
60
+
61
+ const panelUrl = await prompt(
62
+ 'Panel URL (e.g. https://1.2.3.4:9292)',
63
+ existingConfig?.panelUrl,
64
+ );
65
+ if (!panelUrl) {
66
+ throw new Error('Panel URL is required.');
67
+ }
68
+
69
+ // Normalize URL: strip trailing slash
70
+ const normalizedUrl = panelUrl.replace(/\/+$/, '');
71
+
72
+ const defaultP12 = existingConfig?.p12Path || './agent.p12';
73
+ const p12Input = await prompt('Path to agent certificate (.p12)', defaultP12);
74
+ const p12Path = resolve(p12Input);
75
+
76
+ if (!existsSync(p12Path)) {
77
+ throw new Error(`client.p12 not found at: ${p12Path}`);
78
+ }
79
+
80
+ const p12Password = await prompt('P12 password');
81
+ if (!p12Password) {
82
+ throw new Error('P12 password is required.');
83
+ }
84
+
85
+ console.log('');
86
+
87
+ // Context shared across tasks
88
+ const ctx = {
89
+ panelUrl: normalizedUrl,
90
+ p12Path,
91
+ p12Password,
92
+ chiselVersion: null,
93
+ plistXml: null,
94
+ domain: null,
95
+ tunnels: [],
96
+ };
97
+
98
+ const tasks = new Listr(
99
+ [
100
+ {
101
+ title: 'Verifying panel connectivity',
102
+ 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'})`;
105
+ },
106
+ rendererOptions: { persistentOutput: true },
107
+ },
108
+ {
109
+ title: 'Creating directories',
110
+ task: async () => {
111
+ await mkdir(CHISEL_BIN_DIR, { recursive: true });
112
+ await mkdir(LOGS_DIR, { recursive: true });
113
+ },
114
+ },
115
+ {
116
+ title: 'Installing Chisel',
117
+ task: async (_ctx, task) => {
118
+ const result = await installChisel();
119
+ ctx.chiselVersion = result.version;
120
+ if (result.skipped) {
121
+ task.output = `Already installed (${result.version})`;
122
+ } else {
123
+ task.output = `Installed ${result.version}`;
124
+ }
125
+ },
126
+ rendererOptions: { persistentOutput: true },
127
+ },
128
+ {
129
+ title: 'Fetching tunnel configuration',
130
+ task: async (_ctx, task) => {
131
+ const data = await fetchPlist(ctx.panelUrl, ctx.p12Path, ctx.p12Password);
132
+ ctx.plistXml = data.plist;
133
+
134
+ // Also fetch tunnel list for the summary
135
+ const tunnelData = await fetchTunnels(ctx.panelUrl, ctx.p12Path, ctx.p12Password);
136
+ ctx.tunnels = tunnelData.tunnels || [];
137
+ task.output = `${ctx.tunnels.length} tunnel(s) configured`;
138
+ },
139
+ rendererOptions: { persistentOutput: true },
140
+ },
141
+ {
142
+ title: 'Rewriting plist paths',
143
+ task: async () => {
144
+ ctx.plistXml = rewritePlist(ctx.plistXml);
145
+ },
146
+ },
147
+ {
148
+ title: 'Writing plist file',
149
+ task: async () => {
150
+ await writePlistFile(ctx.plistXml);
151
+ },
152
+ },
153
+ {
154
+ title: 'Unloading previous agent',
155
+ skip: async () => {
156
+ const loaded = await isAgentLoaded();
157
+ return !loaded && 'No previous agent loaded';
158
+ },
159
+ task: async () => {
160
+ await unloadAgent();
161
+ },
162
+ },
163
+ {
164
+ title: 'Loading agent',
165
+ task: async () => {
166
+ await loadAgent();
167
+ },
168
+ },
169
+ {
170
+ title: 'Verifying agent is running',
171
+ task: async (_ctx, task) => {
172
+ // Give launchd a moment to start the process
173
+ await new Promise((r) => setTimeout(r, 2000));
174
+ const pid = await getAgentPid();
175
+ if (pid) {
176
+ task.output = `Agent running (PID ${pid})`;
177
+ } else {
178
+ const loaded = await isAgentLoaded();
179
+ if (loaded) {
180
+ task.output = 'Agent loaded (process starting...)';
181
+ } else {
182
+ throw new Error(
183
+ 'Agent failed to load. Check logs with: portlama-agent logs',
184
+ );
185
+ }
186
+ }
187
+ },
188
+ rendererOptions: { persistentOutput: true },
189
+ },
190
+ {
191
+ title: 'Saving configuration',
192
+ task: async () => {
193
+ // Extract domain from the plist (look for wss://tunnel.<domain>)
194
+ const domainMatch = ctx.plistXml.match(/wss:\/\/tunnel\.([^:]+):/);
195
+ ctx.domain = domainMatch ? domainMatch[1] : null;
196
+
197
+ await saveAgentConfig({
198
+ panelUrl: ctx.panelUrl,
199
+ p12Path: ctx.p12Path,
200
+ p12Password: ctx.p12Password,
201
+ domain: ctx.domain,
202
+ chiselVersion: ctx.chiselVersion,
203
+ setupAt: new Date().toISOString(),
204
+ });
205
+ },
206
+ },
207
+ ],
208
+ {
209
+ renderer: 'default',
210
+ rendererOptions: { collapseSubtasks: false },
211
+ exitOnError: true,
212
+ },
213
+ );
214
+
215
+ await tasks.run();
216
+
217
+ // Print summary
218
+ printSetupSummary(ctx);
219
+ }
220
+
221
+ /**
222
+ * Print a formatted summary after successful setup.
223
+ * @param {object} ctx
224
+ */
225
+ function printSetupSummary(ctx) {
226
+ const b = chalk.bold;
227
+ const c = chalk.cyan;
228
+ const d = chalk.dim;
229
+ const g = chalk.green;
230
+
231
+ console.log('');
232
+ console.log(c(' ╔══════════════════════════════════════════════════════════╗'));
233
+ console.log(c(' ║') + ` ${g.bold('Portlama Agent installed successfully!')}` + ' '.repeat(17) + c('║'));
234
+ console.log(c(' ╠══════════════════════════════════════════════════════════╣'));
235
+
236
+ if (ctx.domain) {
237
+ console.log(c(' ║') + ` ${b('Domain:')} ${c(ctx.domain)}` + ' '.repeat(Math.max(0, 46 - ctx.domain.length)) + c('║'));
238
+ }
239
+
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('║'));
242
+ console.log(c(' ║') + ' '.repeat(58) + c('║'));
243
+
244
+ if (ctx.tunnels.length > 0) {
245
+ for (const t of ctx.tunnels) {
246
+ const line = `${t.subdomain} → localhost:${t.port}`;
247
+ console.log(c(' ║') + ` ${d('•')} ${line}` + ' '.repeat(Math.max(0, 54 - line.length)) + c('║'));
248
+ }
249
+ console.log(c(' ║') + ' '.repeat(58) + c('║'));
250
+ }
251
+
252
+ 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('║'));
257
+ console.log(c(' ║') + ' '.repeat(58) + c('║'));
258
+ console.log(c(' ╚══════════════════════════════════════════════════════════╝'));
259
+ console.log('');
260
+ }
@@ -0,0 +1,83 @@
1
+ import chalk from 'chalk';
2
+ import { assertMacOS, CHISEL_BIN_PATH, PLIST_PATH, LOG_FILE, AGENT_DIR } from '../lib/platform.js';
3
+ import { loadAgentConfig } from '../lib/config.js';
4
+ import { isAgentLoaded, getAgentPid } from '../lib/launchctl.js';
5
+ import { getInstalledVersion } from '../lib/chisel.js';
6
+ import { fetchTunnels } from '../lib/panel-api.js';
7
+ import { existsSync } from 'node:fs';
8
+
9
+ /**
10
+ * Print formatted status information about the agent.
11
+ */
12
+ export async function runStatus() {
13
+ assertMacOS();
14
+
15
+ const b = chalk.bold;
16
+ const c = chalk.cyan;
17
+ const g = chalk.green;
18
+ const r = chalk.red;
19
+ const d = chalk.dim;
20
+ const y = chalk.yellow;
21
+
22
+ console.log('');
23
+ console.log(b(' Portlama Agent Status'));
24
+ console.log(d(' ─'.repeat(28)));
25
+
26
+ // Config
27
+ const config = await loadAgentConfig();
28
+ if (!config) {
29
+ console.log(` ${r('Not configured.')} Run ${c('portlama-agent setup')} first.`);
30
+ console.log('');
31
+ return;
32
+ }
33
+
34
+ // Agent status
35
+ const loaded = await isAgentLoaded();
36
+ const pid = await getAgentPid();
37
+
38
+ console.log(` ${b('Agent:')} ${loaded ? g('loaded') : r('not loaded')}${pid ? ` (PID ${pid})` : ''}`);
39
+ console.log(` ${b('Panel:')} ${c(config.panelUrl)}`);
40
+
41
+ if (config.domain) {
42
+ console.log(` ${b('Domain:')} ${c(config.domain)}`);
43
+ }
44
+
45
+ // Chisel
46
+ const chiselVersion = await getInstalledVersion();
47
+ const chiselInstalled = existsSync(CHISEL_BIN_PATH);
48
+ console.log(` ${b('Chisel:')} ${chiselInstalled ? g(chiselVersion || 'installed') : r('not installed')}`);
49
+
50
+ // Files
51
+ console.log(` ${b('Plist:')} ${existsSync(PLIST_PATH) ? g('present') : y('missing')}`);
52
+ console.log(` ${b('Config:')} ${existsSync(AGENT_DIR) ? g('present') : y('missing')}`);
53
+ console.log(` ${b('Logs:')} ${d(LOG_FILE)}`);
54
+
55
+ if (config.setupAt) {
56
+ console.log(` ${b('Setup at:')} ${d(config.setupAt)}`);
57
+ }
58
+ if (config.updatedAt) {
59
+ console.log(` ${b('Updated:')} ${d(config.updatedAt)}`);
60
+ }
61
+
62
+ // Try to fetch tunnels from panel
63
+ console.log('');
64
+ console.log(b(' Tunnels'));
65
+ console.log(d(' ─'.repeat(28)));
66
+
67
+ try {
68
+ const data = await fetchTunnels(config.panelUrl, config.p12Path, config.p12Password);
69
+ const tunnels = data.tunnels || [];
70
+
71
+ if (tunnels.length === 0) {
72
+ console.log(` ${d('No tunnels configured.')}`);
73
+ } else {
74
+ for (const t of tunnels) {
75
+ console.log(` ${c('•')} ${b(t.subdomain)}.${config.domain || '?'} → localhost:${t.port}${t.description ? d(` (${t.description})`) : ''}`);
76
+ }
77
+ }
78
+ } catch {
79
+ console.log(` ${y('Could not reach panel to fetch tunnel list.')}`);
80
+ }
81
+
82
+ console.log('');
83
+ }
@@ -0,0 +1,54 @@
1
+ import { rm } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import { Listr } from 'listr2';
4
+ import chalk from 'chalk';
5
+ import { assertMacOS, AGENT_DIR, PLIST_PATH } from '../lib/platform.js';
6
+ import { isAgentLoaded, unloadAgent } from '../lib/launchctl.js';
7
+
8
+ /**
9
+ * Unload the agent, remove the plist, chisel binary, and config.
10
+ */
11
+ export async function runUninstall() {
12
+ assertMacOS();
13
+
14
+ const tasks = new Listr(
15
+ [
16
+ {
17
+ title: 'Unloading agent',
18
+ skip: async () => {
19
+ const loaded = await isAgentLoaded();
20
+ return !loaded && 'Agent not loaded';
21
+ },
22
+ task: async () => {
23
+ await unloadAgent();
24
+ },
25
+ },
26
+ {
27
+ title: 'Removing plist file',
28
+ skip: () => !existsSync(PLIST_PATH) && 'Plist not found',
29
+ task: async () => {
30
+ await rm(PLIST_PATH);
31
+ },
32
+ },
33
+ {
34
+ title: 'Removing ~/.portlama directory',
35
+ skip: () => !existsSync(AGENT_DIR) && 'Directory not found',
36
+ task: async () => {
37
+ await rm(AGENT_DIR, { recursive: true });
38
+ },
39
+ },
40
+ ],
41
+ {
42
+ renderer: 'default',
43
+ rendererOptions: { collapseSubtasks: false },
44
+ exitOnError: true,
45
+ },
46
+ );
47
+
48
+ await tasks.run();
49
+
50
+ console.log('');
51
+ console.log(chalk.green(' Portlama Agent uninstalled successfully.'));
52
+ console.log(chalk.dim(' All agent files have been removed.'));
53
+ console.log('');
54
+ }
@@ -0,0 +1,110 @@
1
+ import { Listr } from 'listr2';
2
+ import chalk from 'chalk';
3
+ import { assertMacOS } from '../lib/platform.js';
4
+ import { requireAgentConfig, saveAgentConfig } from '../lib/config.js';
5
+ import { fetchPlist, fetchTunnels } from '../lib/panel-api.js';
6
+ import { rewritePlist, writePlistFile } from '../lib/plist.js';
7
+ import { isAgentLoaded, unloadAgent, loadAgent, getAgentPid } from '../lib/launchctl.js';
8
+
9
+ /**
10
+ * Re-fetch the plist from the panel and restart the agent.
11
+ * Used after adding/removing tunnels on the panel.
12
+ */
13
+ export async function runUpdate() {
14
+ assertMacOS();
15
+
16
+ const config = await requireAgentConfig();
17
+
18
+ const ctx = {
19
+ plistXml: null,
20
+ tunnels: [],
21
+ };
22
+
23
+ const tasks = new Listr(
24
+ [
25
+ {
26
+ title: 'Fetching updated tunnel configuration',
27
+ task: async (_ctx, task) => {
28
+ const data = await fetchPlist(config.panelUrl, config.p12Path, config.p12Password);
29
+ ctx.plistXml = data.plist;
30
+
31
+ const tunnelData = await fetchTunnels(config.panelUrl, config.p12Path, config.p12Password);
32
+ ctx.tunnels = tunnelData.tunnels || [];
33
+ task.output = `${ctx.tunnels.length} tunnel(s) configured`;
34
+ },
35
+ rendererOptions: { persistentOutput: true },
36
+ },
37
+ {
38
+ title: 'Rewriting plist paths',
39
+ task: async () => {
40
+ ctx.plistXml = rewritePlist(ctx.plistXml);
41
+ },
42
+ },
43
+ {
44
+ title: 'Writing plist file',
45
+ task: async () => {
46
+ await writePlistFile(ctx.plistXml);
47
+ },
48
+ },
49
+ {
50
+ title: 'Unloading agent',
51
+ skip: async () => {
52
+ const loaded = await isAgentLoaded();
53
+ return !loaded && 'Agent not currently loaded';
54
+ },
55
+ task: async () => {
56
+ await unloadAgent();
57
+ },
58
+ },
59
+ {
60
+ title: 'Loading agent',
61
+ task: async () => {
62
+ await loadAgent();
63
+ },
64
+ },
65
+ {
66
+ title: 'Verifying agent is running',
67
+ task: async (_ctx, task) => {
68
+ await new Promise((r) => setTimeout(r, 2000));
69
+ const pid = await getAgentPid();
70
+ if (pid) {
71
+ task.output = `Agent running (PID ${pid})`;
72
+ } else {
73
+ const loaded = await isAgentLoaded();
74
+ if (loaded) {
75
+ task.output = 'Agent loaded (process starting...)';
76
+ } else {
77
+ throw new Error(
78
+ 'Agent failed to load. Check logs with: portlama-agent logs',
79
+ );
80
+ }
81
+ }
82
+ },
83
+ rendererOptions: { persistentOutput: true },
84
+ },
85
+ {
86
+ title: 'Saving configuration',
87
+ task: async () => {
88
+ await saveAgentConfig({
89
+ ...config,
90
+ updatedAt: new Date().toISOString(),
91
+ });
92
+ },
93
+ },
94
+ ],
95
+ {
96
+ renderer: 'default',
97
+ rendererOptions: { collapseSubtasks: false },
98
+ exitOnError: true,
99
+ },
100
+ );
101
+
102
+ await tasks.run();
103
+
104
+ console.log('');
105
+ console.log(chalk.green(' Agent updated successfully.'));
106
+ if (ctx.tunnels.length > 0) {
107
+ console.log(chalk.dim(` ${ctx.tunnels.length} tunnel(s) active.`));
108
+ }
109
+ console.log('');
110
+ }
package/src/index.js ADDED
@@ -0,0 +1,89 @@
1
+ import chalk from 'chalk';
2
+
3
+ /**
4
+ * Print help message and exit.
5
+ */
6
+ function printHelp() {
7
+ const b = chalk.bold;
8
+ const c = chalk.cyan;
9
+ const d = chalk.dim;
10
+
11
+ console.log(`
12
+ ${b('portlama-agent')} — Mac tunnel agent for Portlama
13
+
14
+ ${b('USAGE')}
15
+
16
+ ${c('portlama-agent')} ${d('<command>')}
17
+
18
+ ${b('COMMANDS')}
19
+
20
+ ${c('setup')} Interactive setup: install Chisel, fetch tunnel config, start agent
21
+ ${c('update')} Re-fetch plist from panel after tunnel changes
22
+ ${c('uninstall')} Stop agent and remove all files
23
+ ${c('status')} Show agent health, tunnel list, connection status
24
+ ${c('logs')} Stream Chisel log output (tail -f)
25
+
26
+ ${b('EXAMPLES')}
27
+
28
+ ${d('# First-time setup')}
29
+ ${c('npx @lamalibre/portlama-agent setup')}
30
+
31
+ ${d('# After adding a tunnel on the panel')}
32
+ ${c('portlama-agent update')}
33
+
34
+ ${d('# Check if the agent is running')}
35
+ ${c('portlama-agent status')}
36
+
37
+ ${b('PREREQUISITES')}
38
+
39
+ ${d('•')} macOS (arm64 or x64)
40
+ ${d('•')} Agent certificate (.p12) generated from your Portlama panel
41
+ (Panel → Certificates → Agent Certificates → Generate)
42
+ ${d('•')} Panel URL (e.g. https://1.2.3.4:9292)
43
+ `);
44
+ process.exit(0);
45
+ }
46
+
47
+ /**
48
+ * Parse command from argv and dispatch to the appropriate module.
49
+ */
50
+ export async function main() {
51
+ const args = process.argv.slice(2);
52
+ const command = args[0];
53
+
54
+ if (!command || command === '--help' || command === '-h') {
55
+ printHelp();
56
+ }
57
+
58
+ switch (command) {
59
+ case 'setup': {
60
+ const { runSetup } = await import('./commands/setup.js');
61
+ await runSetup();
62
+ break;
63
+ }
64
+ case 'update': {
65
+ const { runUpdate } = await import('./commands/update.js');
66
+ await runUpdate();
67
+ break;
68
+ }
69
+ case 'uninstall': {
70
+ const { runUninstall } = await import('./commands/uninstall.js');
71
+ await runUninstall();
72
+ break;
73
+ }
74
+ case 'status': {
75
+ const { runStatus } = await import('./commands/status.js');
76
+ await runStatus();
77
+ break;
78
+ }
79
+ case 'logs': {
80
+ const { runLogs } = await import('./commands/logs.js');
81
+ await runLogs();
82
+ break;
83
+ }
84
+ default:
85
+ console.error(`\n Unknown command: ${chalk.red(command)}`);
86
+ console.error(` Run ${chalk.cyan('portlama-agent --help')} for usage.\n`);
87
+ process.exit(1);
88
+ }
89
+ }
@@ -0,0 +1,112 @@
1
+ import { execa } from 'execa';
2
+ import { access, constants, mkdir } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import path from 'node:path';
5
+ import crypto from 'node:crypto';
6
+ import { CHISEL_BIN_PATH, CHISEL_BIN_DIR } from './platform.js';
7
+ import { detectArch } from './platform.js';
8
+
9
+ const GITHUB_API = 'https://api.github.com/repos/jpillora/chisel/releases/latest';
10
+
11
+ /**
12
+ * Check if a file exists at the given path.
13
+ */
14
+ async function fileExists(filePath) {
15
+ try {
16
+ await access(filePath, constants.F_OK);
17
+ return true;
18
+ } catch {
19
+ return false;
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Get the currently installed Chisel version, or null if not installed.
25
+ */
26
+ export async function getInstalledVersion() {
27
+ try {
28
+ const { stdout } = await execa(CHISEL_BIN_PATH, ['--version']);
29
+ return stdout.trim();
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Download and install the Chisel binary from GitHub releases.
37
+ * Installs to ~/.portlama/bin/chisel — no sudo needed.
38
+ * @returns {Promise<{ skipped?: boolean, installed?: boolean, version: string }>}
39
+ */
40
+ export async function installChisel() {
41
+ const exists = await fileExists(CHISEL_BIN_PATH);
42
+ if (exists) {
43
+ const version = await getInstalledVersion();
44
+ if (version) {
45
+ return { skipped: true, version };
46
+ }
47
+ }
48
+
49
+ // Fetch latest release info
50
+ let releaseInfo;
51
+ try {
52
+ const { stdout } = await execa('curl', [
53
+ '-s', '-L',
54
+ '-H', 'Accept: application/vnd.github+json',
55
+ GITHUB_API,
56
+ ]);
57
+ releaseInfo = JSON.parse(stdout);
58
+ } catch (err) {
59
+ throw new Error(
60
+ `Failed to fetch Chisel release info from GitHub: ${err.message}. Check internet connectivity.`,
61
+ );
62
+ }
63
+
64
+ if (releaseInfo.message && releaseInfo.message.includes('rate limit')) {
65
+ throw new Error(
66
+ 'GitHub API rate limit exceeded. Please try again later or set a GITHUB_TOKEN environment variable.',
67
+ );
68
+ }
69
+
70
+ // Find the correct asset for this platform
71
+ const archSuffix = detectArch();
72
+ const asset = releaseInfo.assets?.find(
73
+ (a) => a.name.includes(archSuffix) && a.name.endsWith('.gz'),
74
+ );
75
+
76
+ if (!asset) {
77
+ throw new Error(
78
+ `Could not find ${archSuffix} asset in the latest Chisel release. Available assets: ` +
79
+ (releaseInfo.assets?.map((a) => a.name).join(', ') || 'none'),
80
+ );
81
+ }
82
+
83
+ const downloadUrl = asset.browser_download_url;
84
+ const tmpGz = path.join(tmpdir(), `chisel-${crypto.randomBytes(4).toString('hex')}.gz`);
85
+ const tmpBin = tmpGz.replace('.gz', '');
86
+
87
+ try {
88
+ await execa('curl', ['-L', '-o', tmpGz, downloadUrl]);
89
+ } catch (err) {
90
+ throw new Error(
91
+ `Failed to download Chisel from ${downloadUrl}: ${err.stderr || err.message}`,
92
+ );
93
+ }
94
+
95
+ try {
96
+ await mkdir(CHISEL_BIN_DIR, { recursive: true });
97
+ await execa('gunzip', ['-f', tmpGz]);
98
+ await execa('mv', [tmpBin, CHISEL_BIN_PATH]);
99
+ await execa('chmod', ['+x', CHISEL_BIN_PATH]);
100
+ } catch (err) {
101
+ throw new Error(`Failed to install Chisel binary: ${err.stderr || err.message}`);
102
+ } finally {
103
+ await execa('rm', ['-f', tmpGz, tmpBin]).catch(() => {});
104
+ }
105
+
106
+ const version = await getInstalledVersion();
107
+ if (!version) {
108
+ throw new Error('Chisel was installed but version check failed. The binary may be corrupted.');
109
+ }
110
+
111
+ return { installed: true, version };
112
+ }
@@ -0,0 +1,42 @@
1
+ import { readFile, writeFile, rename, mkdir } from 'node:fs/promises';
2
+ import { CONFIG_PATH, AGENT_DIR } from './platform.js';
3
+
4
+ /**
5
+ * Load the agent config from ~/.portlama/agent.json.
6
+ * Returns null if the file does not exist.
7
+ * @returns {Promise<object | null>}
8
+ */
9
+ export async function loadAgentConfig() {
10
+ try {
11
+ const raw = await readFile(CONFIG_PATH, 'utf8');
12
+ return JSON.parse(raw);
13
+ } catch {
14
+ return null;
15
+ }
16
+ }
17
+
18
+ /**
19
+ * Save the agent config atomically (write tmp → rename).
20
+ * @param {object} config
21
+ */
22
+ export async function saveAgentConfig(config) {
23
+ await mkdir(AGENT_DIR, { recursive: true });
24
+ const tmp = CONFIG_PATH + '.tmp';
25
+ await writeFile(tmp, JSON.stringify(config, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
26
+ await rename(tmp, CONFIG_PATH);
27
+ }
28
+
29
+ /**
30
+ * Load agent config or throw if it doesn't exist.
31
+ * Used by commands that require prior setup.
32
+ * @returns {Promise<object>}
33
+ */
34
+ export async function requireAgentConfig() {
35
+ const config = await loadAgentConfig();
36
+ if (!config) {
37
+ throw new Error(
38
+ 'No agent configuration found. Run "portlama-agent setup" first.',
39
+ );
40
+ }
41
+ return config;
42
+ }
@@ -0,0 +1,57 @@
1
+ import { execa } from 'execa';
2
+ import { PLIST_PATH, PLIST_LABEL } from './platform.js';
3
+
4
+ /**
5
+ * Check if the launchd agent is currently loaded.
6
+ * @returns {Promise<boolean>}
7
+ */
8
+ export async function isAgentLoaded() {
9
+ try {
10
+ const { stdout } = await execa('launchctl', ['list']);
11
+ return stdout.includes(PLIST_LABEL);
12
+ } catch {
13
+ return false;
14
+ }
15
+ }
16
+
17
+ /**
18
+ * Get the PID of the running agent, or null if not running.
19
+ * @returns {Promise<number | null>}
20
+ */
21
+ export async function getAgentPid() {
22
+ try {
23
+ const { stdout } = await execa('launchctl', ['list']);
24
+ for (const line of stdout.split('\n')) {
25
+ if (line.includes(PLIST_LABEL)) {
26
+ const pid = line.split('\t')[0];
27
+ const parsed = parseInt(pid, 10);
28
+ return Number.isNaN(parsed) || parsed <= 0 ? null : parsed;
29
+ }
30
+ }
31
+ return null;
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Load the launchd agent.
39
+ */
40
+ export async function loadAgent() {
41
+ try {
42
+ await execa('launchctl', ['load', PLIST_PATH]);
43
+ } catch (err) {
44
+ throw new Error(`Failed to load agent: ${err.stderr || err.message}`);
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Unload the launchd agent. Silent if not loaded.
50
+ */
51
+ export async function unloadAgent() {
52
+ try {
53
+ await execa('launchctl', ['unload', PLIST_PATH]);
54
+ } catch {
55
+ // Agent may not be loaded — this is fine
56
+ }
57
+ }
@@ -0,0 +1,88 @@
1
+ import { execa } from 'execa';
2
+
3
+ /**
4
+ * Build the common curl args for mTLS authentication.
5
+ * @param {string} p12Path - Path to client.p12
6
+ * @param {string} p12Password - P12 password
7
+ * @returns {string[]}
8
+ */
9
+ function certArgs(p12Path, p12Password) {
10
+ return [
11
+ '--cert-type', 'P12',
12
+ '--cert', `${p12Path}:${p12Password}`,
13
+ '-k', // accept self-signed server cert
14
+ '-s', // silent
15
+ '-f', // fail on HTTP errors
16
+ '--max-time', '30',
17
+ ];
18
+ }
19
+
20
+ /**
21
+ * Check panel connectivity by hitting /api/health.
22
+ * @param {string} panelUrl - e.g. "https://1.2.3.4:9292"
23
+ * @param {string} p12Path
24
+ * @param {string} p12Password
25
+ * @returns {Promise<object>}
26
+ */
27
+ export async function fetchHealth(panelUrl, p12Path, p12Password) {
28
+ const url = `${panelUrl}/api/health`;
29
+ try {
30
+ const { stdout } = await execa('curl', [
31
+ ...certArgs(p12Path, p12Password),
32
+ url,
33
+ ]);
34
+ return JSON.parse(stdout);
35
+ } catch (err) {
36
+ throw new Error(
37
+ `Cannot reach panel at ${url}. ` +
38
+ `Check the URL and that your client.p12 is valid. ` +
39
+ `Details: ${err.stderr || err.message}`,
40
+ );
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Fetch the plist XML and metadata from the panel.
46
+ * @param {string} panelUrl
47
+ * @param {string} p12Path
48
+ * @param {string} p12Password
49
+ * @returns {Promise<{ plist: string, instructions: object }>}
50
+ */
51
+ export async function fetchPlist(panelUrl, p12Path, p12Password) {
52
+ const url = `${panelUrl}/api/tunnels/mac-plist?format=json`;
53
+ try {
54
+ const { stdout } = await execa('curl', [
55
+ ...certArgs(p12Path, p12Password),
56
+ url,
57
+ ]);
58
+ return JSON.parse(stdout);
59
+ } catch (err) {
60
+ throw new Error(
61
+ `Failed to fetch plist from panel. ` +
62
+ `Details: ${err.stderr || err.message}`,
63
+ );
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Fetch the tunnel list from the panel.
69
+ * @param {string} panelUrl
70
+ * @param {string} p12Path
71
+ * @param {string} p12Password
72
+ * @returns {Promise<{ tunnels: Array<{ id: string, subdomain: string, port: number }> }>}
73
+ */
74
+ export async function fetchTunnels(panelUrl, p12Path, p12Password) {
75
+ const url = `${panelUrl}/api/tunnels`;
76
+ try {
77
+ const { stdout } = await execa('curl', [
78
+ ...certArgs(p12Path, p12Password),
79
+ url,
80
+ ]);
81
+ return JSON.parse(stdout);
82
+ } catch (err) {
83
+ throw new Error(
84
+ `Failed to fetch tunnels from panel. ` +
85
+ `Details: ${err.stderr || err.message}`,
86
+ );
87
+ }
88
+ }
@@ -0,0 +1,41 @@
1
+ import { homedir } from 'node:os';
2
+ import path from 'node:path';
3
+
4
+ const HOME = homedir();
5
+
6
+ export const AGENT_DIR = path.join(HOME, '.portlama');
7
+ export const CHISEL_BIN_DIR = path.join(AGENT_DIR, 'bin');
8
+ export const CHISEL_BIN_PATH = path.join(CHISEL_BIN_DIR, 'chisel');
9
+ export const LOGS_DIR = path.join(AGENT_DIR, 'logs');
10
+ export const CONFIG_PATH = path.join(AGENT_DIR, 'agent.json');
11
+ export const PLIST_LABEL = 'com.portlama.chisel';
12
+ export const PLIST_PATH = path.join(HOME, 'Library', 'LaunchAgents', `${PLIST_LABEL}.plist`);
13
+ export const LOG_FILE = path.join(LOGS_DIR, 'chisel.log');
14
+ export const ERROR_LOG_FILE = path.join(LOGS_DIR, 'chisel.error.log');
15
+
16
+ /**
17
+ * Assert we are running on macOS. Throws if not.
18
+ */
19
+ export function assertMacOS() {
20
+ if (process.platform !== 'darwin') {
21
+ throw new Error(
22
+ 'portlama-agent is designed for macOS only. ' +
23
+ `Detected platform: ${process.platform}`,
24
+ );
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Detect architecture and return the Chisel release suffix.
30
+ * @returns {'darwin_arm64' | 'darwin_amd64'}
31
+ */
32
+ export function detectArch() {
33
+ switch (process.arch) {
34
+ case 'arm64':
35
+ return 'darwin_arm64';
36
+ case 'x64':
37
+ return 'darwin_amd64';
38
+ default:
39
+ throw new Error(`Unsupported architecture: ${process.arch}. Expected arm64 or x64.`);
40
+ }
41
+ }
@@ -0,0 +1,43 @@
1
+ import { writeFile, rename, mkdir } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { CHISEL_BIN_PATH, LOG_FILE, ERROR_LOG_FILE, PLIST_PATH } from './platform.js';
4
+
5
+ /**
6
+ * Rewrite server-generated plist XML to use user-scoped paths.
7
+ *
8
+ * Replaces:
9
+ * /usr/local/bin/chisel → ~/.portlama/bin/chisel
10
+ * /usr/local/var/log/chisel.log → ~/.portlama/logs/chisel.log
11
+ * /usr/local/var/log/chisel.error.log → ~/.portlama/logs/chisel.error.log
12
+ *
13
+ * @param {string} xml - Original plist XML from the panel
14
+ * @returns {string} Rewritten plist XML
15
+ */
16
+ export function rewritePlist(xml) {
17
+ let result = xml;
18
+ result = result.replace(
19
+ /\/usr\/local\/bin\/chisel/g,
20
+ CHISEL_BIN_PATH,
21
+ );
22
+ result = result.replace(
23
+ /\/usr\/local\/var\/log\/chisel\.log/g,
24
+ LOG_FILE,
25
+ );
26
+ result = result.replace(
27
+ /\/usr\/local\/var\/log\/chisel\.error\.log/g,
28
+ ERROR_LOG_FILE,
29
+ );
30
+ return result;
31
+ }
32
+
33
+ /**
34
+ * Write the plist file to ~/Library/LaunchAgents/ atomically.
35
+ * @param {string} xml - Plist XML content (already rewritten)
36
+ */
37
+ export async function writePlistFile(xml) {
38
+ const dir = path.dirname(PLIST_PATH);
39
+ await mkdir(dir, { recursive: true });
40
+ const tmp = PLIST_PATH + '.tmp';
41
+ await writeFile(tmp, xml, 'utf8');
42
+ await rename(tmp, PLIST_PATH);
43
+ }