Skip to content
Development
Command

/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.

From plugin
portaljs
2.3k25 skills25 commands
Install
> /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.md
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{}")
fi

If `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
Ships withportaljs

🌀 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).

Get the whole plugin