portomatic 0.1.0 → 0.1.2

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.
@@ -2,23 +2,141 @@
2
2
  // portomatic <product> [command] [flags]: one command for every Portomatic emulator.
3
3
  // Each product's CLI runs in this process with its own argv, so output, signals and exit codes are its own.
4
4
  import { readFileSync } from 'node:fs';
5
- import { fileURLToPath } from 'node:url';
5
+ import { mkdir, mkdtemp, readFile, readdir, rename, rm, stat, utimes, writeFile } from 'node:fs/promises';
6
+ import { homedir } from 'node:os';
7
+ import { join } from 'node:path';
8
+ import { spawn } from 'node:child_process';
9
+ import { fileURLToPath, pathToFileURL } from 'node:url';
6
10
 
7
- const products = {
8
- slack: { main: '../products/slack/dist/src/main.js', summary: 'Local Slack: seeded workspaces, bots, the Slack UI and a shareable dashboard' },
9
- };
10
- const [product, ...args] = process.argv.slice(2);
11
+ const version = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
12
+ const semver = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([\w.-]+))?(?:\+[\w.-]+)?$/;
13
+ function compare(a, b) {
14
+ const x = semver.exec(a), y = semver.exec(b);
15
+ if (!x || !y) return 0;
16
+ for (let i = 1; i <= 3; i++) if (BigInt(x[i]) !== BigInt(y[i])) return BigInt(x[i]) > BigInt(y[i]) ? 1 : -1;
17
+ if (x[4] === y[4]) return 0;
18
+ if (!x[4] || !y[4]) return x[4] ? -1 : 1;
19
+ const p = x[4].split('.'), q = y[4].split('.');
20
+ for (let i = 0; i < Math.max(p.length, q.length); i++) {
21
+ if (p[i] === q[i]) continue;
22
+ if (p[i] === undefined || q[i] === undefined) return p[i] === undefined ? -1 : 1;
23
+ const pn = /^\d+$/.test(p[i]), qn = /^\d+$/.test(q[i]);
24
+ if (pn && qn) { if (BigInt(p[i]) !== BigInt(q[i])) return BigInt(p[i]) > BigInt(q[i]) ? 1 : -1; }
25
+ else return pn !== qn ? (pn ? -1 : 1) : (p[i] > q[i] ? 1 : -1);
26
+ }
27
+ return 0;
28
+ }
29
+
30
+ async function installed(root, v, directory = join(root, v)) {
31
+ try {
32
+ const pkg = join(directory, 'node_modules/portomatic');
33
+ const metadata = JSON.parse(await readFile(join(pkg, 'package.json'), 'utf8'));
34
+ if (metadata.name === 'portomatic' && metadata.version === v && (await stat(join(pkg, 'bin/portomatic.mjs'))).isFile())
35
+ return { version: v, bin: join(pkg, 'bin/portomatic.mjs') };
36
+ } catch {}
37
+ }
11
38
 
12
- if (product === '--version' || product === '-v') {
13
- console.log(JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version);
14
- } else if (!products[product]) {
15
- const list = Object.entries(products).map(([name, p]) => ` ${name.padEnd(8)} ${p.summary}`).join('\n');
16
- const usage = `Portomatic: local emulators for the services your agents use.\n\nUSAGE portomatic <product> [command] [flags]\n\nPRODUCTS\n${list}\n\nEXAMPLE portomatic slack start --share --headless --port 0 --json\nMORE portomatic slack help | portomatic slack docs agents\n`;
17
- const asked = !product || ['help', '--help', '-h'].includes(product);
18
- (asked ? process.stdout : process.stderr).write(asked ? usage : `Unknown product '${product}'.\n\n${usage}`);
19
- process.exitCode = asked ? 0 : 1;
39
+ async function cached(root) {
40
+ const entries = await readdir(root, { withFileTypes: true }).catch(() => []);
41
+ const versions = await Promise.all(entries.filter(e => e.isDirectory() && semver.test(e.name)).map(e => installed(root, e.name)));
42
+ return versions.filter(Boolean).sort((a, b) => compare(b.version, a.version));
43
+ }
44
+
45
+ async function lock(root) {
46
+ const dir = join(root, '.lock'), until = Date.now() + 100_000;
47
+ await mkdir(root, { recursive: true, mode: 0o700 });
48
+ while (Date.now() < until) {
49
+ try {
50
+ await mkdir(dir);
51
+ return () => rm(dir, { recursive: true, force: true });
52
+ } catch (error) {
53
+ if (error.code !== 'EEXIST') throw error;
54
+ const info = await stat(dir).catch(() => undefined);
55
+ if (info && Date.now() - info.mtimeMs > 120_000) await rm(dir, { recursive: true, force: true });
56
+ else await new Promise(resolve => setTimeout(resolve, 100));
57
+ }
58
+ }
59
+ throw new Error('update lock timeout');
60
+ }
61
+
62
+ async function update() {
63
+ const root = join(process.env.PORTOMATIC_HOME ?? join(homedir(), '.portomatic'), 'versions');
64
+ let latest, selected;
65
+ try {
66
+ const response = await fetch(`${(process.env.PORTOMATIC_REGISTRY ?? 'https://registry.npmjs.org').replace(/\/$/, '')}/portomatic/latest`, { signal: AbortSignal.timeout(1500) });
67
+ if (response.ok) latest = await response.json();
68
+ } catch {}
69
+ try {
70
+ const release = await lock(root);
71
+ try {
72
+ selected = (await cached(root)).find(v => compare(v.version, version) > 0);
73
+ if (semver.test(latest?.version) && compare(latest.version, selected?.version ?? version) > 0 && latest.dist?.tarball) {
74
+ const tmp = await mkdtemp(join(root, '.install-'));
75
+ try {
76
+ console.error(`portomatic: updating ${version} -> ${latest.version}...`);
77
+ await new Promise((resolve, reject) => {
78
+ const child = spawn('npm', ['install', latest.dist.tarball, '--prefix', tmp, '--omit=dev', '--no-save', '--no-audit', '--no-fund', '--loglevel=error'], { stdio: 'ignore' });
79
+ const timer = setTimeout(() => child.kill('SIGKILL'), 90_000);
80
+ child.on('error', error => { clearTimeout(timer); reject(error); });
81
+ child.on('close', code => { clearTimeout(timer); code === 0 ? resolve() : reject(new Error('npm install failed')); });
82
+ });
83
+ // Never promote a partial install or a tarball whose version disagrees with the registry.
84
+ if (!await installed(root, latest.version, tmp)) throw new Error('invalid install');
85
+ const target = join(root, latest.version);
86
+ await rm(target, { recursive: true, force: true });
87
+ await rename(tmp, target);
88
+ selected = await installed(root, latest.version);
89
+ if (selected) selected.fresh = true;
90
+ } catch {}
91
+ finally { await rm(tmp, { recursive: true, force: true }); }
92
+ }
93
+ // A long-running emulator still reads UI assets and helpers from its version's directory, so older
94
+ // versions are only removed once no run has used them for a week.
95
+ const entries = await readdir(root, { withFileTypes: true });
96
+ for (const e of entries) if (e.isDirectory() && semver.test(e.name) && compare(e.name, selected?.version ?? version) < 0) {
97
+ const used = await stat(join(root, e.name, '.used')).catch(() => undefined);
98
+ if (!used || Date.now() - used.mtimeMs > 7 * 86400_000) await rm(join(root, e.name), { recursive: true, force: true });
99
+ }
100
+ } finally { await release(); }
101
+ } catch {}
102
+ return selected ?? (await cached(root)).find(v => compare(v.version, version) > 0);
103
+ }
104
+
105
+ process.env.PORTOMATIC_VERSION = version;
106
+ let runtime;
107
+ if (process.env.PORTOMATIC_NO_UPDATE !== '1' && process.env.PORTOMATIC_RUNTIME !== '1') {
108
+ try { runtime = await update(); } catch {}
109
+ }
110
+ if (runtime?.fresh) {
111
+ // Updating ends this invocation; the next one runs the new version from the start.
112
+ const message = `portomatic updated ${version} -> ${runtime.version}. Run the command again.`;
113
+ if (process.argv.includes('--json')) console.log(JSON.stringify({ ok: false, error: 'updated', updated: { from: version, to: runtime.version }, message }));
114
+ else console.error(message);
115
+ process.exitCode = 75;
116
+ } else if (runtime) {
117
+ const marker = join(runtime.bin, '../../../../.used'), now = new Date();
118
+ await utimes(marker, now, now).catch(() => writeFile(marker, '')).catch(() => {});
119
+ process.env.PORTOMATIC_RUNTIME = '1';
120
+ process.env.PORTOMATIC_VERSION = runtime.version;
121
+ process.argv = [process.argv[0], runtime.bin, ...process.argv.slice(2)];
122
+ await import(pathToFileURL(runtime.bin).href);
20
123
  } else {
21
- const main = new URL(products[product].main, import.meta.url);
22
- process.argv = [process.argv[0], fileURLToPath(main), ...args];
23
- await import(main.href);
124
+ const products = {
125
+ slack: { main: '../products/slack/dist/src/main.js', summary: 'Local Slack: seeded workspaces, bots, the Slack UI and a shareable dashboard' },
126
+ };
127
+ const [product, ...args] = process.argv.slice(2);
128
+
129
+ if (product === '--version' || product === '-v') {
130
+ console.log(version);
131
+ } else if (!products[product]) {
132
+ const list = Object.entries(products).map(([name, p]) => ` ${name.padEnd(8)} ${p.summary}`).join('\n');
133
+ const usage = `Portomatic: local emulators for the services your agents use.\n\nUSAGE portomatic <product> [command] [flags]\n\nPRODUCTS\n${list}\n\nEXAMPLE portomatic slack start --share --headless --port 0 --json\nMORE portomatic slack help | portomatic slack docs agents\n`;
134
+ const asked = !product || ['help', '--help', '-h'].includes(product);
135
+ (asked ? process.stdout : process.stderr).write(asked ? usage : `Unknown product '${product}'.\n\n${usage}`);
136
+ process.exitCode = asked ? 0 : 1;
137
+ } else {
138
+ const main = new URL(products[product].main, import.meta.url);
139
+ process.argv = [process.argv[0], fileURLToPath(main), ...args];
140
+ await import(main.href);
141
+ }
24
142
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portomatic",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Local emulators for the services your agents use. Start with: npx portomatic slack start --share",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,2 +1,2 @@
1
- var U=Object.defineProperty;var d=(h,p)=>U(h,"name",{value:p,configurable:!0});import y from"ws";import{mkdir as j,readFile as $,writeFile as x}from"node:fs/promises";import{homedir as C}from"node:os";import{join as q}from"node:path";import{randomBytes as F}from"node:crypto";const I=process.env.PORTOMATIC_RELAY_URL??"https://relay.portomatic.com",S=256*1024,H=8*1024*1024,J=new Set(["content-encoding","content-length","transfer-encoding","connection","keep-alive"]),W=d(()=>q(process.env.PORTOMATIC_HOME??q(C(),".portomatic"),"device.json"),"deviceFile");async function z(){const h=W();try{return JSON.parse(await $(h,"utf8"))}catch{}const p=await fetch(`${I}/v1/devices`,{method:"POST",signal:AbortSignal.timeout(15e3)}),g=await p.json();if(!g.ok)throw new Error(`Portomatic relay signup failed: ${g.error??p.status}`);const{device_id:O,slug:v,device_secret:i,view_key:_,dashboard_url:P}=g,a={device_id:O,slug:v,device_secret:i,view_key:_,dashboard_url:P};return await j(q(h,".."),{recursive:!0,mode:448}),await x(h,JSON.stringify(a,null,2)+`
2
- `,{mode:384}),a}d(z,"device");async function X(h,p,g=()=>{},O="/_emu/ui/"){const v=await z(),i=new URL(h),_=F(16).toString("hex"),P=[i.origin,`${i.protocol}//localhost:${i.port}`];let a="",b,R=!1,k=1e3,w;const m=new Map,l=new Map,E=d(t=>{if(!a)return t;for(const e of P)t=t.replaceAll(e,a).replaceAll(e.replace(/^http/,"ws"),a.replace(/^https/,"wss"));return t},"rewrite"),N=d(t=>{const e=a&&new URL(a).origin;return a&&t.startsWith(a)?i.origin+t.slice(a.length):e&&t.startsWith(e)?i.origin+t.slice(e.length):t},"localize"),u=d(t=>{b?.readyState===y.OPEN&&b.send(JSON.stringify(t))},"send");async function T(t){const e=m.get(t);m.delete(t);try{const o=new Headers;for(const[n,s]of e.headers)n!=="accept-encoding"&&o.append(n,n==="origin"||n==="referer"?N(s):s);const c=e.body.length&&!["GET","HEAD"].includes(e.method)?Buffer.concat(e.body):void 0,r=await fetch(new URL(e.path,i),{method:e.method,headers:o,body:c,redirect:"manual"}),f=[];r.headers.forEach((n,s)=>{J.has(s)||f.push([s,s==="location"?E(n):n])}),u({t:"res",id:t,status:r.status,headers:f});const M=r.headers.get("content-type")??"";if(/json|text\/(plain|html)/.test(M)&&Number(r.headers.get("content-length")??0)<=H){const n=Buffer.from(E(await r.text()));for(let s=0;s<n.length;s+=S)u({t:"res-body",id:t,b64:n.subarray(s,s+S).toString("base64")})}else if(r.body)for await(const n of r.body)for(let s=0;s<n.length;s+=S)u({t:"res-body",id:t,b64:Buffer.from(n.subarray(s,s+S)).toString("base64")});u({t:"res-end",id:t})}catch(o){u({t:"res-error",id:t,message:String(o.message??o)})}}d(T,"forward");function A(t){const e={origin:i.origin};for(const[r,f]of t.headers)["origin","host","accept-encoding"].includes(r)||(e[r]=f);const o=new y(new URL(t.path,i.origin.replace(/^http/,"ws")),t.protocols,{headers:e}),c={ws:o,queue:[]};l.set(t.sid,c),o.on("open",()=>{for(const r of c.queue)o.send(r);c.queue.length=0}),o.on("message",(r,f)=>u(f?{t:"ws-msg",sid:t.sid,b64:Buffer.from(r).toString("base64")}:{t:"ws-msg",sid:t.sid,text:E(r.toString())})),o.on("close",(r,f)=>{l.delete(t.sid),u({t:"ws-close",sid:t.sid,code:r,reason:f.toString()})}),o.on("error",()=>{})}d(A,"openLocal");function B(t){if(t==="pong")return;const e=JSON.parse(t);switch(e.t){case"hello":a=e.base,k=1e3;break;case"req":m.set(e.id,{method:e.method,path:e.path,headers:e.headers,body:[]});break;case"req-body":m.get(e.id)?.body.push(Buffer.from(e.b64,"base64"));break;case"req-end":m.has(e.id)&&T(e.id);break;case"ws-open":A(e);break;case"ws-msg":{const o=l.get(e.sid);if(!o)break;const c=e.text??Buffer.from(e.b64,"base64");o.ws.readyState===y.OPEN?o.ws.send(c):o.queue.push(c);break}case"ws-close":{const o=l.get(e.sid);l.delete(e.sid),o?.ws.close();break}}}d(B,"onFrame");function L(){if(R)return;const t=new URL("/v1/agent",I.replace(/^http/,"ws"));t.searchParams.set("product",p),t.searchParams.set("token",_),t.searchParams.set("ui",O),t.searchParams.set("port",i.port);const e=b=new y(t,{headers:{authorization:`Bearer ${v.device_secret}`}});e.on("open",()=>{clearInterval(w),w=setInterval(()=>e.readyState===y.OPEN&&e.send("ping"),25e3),w.unref()}),e.on("message",o=>{try{B(o.toString())}catch(c){g(`relay frame: ${c}`)}}),e.on("unexpected-response",(o,c)=>{c.statusCode===401&&g("Portomatic relay rejected this device; delete ~/.portomatic/device.json to register again.")}),e.on("error",()=>{}),e.on("close",()=>{clearInterval(w);for(const{ws:o}of l.values())o.close();l.clear(),m.clear(),!(R||b!==e)&&(setTimeout(L,k).unref(),k=Math.min(k*2,3e4))})}return d(L,"connect"),L(),{dashboard:v.dashboard_url,close(){R=!0,clearInterval(w),b?.close(1e3);for(const{ws:t}of l.values())t.close()}}}d(X,"share");export{z as device,X as share};
1
+ var x=Object.defineProperty;var i=(l,g)=>x(l,"name",{value:g,configurable:!0});import y from"ws";import{mkdir as C,readFile as L,writeFile as J}from"node:fs/promises";import{homedir as $}from"node:os";import{join as T}from"node:path";import{randomBytes as F}from"node:crypto";const q=process.env.PORTOMATIC_RELAY_URL??"https://relay.portomatic.com",P=256*1024,H=8*1024*1024,W=new Set(["content-encoding","content-length","transfer-encoding","connection","keep-alive"]),z=i(()=>T(process.env.PORTOMATIC_HOME??T($(),".portomatic"),"device.json"),"deviceFile");async function D(){for(const l of["../../package.json","../../../package.json"])try{return JSON.parse(await L(new URL(l,import.meta.url),"utf8")).version}catch{}return"0.0.0"}i(D,"packageVersion");async function V(){const l=z();try{return JSON.parse(await L(l,"utf8"))}catch{}const g=await fetch(`${q}/v1/devices`,{method:"POST",signal:AbortSignal.timeout(15e3)}),h=await g.json();if(!h.ok)throw new Error(`Portomatic relay signup failed: ${h.error??g.status}`);const{device_id:R,slug:v,device_secret:_,view_key:d,dashboard_url:E}=h,k={device_id:R,slug:v,device_secret:_,view_key:d,dashboard_url:E};return await C(T(l,".."),{recursive:!0,mode:448}),await J(l,JSON.stringify(k,null,2)+`
2
+ `,{mode:384}),k}i(V,"device");async function ee(l,g,h=()=>{},R="/_emu/ui/"){const v=await V(),_=process.env.PORTOMATIC_VERSION??await D(),d=new URL(l),E=F(16).toString("hex"),k=[d.origin,`${d.protocol}//localhost:${d.port}`];let f="",w,S=!1,O=1e3,b;const m=new Map,u=new Map,N=i(t=>{if(!f)return t;for(const e of k)t=t.replaceAll(e,f).replaceAll(e.replace(/^http/,"ws"),f.replace(/^https/,"wss"));return t},"rewrite"),A=i(t=>{const e=f&&new URL(f).origin;return f&&t.startsWith(f)?d.origin+t.slice(f.length):e&&t.startsWith(e)?d.origin+t.slice(e.length):t},"localize"),p=i(t=>{w?.readyState===y.OPEN&&w.send(JSON.stringify(t))},"send");async function U(t){const e=m.get(t);m.delete(t);try{const o=new Headers;for(const[c,n]of e.headers)c!=="accept-encoding"&&o.append(c,c==="origin"||c==="referer"?A(n):n);const r=e.body.length&&!["GET","HEAD"].includes(e.method)?Buffer.concat(e.body):void 0,s=await fetch(new URL(e.path,d),{method:e.method,headers:o,body:r,redirect:"manual"}),a=[];s.headers.forEach((c,n)=>{W.has(n)||a.push([n,n==="location"?N(c):c])}),p({t:"res",id:t,status:s.status,headers:a});const M=s.headers.get("content-type")??"";if(/json|text\/(plain|html)/.test(M)&&Number(s.headers.get("content-length")??0)<=H){const c=Buffer.from(N(await s.text()));for(let n=0;n<c.length;n+=P)p({t:"res-body",id:t,b64:c.subarray(n,n+P).toString("base64")})}else if(s.body)for await(const c of s.body)for(let n=0;n<c.length;n+=P)p({t:"res-body",id:t,b64:Buffer.from(c.subarray(n,n+P)).toString("base64")});p({t:"res-end",id:t})}catch(o){p({t:"res-error",id:t,message:String(o.message??o)})}}i(U,"forward");function j(t){const e={origin:d.origin};for(const[s,a]of t.headers)["origin","host","accept-encoding"].includes(s)||(e[s]=a);const o=new y(new URL(t.path,d.origin.replace(/^http/,"ws")),t.protocols,{headers:e}),r={ws:o,queue:[]};u.set(t.sid,r),o.on("open",()=>{for(const s of r.queue)o.send(s);r.queue.length=0}),o.on("message",(s,a)=>p(a?{t:"ws-msg",sid:t.sid,b64:Buffer.from(s).toString("base64")}:{t:"ws-msg",sid:t.sid,text:N(s.toString())})),o.on("close",(s,a)=>{u.delete(t.sid),p({t:"ws-close",sid:t.sid,code:s,reason:a.toString()})}),o.on("error",()=>{})}i(j,"openLocal");function B(t){if(t==="pong")return;const e=JSON.parse(t);switch(e.t){case"hello":f=e.base,O=1e3;break;case"req":m.set(e.id,{method:e.method,path:e.path,headers:e.headers,body:[]});break;case"req-body":m.get(e.id)?.body.push(Buffer.from(e.b64,"base64"));break;case"req-end":m.has(e.id)&&U(e.id);break;case"ws-open":j(e);break;case"ws-msg":{const o=u.get(e.sid);if(!o)break;const r=e.text??Buffer.from(e.b64,"base64");o.ws.readyState===y.OPEN?o.ws.send(r):o.queue.push(r);break}case"ws-close":{const o=u.get(e.sid);u.delete(e.sid),o?.ws.close();break}}}i(B,"onFrame");function I(){if(S)return;const t=new URL("/v1/agent",q.replace(/^http/,"ws"));t.searchParams.set("product",g),t.searchParams.set("token",E),t.searchParams.set("ui",R),t.searchParams.set("port",d.port),t.searchParams.set("version",_);const e=w=new y(t,{headers:{authorization:`Bearer ${v.device_secret}`}});e.on("open",()=>{clearInterval(b),b=setInterval(()=>e.readyState===y.OPEN&&e.send("ping"),25e3),b.unref()}),e.on("message",o=>{try{B(o.toString())}catch(r){h(`relay frame: ${r}`)}}),e.on("unexpected-response",(o,r)=>{if(r.statusCode===426){S=!0;let s="";r.setEncoding("utf8"),r.on("data",a=>{s+=a}),r.on("end",()=>{let a="This Portomatic version is too old for sharing. Update with: npx portomatic@latest";try{a=JSON.parse(s).message||a}catch{}h(a),e.terminate()})}else r.statusCode===401&&h("Portomatic relay rejected this device; delete ~/.portomatic/device.json to register again."),r.resume(),e.terminate()}),e.on("error",()=>{}),e.on("close",()=>{clearInterval(b);for(const{ws:o}of u.values())o.close();u.clear(),m.clear(),!(S||w!==e)&&(setTimeout(I,O).unref(),O=Math.min(O*2,3e4))})}return i(I,"connect"),I(),{dashboard:v.dashboard_url,close(){S=!0,clearInterval(b),w?.close(1e3);for(const{ws:t}of u.values())t.close()}}}i(ee,"share");export{V as device,ee as share};