@portalsdk/cli 0.1.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Portal
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,68 @@
1
+ # @portalsdk/cli
2
+
3
+ The Portal command line. Two commands: deploy your configuration, and set secrets.
4
+
5
+ ```bash
6
+ npm install -g @portalsdk/cli
7
+ # or run without installing:
8
+ npx @portalsdk/cli deploy
9
+ ```
10
+
11
+ ## Authenticate
12
+
13
+ Every command needs your project's **secret** key (`sk_...`):
14
+
15
+ ```bash
16
+ export PORTAL_SECRET=sk_your_secret_key
17
+ ```
18
+
19
+ ## `portal deploy`
20
+
21
+ Deploys the `portal.config.ts` in the current directory (or `--config <path>`).
22
+ Your config is authored with [`@portalsdk/config`](https://www.npmjs.com/package/@portalsdk/config).
23
+
24
+ ```bash
25
+ portal deploy
26
+ ```
27
+
28
+ ```
29
+ ✓ Deployed portal.config.ts
30
+ Version cfg_01j…
31
+ 3 channel overrides: live-events, room-*, support
32
+ Uploaded: hooks, 1 extension
33
+ ```
34
+
35
+ Deploying is atomic — if anything fails, nothing changes. Channels with active
36
+ connections keep their current configuration until they restart; new connections use
37
+ the new version right away.
38
+
39
+ Options:
40
+
41
+ - `-c, --config <path>` — path to your config file (default: `portal.config.ts`).
42
+
43
+ ## `portal secrets set <name>`
44
+
45
+ Sets a secret your config reads with `env("NAME")`. The value is sent once and never
46
+ printed.
47
+
48
+ ```bash
49
+ # from a flag
50
+ portal secrets set OPENAI_API_KEY --value sk-...
51
+
52
+ # or piped on stdin
53
+ echo -n "sk-..." | portal secrets set OPENAI_API_KEY
54
+
55
+ # or typed at a hidden prompt
56
+ portal secrets set OPENAI_API_KEY
57
+ ```
58
+
59
+ ## Environment
60
+
61
+ | Variable | Purpose |
62
+ | --- | --- |
63
+ | `PORTAL_SECRET` | Your project secret key (`sk_...`). Required. |
64
+ | `PORTAL_API_URL` | Override the API base URL (for local development). Optional. |
65
+
66
+ ## License
67
+
68
+ MIT
package/dist/index.js ADDED
@@ -0,0 +1,54 @@
1
+ #!/usr/bin/env node
2
+ import{Command as Pt}from"commander";import p from"picocolors";import ht from"path";var u=class extends Error{constructor(e,o,r){super(e);this.status=o;this.code=r;this.name="ApiError"}},d=class{constructor(n,e){this.secret=e;this.baseUrl=n.replace(/\/+$/,"")}baseUrl;async createDeploy(n){let{status:e,data:o}=await this.send("POST","/v1/deploys",n);if(e>=200&&e<300)return{versionId:D(o),alreadyDeployed:!1};if(e===409)return{versionId:D(o),alreadyDeployed:!0};throw this.fail(e,o)}async activate(n){let{status:e,data:o}=await this.send("POST",`/v1/deploys/${encodeURIComponent(n)}/activate`);if(e<200||e>=300)throw this.fail(e,o)}async setSecret(n,e){let{status:o,data:r}=await this.send("PUT",`/v1/secrets/${encodeURIComponent(n)}`,{value:e});if(o<200||o>=300)throw this.fail(o,r)}async send(n,e,o){let r;try{r=await fetch(`${this.baseUrl}${e}`,{method:n,headers:{authorization:`Bearer ${this.secret}`,"content-type":"application/json"},body:o===void 0?void 0:JSON.stringify(o)})}catch(i){throw new u(`Could not reach the Portal API at ${this.baseUrl}: ${i instanceof Error?i.message:String(i)}`,0)}let a=await r.text(),s={};if(a)try{s=JSON.parse(a)}catch{s={reason:a}}return{status:r.status,data:s}}fail(n,e){let o=e.code;return n===401||o==="unauthorized"?new u("Your PORTAL_SECRET was not accepted. It must be a valid project secret key (sk_...).",n,o):new u(e.reason||`The Portal API rejected the request (${o??n}).`,n,o)}};function D(t){if(!t.versionId)throw new u("The Portal API did not return a version id for this deploy.",502);return t.versionId}import{existsSync as I}from"fs";import k from"path";import{mkdtemp as V,rm as J,writeFile as Y}from"fs/promises";import{tmpdir as G}from"os";import g from"path";import{pathToFileURL as X}from"url";import M from"esbuild";var v=class extends Error{};async function x(t){let n;try{n=(await M.build({entryPoints:[t],bundle:!0,write:!1,format:"esm",platform:"node",target:"node18",logLevel:"silent",absWorkingDir:g.dirname(t)})).outputFiles[0].text}catch(r){throw new v(Q(r,t))}let e=await V(g.join(G(),"portal-cli-")),o=g.join(e,"module.mjs");try{return await Y(o,n,"utf8"),await import(X(o).href)}finally{await J(e,{recursive:!0,force:!0})}}function Q(t,n){if(t!=null&&typeof t=="object"&&"errors"in t&&Array.isArray(t.errors)){let o=M.formatMessagesSync(t.errors,{kind:"error",color:!1}).join(`
3
+ `).trimEnd();if(o)return o}return`Failed to compile ${g.basename(n)}: ${t instanceof Error?t.message:String(t)}`}var m=class extends Error{},Z=["portal.config.ts","portal.config.mts","portal.config.js","portal.config.mjs"];function O(t,n){if(n){let e=k.resolve(t,n);if(!I(e))throw new m(`No config file found at ${n}`);return e}for(let e of Z){let o=k.join(t,e);if(I(o))return o}throw new m(`No portal.config.ts found in ${t}. Create one, or point at it with --config <path>.`)}async function R(t){let e=(await x(t)).default;if(e==null||typeof e!="object")throw new m(`${k.basename(t)} must \`export default defineConfig({ ... })\`.`);return e}function b(t,n){let e={project:{webhooks:null},channels:{}};t.auth&&(e.project.auth={issuer:t.auth.issuer,jwksUrl:t.auth.jwksUrl,claimMap:{...t.auth.claimMap}});for(let[o,r]of Object.entries(t.channels??{}))e.channels[o]=tt(r,n.get(o)??[]);return e}function tt(t,n){let e={};t.authz&&(e.authz=!0),t.onPublish&&t.onPublish.length>0&&(e.onPublish=t.onPublish.length),t.onDisconnect&&t.onDisconnect.length>0&&(e.onDisconnect=t.onDisconnect.length),t.notify&&(e.notify=!0);let o={};for(let r of n)o[r.handle]={namespace:r.namespace,transport:r.transport};return{mode:t.mode??"standard",anonymous:t.anonymous??!0,hooks:e,extensions:o}}import{mkdtemp as ot,rm as rt,writeFile as st}from"fs/promises";import{tmpdir as it}from"os";import y from"path";import at from"esbuild";var l=class extends Error{};function S(t,n){if(t.webhooks!=null)throw new l("The `webhooks` block is reserved for a future release. Remove it before deploying.");for(let[e,o]of Object.entries(t.channels??{})){if(o&&"onMiddlewareFailure"in o)throw new l(`Channel "${e}" sets \`onMiddlewareFailure\`, which is no longer supported \u2014 message handling always fails closed. Remove it.`);et(e),nt(e,n.get(e)??[])}}function et(t){if(t.includes("*")&&!/^[^*]+\*$/.test(t))throw new l(`Unsupported channel pattern "${t}": a template must end with a single "*" over a fixed prefix (e.g. "room-*").`)}function nt(t,n){let e=new Map;for(let o of n){let r=e.get(o.namespace);if(r)throw new l(`Channel "${t}" has two extensions using the namespace "${o.namespace}" ("${r}" and "${o.handle}"). Each namespace must be unique within a channel.`);e.set(o.namespace,o.handle)}}var ct="hooks";function N(t){return`import config from ${JSON.stringify(t)};
4
+
5
+ const channels = config.channels || {};
6
+
7
+ function channelFor(key) {
8
+ const channel = channels[key];
9
+ if (!channel) throw new Error("No configuration for channel key: " + key);
10
+ return channel;
11
+ }
12
+
13
+ export async function run(request) {
14
+ const channel = channelFor(request.channelKey);
15
+ switch (request.hook) {
16
+ case "authz": {
17
+ if (typeof channel.authz !== "function") return { result: { action: "allow" } };
18
+ return { result: await channel.authz(request.ctx) };
19
+ }
20
+ case "publish": {
21
+ const step = (channel.onPublish || [])[request.index];
22
+ if (typeof step !== "function") throw new Error("No publish middleware at index " + request.index);
23
+ const deferred = [];
24
+ const notifications = [];
25
+ const ctx = Object.assign({}, request.ctx, {
26
+ defer: (fn) => { deferred.push(fn); },
27
+ notify: (fn) => { notifications.push(fn); },
28
+ });
29
+ const result = await step(ctx);
30
+ return { result, deferred: deferred.length, notifications: notifications.length };
31
+ }
32
+ case "disconnect": {
33
+ const step = (channel.onDisconnect || [])[request.index];
34
+ if (typeof step !== "function") throw new Error("No disconnect callback at index " + request.index);
35
+ const notifications = [];
36
+ const ctx = Object.assign({}, request.ctx, {
37
+ notify: (fn) => { notifications.push(fn); },
38
+ });
39
+ const result = await step(ctx);
40
+ return { result, notifications: notifications.length };
41
+ }
42
+ case "notify": {
43
+ if (typeof channel.notify !== "function") return { result: null };
44
+ return { result: await channel.notify(request.ctx) };
45
+ }
46
+ default:
47
+ throw new Error("Unknown hook: " + request.hook);
48
+ }
49
+ }
50
+
51
+ export default { run };
52
+ `}function T(t){return Object.values(t.channels??{}).some(n=>n.authz!=null||(n.onPublish?.length??0)>0||(n.onDisconnect?.length??0)>0||n.notify!=null)}async function P(t,n,e){let o=y.dirname(n),r;if(T(t)){let s=await pt(n);r={name:ct,content:s}}let a=[];for(let[s,i]of e)for(let c of i){let f=await q(c.path,o);a.push({key:s,handle:c.handle,name:ft(s,c.handle),content:f})}return lt(a),{hookScript:r,extensionScripts:a}}function lt(t){let n=new Map;for(let e of t){let o=n.get(e.name);if(o)throw new l(`Extensions "${o.handle}" (channel "${o.key}") and "${e.handle}" (channel "${e.key}") both map to the upload name "${e.name}". Rename a handle or channel so they differ by more than punctuation.`);n.set(e.name,e)}}function C(t,n){for(let e of n){let o=t.channels[e.key]?.extensions[e.handle];o&&(o.script=e.name)}}async function pt(t){let n=await ot(y.join(it(),"portal-cli-hooks-")),e=y.join(n,"hooks.mjs");try{return await st(e,N(t),"utf8"),await q(e,y.dirname(t))}finally{await rt(n,{recursive:!0,force:!0})}}async function q(t,n){return(await at.build({entryPoints:[t],bundle:!0,write:!1,format:"esm",target:"es2022",platform:"neutral",logLevel:"silent",absWorkingDir:n})).outputFiles[0].text}function ft(t,n){return`ext-${U(t)}-${U(n)}`}function U(t){return t.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-+|-+$/g,"")}import{existsSync as ut}from"fs";import dt from"path";var h=class extends Error{};async function $(t,n){let e=new Map;for(let[o,r]of Object.entries(t.channels??{})){let a=r.extensions;if(!a||Object.keys(a).length===0)continue;let s=[];for(let[i,c]of Object.entries(a)){let f=dt.resolve(n,c);if(!ut(f))throw new h(`Channel "${o}" attaches extension "${i}" from "${c}", but that file does not exist.`);let K=await x(f),E=mt(K.default);if(!E)throw new h(`Extension "${i}" (${c}) must \`export default defineExtension(...)\` with a static manifest.`);s.push({handle:i,source:c,namespace:E.namespace,transport:E.transport,path:f})}e.set(o,s)}return e}function mt(t){if(typeof t!="function"&&typeof t!="object")return null;let n=t?.manifest;if(n==null||typeof n!="object")return null;let{namespace:e,transport:o}=n;return typeof e!="string"||!e.endsWith(".")||o!=="ws"&&o!=="http"?null:{namespace:e,transport:o}}async function H(t){let n=O(t.cwd,t.configPath),e=await R(n),o=ht.dirname(n),r=await $(e,o);S(e,r);let a=b(e,r),s=await P(e,n,r);C(a,s.extensionScripts);let i=new d(t.apiUrl,t.secret),{versionId:c,alreadyDeployed:f}=await i.createDeploy({extract:a,hookScript:s.hookScript,extensionScripts:s.extensionScripts.length>0?s.extensionScripts:void 0});return await i.activate(c),{configPath:n,versionId:c,alreadyDeployed:f,channels:Object.keys(a.channels),hasHookScript:!!s.hookScript,extensionCount:s.extensionScripts.length}}async function L(t){await new d(t.apiUrl,t.secret).setSecret(t.name,t.value)}import F from"path";var gt="https://api.useportal.co";function _(t,n){let e=F.relative(n,t);return e&&!e.startsWith("..")&&!F.isAbsolute(e)?e:t}function j(){return(process.env.PORTAL_API_URL||gt).replace(/\/+$/,"")}function A(){let t=process.env.PORTAL_SECRET;if(!t)throw new Error("Set PORTAL_SECRET to your project's secret key (sk_...) before running this command.");return t}async function B(t){if(t!=null&&t!=="")return t;if(process.stdin.isTTY){let e=await St("Value: ");if(!e)throw new Error("No value entered.");return e}let n=await xt();if(!n)throw new Error("No value provided. Pass --value <value> or pipe the value on stdin.");return n}async function xt(){let t=[];for await(let n of process.stdin)t.push(n);return Buffer.concat(t).toString("utf8").trim()}var yt=10,wt=13,Et=4,vt=3,kt=8,bt=127;function St(t){return new Promise(n=>{let e=process.stdin;process.stdout.write(t),e.setRawMode?.(!0),e.resume(),e.setEncoding("utf8");let o="",r=a=>{for(let s of a){let i=s.charCodeAt(0);if(i===yt||i===wt||i===Et){e.setRawMode?.(!1),e.pause(),e.removeListener("data",r),process.stdout.write(`
53
+ `),n(o);return}i===vt&&(e.setRawMode?.(!1),e.pause(),e.removeListener("data",r),process.stdout.write(`
54
+ `),process.exit(1)),i===kt||i===bt?o=o.slice(0,-1):o+=s}};e.on("data",r)})}var Ct="0.1.0",w=new Pt;w.name("portal").description("Deploy your Portal configuration and manage secrets.").version(Ct);w.command("deploy").description("Deploy your portal.config.ts.").option("-c, --config <path>","Path to your config file (default: portal.config.ts)").action(async t=>{await W(async()=>{let n=A(),e=await H({cwd:process.cwd(),configPath:t.config,apiUrl:j(),secret:n});jt(e)})});var $t=w.command("secrets").description("Manage your project secrets.");$t.command("set").argument("<name>",'Secret name, referenced in your config with env("NAME")').description("Set a project secret.").option("--value <value>","The secret value (otherwise read from stdin)").action(async(t,n)=>{await W(async()=>{let e=A(),o=await B(n.value);await L({name:t,value:o,apiUrl:j(),secret:e}),console.log(`${p.green("\u2713")} Secret ${p.bold(t)} set.`)})});w.parseAsync(process.argv);async function W(t){try{await t()}catch(n){console.error(`${p.red("Error:")} ${n instanceof Error?n.message:String(n)}`),process.exitCode=1}}function jt(t){t.alreadyDeployed?console.log(`${p.green("\u2713")} Already up to date \u2014 version ${p.bold(t.versionId)}`):(console.log(`${p.green("\u2713")} Deployed ${p.bold(_(t.configPath,process.cwd()))}`),console.log(` Version ${t.versionId}`));let{channels:n}=t;console.log(` ${n.length} channel ${z(n.length,"override","overrides")}: ${n.join(", ")}`);let e=[];t.hasHookScript&&e.push("hooks"),t.extensionCount>0&&e.push(`${t.extensionCount} ${z(t.extensionCount,"extension","extensions")}`),e.length>0&&console.log(` Uploaded: ${e.join(", ")}`),console.log(p.dim(" Channels with active connections keep their current configuration until they restart; new connections use this version right away."))}function z(t,n,e){return t===1?n:e}
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@portalsdk/cli",
3
+ "version": "0.1.0",
4
+ "description": "The Portal command line: deploy your configuration and manage secrets.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "portal": "./dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist"
12
+ ],
13
+ "publishConfig": {
14
+ "access": "public"
15
+ },
16
+ "engines": {
17
+ "node": ">=18"
18
+ },
19
+ "keywords": [
20
+ "portal",
21
+ "cli",
22
+ "deploy",
23
+ "realtime"
24
+ ],
25
+ "scripts": {
26
+ "build": "tsup",
27
+ "dev": "tsx src/index.ts",
28
+ "typecheck": "tsc -p tsconfig.json --noEmit",
29
+ "test": "vitest run",
30
+ "test:watch": "vitest",
31
+ "prepublishOnly": "tsup"
32
+ },
33
+ "dependencies": {
34
+ "commander": "^14.0.2",
35
+ "esbuild": "^0.25.0",
36
+ "picocolors": "^1.1.1"
37
+ },
38
+ "devDependencies": {
39
+ "@portalsdk/config": "workspace:*",
40
+ "@types/node": "^24",
41
+ "tsup": "^8.5.0",
42
+ "tsx": "^4.7.1",
43
+ "typescript": "^5.8.3",
44
+ "vitest": "^3.2.4"
45
+ }
46
+ }