/portaljs-deploy
Deploy a PortalJS portal to PortalJS Arc — Datopian-managed static hosting on Cloudflare. Builds a static export, uploads it, and returns a live <slug>.arc.portaljs.com URL. One command, one target.
> /plugin marketplace add datopian/portaljs > /plugin install portaljs@datopian-portaljs
How it fires
How this command gets triggered: by you, by Claude, or both.
- Fires itselfClaude auto-loads it when your prompt matches the work.
- You can call itInvoke it directly when you want it.
- Slash command
/portaljs-deploy
Context preview
What this command does when you run it.
Deploy a PortalJS portal to PortalJS Arc — Datopian-managed static hosting on Cloudflare. Builds a static export, uploads it, and returns a live <slug>.arc.portaljs.com URL. One command, one target.
Command definition
portaljs-deploy.mddescription: Deploy a PortalJS portal to PortalJS Arc — Datopian-managed static hosting on Cloudflare. Builds a static export, uploads it, and returns a live <slug>.arc.portaljs.com URL. One command, one target.
allowed-tools: Read, Write, Edit, Bash
/portaljs-deploy
Publish an existing PortalJS portal to **PortalJS Arc** — Datopian's managed static hosting. The skill builds a static export of the portal, uploads it to the Arc API, and prints a live `https://<slug>.arc.portaljs.com` URL. Re-running redeploys the same portal (idempotent on the slug).
This is a **single-target** skill: it deploys to PortalJS Arc only. If you'd rather host the portal yourself, it's a standard static Next.js export — run `npm run build` and upload `out/` to any static host (Vercel, your own Cloudflare, Netlify, S3, …); you don't need this skill for that.
> **Static only (for now).** Arc serves static exports — the catalog template, `/portaljs-add-dataset`, > `/portaljs-migrate`, and `/portaljs-connect-ckan` (SSG) all export cleanly. SSR isn't hosted on Arc yet.
Required input — ask, don't error
- **Portal directory** (optional) — the portal project (default: current directory). Must be a
Next.js portal (`package.json` with a `next` dependency).
- **Slug** (optional) — the subdomain to publish under (`<slug>.arc.portaljs.com`). Default:
the project's `package.json` `name` (or the directory name), slugified. Override with `--slug <name>`.
- **Auth** — a PortalJS Arc token. Read from `PORTALJS_TOKEN`, else `~/.portaljs/credentials`
(`{ "token": "…" }`). If neither is present, **sign in on demand** (one browser click — device flow, see step 2) to obtain and store one, then continue. Auth is never a separate step the user runs; `/portaljs-deploy` handles it. Don't ask the user to copy a token by hand.
Steps
1. Gather input + validate the portal
Extract from `$ARGUMENTS`:
- `PORTAL_DIR` (default `.`), `SLUG` (default from `package.json` name / dir, slugified to a
DNS label: lowercase, `[a-z0-9-]`, ≤63 chars).
Confirm `PORTAL_DIR/package.json` exists and lists `next`. If not:
ERROR: [deploy] NOT_A_PORTAL No Next.js project in <dir> — run from a portal directory.
Reserved slugs (`www`, `api`, `admin`, `staging`, `arc`) are not allowed — if the derived slug is reserved or invalid, ask for a `--slug`.
2. Resolve the Arc token (sign in on demand)
TOKEN="${PORTALJS_TOKEN:-}"
# Read the credentials file as JSON (never require() it — that executes it as JS
# and an extensionless file won't parse the documented {"token":"…"} shape).
if [ -z "$TOKEN" ] && [ -f "$HOME/.portaljs/credentials" ]; then
TOKEN=$(node -e "const fs=require('fs');try{process.stdout.write(JSON.parse(fs.readFileSync(process.env.HOME+'/.portaljs/credentials','utf8')).token||'')}catch{}")
fiIf `TOKEN` is empty, **sign in inline** with the device-authorization flow (the `gh auth login` / `wrangler login` model — one browser click, no token copying). Run the self-contained Node script below (Node ≥18; uses global `fetch`): it requests a device code, opens the browser, polls until you approve, then writes `~/.portaljs/credentials` (mode 0600). After it succeeds, re-read `TOKEN` with the snippet above and continue.
AUTH="${PORTALJS_ARC_AUTH:-https://arc.portaljs.com}"
API="${PORTALJS_ARC_API:-https://api.arc.portaljs.com}"
cat > /tmp/arc-login.mjs <<'EOF'
import { writeFileSync, mkdirSync, chmodSync } from 'node:fs'
import { join } from 'node:path'
import { homedir, hostname } from 'node:os'
import { spawn } from 'node:child_process'
const AUTH = process.env.PORTALJS_ARC_AUTH || 'https://arc.portaljs.com'
const API = process.env.PORTALJS_ARC_API || 'https://api.arc.portaljs.com'
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
// Best-effort browser open; harmless/no-op in headless/agent sessions.
function openBrowser(url) {
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open'
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url]
try { spawn(cmd, args, { stdio: 'ignore', detached: true }).unref() } catch {}
}
async function main() {
const start = await fetch(`${AUTH}/device/code`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ label: hostname() }),
})
if (!start.ok) throw new Error(`device/code failed: HTTP ${start.status}`)
const { device_code, user_code, verification_uri, verification_uri_complete, interval, expires_in } = await start.json()
console.log('\n Opening your browser… sign in with GitHub, then click "Authorize this device".')
console.log(` Didn't get a browser? Open ${verification_uri} and enter: ${user_code}\n`)
openBrowser(verification_uri_complete || verification_uri)
const deadline = Date.now() + (expires_in || 900) * 1000
let wait = (interval || 5) * 1000
for (;;) {
if (Date.now() > deadline) throw new Error('Timed out waiting for authorization. Re-run /portaljs-deploy.')
await sleep(wait)
const poll = await fetch(`${AUTH}/device/token`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ device_code }),
})
if (poll.status === 200) {
const { token } = await poll.json()
const dir = join(homedir(), '.portaljs')
mkdirSync(dir, { recursive: true })
const file = join(dir, 'credentials')
writeFileSync(file, JSON.stringify({ token, api: API }) + '\n', { mode: 0o600 })
chmodSync(file, 0o600)
// Confirm + greet by login name.
let who = ''
try {
const me = await fetch(`${API}/v1/whoami`, { headers: { authorization: `Bearer ${token}` } })
if (me.ok) who = (await me.json()).login
} catch {}
console.log(`✓ Logged in${who ? ` as @${who}` : ''}. Credentials saved to ${file}`)
return
}
if (poll.status === 428) continue // authorization_Read more
description: Deploy a PortalJS portal to PortalJS Arc — Datopian-managed static hosting on Cloudflare. Builds a static export, uploads it, and returns a live <slug>.arc.portaljs.com URL. One command, one target. allowed-tools: Read, Write, Edit, Bash
/portaljs-deploy
Publish an existing PortalJS portal to **PortalJS Arc** — Datopian's managed static hosting. The skill builds a static export of the portal, uploads it to the Arc API, and prints a live `https://<slug>.arc.portaljs.com` URL. Re-running redeploys the same portal (idempotent on the slug).
This is a **single-target** skill: it deploys to PortalJS Arc only. If you'd rather host the portal yourself, it's a standard static Next.js export — run `npm run build` and upload `out/` to any static host (Vercel, your own Cloudflare, Netlify, S3, …); you don't need this skill for that.
> **Static only (for now).** Arc serves static exports — the catalog template, `/portaljs-add-dataset`, > `/portaljs-migrate`, and `/portaljs-connect-ckan` (SSG) all export cleanly. SSR isn't hosted on Arc yet.
Required input — ask, don't error
- **Portal directory** (optional) — the portal project (default: current directory). Must be a
Next.js portal (`package.json` with a `next` dependency).
- **Slug** (optional) — the subdomain to publish under (`<slug>.arc.portaljs.com`). Default:
the project's `package.json` `name` (or the directory name), slugified. Override with `--slug <name>`.
- **Auth** — a PortalJS Arc token. Read from `PORTALJS_TOKEN`, else `~/.portaljs/credentials`
(`{ "token": "…" }`). If neither is present, **sign in on demand** (one browser click — device flow, see step 2) to obtain and store one, then continue. Auth is never a separate step the user runs; `/portaljs-deploy` handles it. Don't ask the user to copy a token by hand.
Steps
1. Gather input + validate the portal
Extract from `$ARGUMENTS`:
- `PORTAL_DIR` (default `.`), `SLUG` (default from `package.json` name / dir, slugified to a
DNS label: lowercase, `[a-z0-9-]`, ≤63 chars).
Confirm `PORTAL_DIR/package.json` exists and lists `next`. If not:
ERROR: [deploy] NOT_A_PORTAL No Next.js project in <dir> — run from a portal directory.
Reserved slugs (`www`, `api`, `admin`, `staging`, `arc`) are not allowed — if the derived slug is reserved or invalid, ask for a `--slug`.
2. Resolve the Arc token (sign in on demand)
TOKEN="${PORTALJS_TOKEN:-}"
# Read the credentials file as JSON (never require() it — that executes it as JS
# and an extensionless file won't parse the documented {"token":"…"} shape).
if [ -z "$TOKEN" ] && [ -f "$HOME/.portaljs/credentials" ]; then
TOKEN=$(node -e "const fs=require('fs');try{process.stdout.write(JSON.parse(fs.readFileSync(process.env.HOME+'/.portaljs/credentials','utf8')).token||'')}catch{}")
fiIf `TOKEN` is empty, **sign in inline** with the device-authorization flow (the `gh auth login` / `wrangler login` model — one browser click, no token copying). Run the self-contained Node script below (Node ≥18; uses global `fetch`): it requests a device code, opens the browser, polls until you approve, then writes `~/.portaljs/credentials` (mode 0600). After it succeeds, re-read `TOKEN` with the snippet above and continue.
AUTH="${PORTALJS_ARC_AUTH:-https://arc.portaljs.com}"
API="${PORTALJS_ARC_API:-https://api.arc.portaljs.com}"
cat > /tmp/arc-login.mjs <<'EOF'
import { writeFileSync, mkdirSync, chmodSync } from 'node:fs'
import { join } from 'node:path'
import { homedir, hostname } from 'node:os'
import { spawn } from 'node:child_process'
const AUTH = process.env.PORTALJS_ARC_AUTH || 'https://arc.portaljs.com'
const API = process.env.PORTALJS_ARC_API || 'https://api.arc.portaljs.com'
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
// Best-effort browser open; harmless/no-op in headless/agent sessions.
function openBrowser(url) {
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open'
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url]
try { spawn(cmd, args, { stdio: 'ignore', detached: true }).unref() } catch {}
}
async function main() {
const start = await fetch(`${AUTH}/device/code`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ label: hostname() }),
})
if (!start.ok) throw new Error(`device/code failed: HTTP ${start.status}`)
const { device_code, user_code, verification_uri, verification_uri_complete, interval, expires_in } = await start.json()
console.log('\n Opening your browser… sign in with GitHub, then click "Authorize this device".')
console.log(` Didn't get a browser? Open ${verification_uri} and enter: ${user_code}\n`)
openBrowser(verification_uri_complete || verification_uri)
const deadline = Date.now() + (expires_in || 900) * 1000
let wait = (interval || 5) * 1000
for (;;) {
if (Date.now() > deadline) throw new Error('Timed out waiting for authorization. Re-run /portaljs-deploy.')
await sleep(wait)
const poll = await fetch(`${AUTH}/device/token`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ device_code }),
})
if (poll.status === 200) {
const { token } = await poll.json()
const dir = join(homedir(), '.portaljs')
mkdirSync(dir, { recursive: true })
const file = join(dir, 'credentials')
writeFileSync(file, JSON.stringify({ token, api: API }) + '\n', { mode: 0o600 })
chmodSync(file, 0o600)
// Confirm + greet by login name.
let who = ''
try {
const me = await fetch(`${API}/v1/whoami`, { headers: { authorization: `Bearer ${token}` } })
if (me.ok) who = (await me.json()).login
} catch {}
console.log(`✓ Logged in${who ? ` as @${who}` : ''}. Credentials saved to ${file}`)
return
}
if (poll.status === 428) continue // authorization_🌀 AI-native framework for building data portals. Scaffold a full portal from a brief and load datasets in minutes with agentic skills — any backend (CKAN, GitHub, Frictionless).
Repo: datopian/portaljs
Other commands on portaljs.
- /add-chart
Renamed → /portaljs-add-chart. This alias will be removed next minor release.
Open command - /add-dataset
Renamed → /portaljs-add-dataset. This alias will be removed next minor release.
Open command - /add-map
Renamed → /portaljs-add-map. This alias will be removed next minor release.
Open command - /add-resource
Renamed → /portaljs-add-resource. This alias will be removed next minor release.
Open command - /arcgis-to-portaljs
Migrate a whole ArcGIS Hub site (opendata.arcgis.com or a Hub Premium custom domain) into a PortalJS Arc portal end-to-end. Harvests the Hub /data.json (DCAT-US) inventory, exports every FeatureService layer through the ArcGIS REST query API (resultOffset paging), converts each
Open command - /architect
Renamed → /portaljs-architect. This alias will be removed next minor release.
Open command

