Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 50 additions & 14 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,13 +1,25 @@
name: CI Workflow
name: CI

# Feedback for pull requests and for every push to the mainline. The
# build-and-test job is the one that matters: it is the same set of checks the
# deploy workflow gates on, so a green tick here means the same commit can ship.
# Lint runs alongside but is advisory (see the job) because the tree carries a
# large backlog of pre-existing findings that a feature branch should not have
# to clear before it can merge.

on:
push:
branches: [ "master", "main" ]
branches: ["master", "main"]
pull_request:
branches: [ "master", "main" ]
branches: ["master", "main"]

# A newer push to the same branch makes an in-flight run obsolete.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

jobs:
build-and-check:
build-and-test:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
Expand All @@ -16,21 +28,45 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
cache: 'npm'
# Node 20, not 18: vitest 4 requires ^20 || ^22 || >=24, so the test
# step below will not even start on the old pin.
node-version: "20"
cache: "npm"

- name: Install dependencies
run: npm ci

- name: Type check (Shared)
run: npm run build -w @sui-cli-web/shared

- name: Build (All)
- name: Build (all workspaces)
# This is also the typecheck that counts - tsup and vite fail the build
# on a real type error in shared, server or web.
run: npm run build

# Advisory for now: the repo carries a large pre-existing biome backlog
# (formatting, import order, a11y) unrelated to most PRs. Keep the report
# visible in the logs without failing the build until it's cleaned up.
- name: Lint and Format Code
- name: Test (server - unit + e2e)
# No `sui` binary is installed on the runner on purpose: a present-but-
# unconfigured CLI blocks on an interactive prompt, while an absent one
# fails fast. The e2e suite is written to assert only the shape of the
# "sui missing" state, so it is deterministic here.
run: npm test -w sui-cli-web-server

lint:
runs-on: ubuntu-latest
# Advisory only: the tree has a standing biome backlog, so a red result here
# must not wall off a branch. It stays in the pipeline so new violations are
# visible in the log rather than invisible.
continue-on-error: true
steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"

- name: Install dependencies
run: npm ci

- name: Lint and format check
run: npx @biomejs/biome ci .
continue-on-error: true
39 changes: 37 additions & 2 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,18 @@ name: Deploy to Railway
# building with no error anywhere. This workflow uploads the source itself with
# a project token, which does not depend on the App at all.
#
# The deploy is gated: the `test` job below builds every workspace and runs the
# server suite, and `deploy` declares `needs: test`, so a commit that fails to
# build or fails a test never reaches `railway up`. Keeping the gate as a job in
# THIS workflow - rather than a separate CI workflow chained by workflow_run -
# means it runs on exactly this commit and ref, with no skipped-vs-failed
# ambiguity and no stale-ref race.
#
# Requires a repository secret RAILWAY_TOKEN: a *project* token from
# https://railway.com/account/tokens scoped to sui-cli-web / production.
# Without it the job skips instead of failing, so a fork or a clone without the
# secret does not report a red build for something it was never meant to do.
# Without it the deploy step skips instead of failing, so a fork or a clone
# without the secret does not report a red build for something it was never
# meant to do.

on:
push:
Expand All @@ -22,8 +30,35 @@ concurrency:
cancel-in-progress: true

jobs:
test:
runs-on: ubuntu-latest
if: ${{ github.repository == 'harrymove-ctrl/sui-cli-web' }}
steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
# Must be >= 20 for vitest 4; a bad pin here would skip the very tests
# this gate exists to run and let a broken commit through as "passed".
node-version: "20"
cache: "npm"

- name: Install dependencies
run: npm ci

- name: Build (all workspaces)
run: npm run build

- name: Test (server - unit + e2e)
# No `sui` binary on the runner by design - see ci.yml for why.
run: npm test -w sui-cli-web-server

deploy:
runs-on: ubuntu-latest
# The gate: this is what stops a red build or a failing test from shipping.
needs: test
if: ${{ github.repository == 'harrymove-ctrl/sui-cli-web' }}
steps:
- name: Checkout repository
Expand Down
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ PLAN.md
# Keep README.md and packages/server/README.md for npm
IMPLEMENTATION_PLAN.md
FEATURES.md
docs/
plans/
contracts/community_registry/CONTRACT_INFO.md

Expand Down
5 changes: 3 additions & 2 deletions apps/marketing/.astro/content-modules.mjs
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@

export default new Map([
["src/content/posts/21-day-sui-challenge-day-5.mdx", () => import("astro:content-layer-deferred-module?astro%3Acontent-layer-deferred-module=&fileName=src%2Fcontent%2Fposts%2F21-day-sui-challenge-day-5.mdx&astroContentModuleFlag=true")],
["src/content/posts/21-day-sui-challenge-day-2.mdx", () => import("astro:content-layer-deferred-module?astro%3Acontent-layer-deferred-module=&fileName=src%2Fcontent%2Fposts%2F21-day-sui-challenge-day-2.mdx&astroContentModuleFlag=true")],
["src/content/posts/21-day-sui-challenge-day-4.mdx", () => import("astro:content-layer-deferred-module?astro%3Acontent-layer-deferred-module=&fileName=src%2Fcontent%2Fposts%2F21-day-sui-challenge-day-4.mdx&astroContentModuleFlag=true")],
["src/content/posts/21-day-sui-challenge-day-3.mdx", () => import("astro:content-layer-deferred-module?astro%3Acontent-layer-deferred-module=&fileName=src%2Fcontent%2Fposts%2F21-day-sui-challenge-day-3.mdx&astroContentModuleFlag=true")],
["src/content/posts/21-day-sui-challenge-day-4.mdx", () => import("astro:content-layer-deferred-module?astro%3Acontent-layer-deferred-module=&fileName=src%2Fcontent%2Fposts%2F21-day-sui-challenge-day-4.mdx&astroContentModuleFlag=true")],
["src/content/posts/21-day-sui-challenge-day-5.mdx", () => import("astro:content-layer-deferred-module?astro%3Acontent-layer-deferred-module=&fileName=src%2Fcontent%2Fposts%2F21-day-sui-challenge-day-5.mdx&astroContentModuleFlag=true")],
["src/content/posts/21-day-sui-challenge-is-live.mdx", () => import("astro:content-layer-deferred-module?astro%3Acontent-layer-deferred-module=&fileName=src%2Fcontent%2Fposts%2F21-day-sui-challenge-is-live.mdx&astroContentModuleFlag=true")],
["src/content/posts/getting-started-with-sui-cli-web.mdx", () => import("astro:content-layer-deferred-module?astro%3Acontent-layer-deferred-module=&fileName=src%2Fcontent%2Fposts%2Fgetting-started-with-sui-cli-web.mdx&astroContentModuleFlag=true")],
["src/content/posts/copy-for-ai-and-responsive-objects.mdx", () => import("astro:content-layer-deferred-module?astro%3Acontent-layer-deferred-module=&fileName=src%2Fcontent%2Fposts%2Fcopy-for-ai-and-responsive-objects.mdx&astroContentModuleFlag=true")],
["src/content/posts/introducing-sui-cli-web.mdx", () => import("astro:content-layer-deferred-module?astro%3Acontent-layer-deferred-module=&fileName=src%2Fcontent%2Fposts%2Fintroducing-sui-cli-web.mdx&astroContentModuleFlag=true")]]);

4 changes: 2 additions & 2 deletions apps/marketing/src/components/react/BrandedImage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,15 @@ interface BrandedImageProps {
* - SVG displacement filter for interactive "decay" effect on hover
* - 3D perspective tilt effect (rotateX/rotateY based on mouse position)
* - Smooth mouse-tracking distortion
* - Branded watermark overlay (default: cli.firstmovers.io)
* - Branded watermark overlay (default: sui-cli-web-production.up.railway.app)
* - Works with both online URLs and local images
*/
export function BrandedImage({
src,
alt = 'Image',
width = 400,
height = 300,
watermark = 'cli.firstmovers.io',
watermark = 'sui-cli-web-production.up.railway.app',
className = '',
enableTilt = true,
maxTilt = 15,
Expand Down
2 changes: 1 addition & 1 deletion apps/marketing/src/components/react/TwitterEmbed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ declare global {
export function TwitterEmbed({
tweetUrl,
theme = 'dark',
watermark = 'cli.firstmovers.io',
watermark = 'sui-cli-web-production.up.railway.app',
maxTilt = 12,
}: TwitterEmbedProps) {
const containerRef = useRef<HTMLDivElement>(null);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
---
title: "What's New: Copy for AI Everywhere + a Responsive Object Explorer"
description: "Every page in Sui CLI Web can now hand its state to an AI agent, and the object list fits any screen - table on desktop, compact list on mobile."
publishDate: 2026-07-24
category: wrapup
tags: ["changelog", "sui", "ai", "developer-experience", "responsive", "objects"]
author: "Harry Phan"
sections:
- id: copy-for-ai
title: "Copy for AI, on every page"
- id: responsive-objects
title: "An object explorer that fits your screen"
- id: whats-next
title: "What's next"
---

A short wrap-up of what shipped today. Two changes, both about the same thing: getting out of your way when you want to move from *looking* at on-chain state to *doing* something with it.

## Copy for AI, on every page

Sui CLI Web already turns raw JSON into something you can read. But the moment you want a second opinion - "what is this object?", "explain this PTB", "is this multi-sig set up right?" - you were back to copying fields by hand into a chat window.

Not anymore. A **Copy for AI** button now lives in the header of every major page: objects, coins, transfers, the transaction inspector, gas analysis, key management, Move deploy, security tools, and the rest. Each one understands the page you're on and gives you three ready-made options:

- **Copy prompt** - a natural-language description of exactly what you're looking at, ready to paste.
- **Copy as JSON** - the underlying structured data, capped so a wallet with thousands of objects doesn't blow up your clipboard.
- **Copy page as markdown** - a clean, human-readable rendering.

There are also one-click **Open in ChatGPT** and **Open in Claude** shortcuts that carry the prompt straight into a fresh chat.

The context is tailored per page. On the transaction inspector it hands over the full programmable transaction - commands, arguments, gas budget, and the latest dry-run result - so an agent can actually reason about it. On the object detail view it describes the type, owner, and Display metadata.

One deliberate boundary: on the key-management and address pages, **only public data ever leaves the app** - addresses, public keys, key schemes, multi-sig thresholds. Private keys, mnemonics, and seed phrases are never included in any payload. Everything still runs locally; nothing is sent anywhere until *you* paste it.

## An object explorer that fits your screen

The **My Objects** view is a proper data table - sortable, resizable, reorderable columns that stay smooth even at thousands of objects. But a fixed-width table has a bad habit: on a narrow window it either overflows into a horizontal scroll or leaves a big dead gap where short values don't fill their column.

Two fixes:

- **Fit-to-width** - the Object ID column now stretches to fill whatever space is available, so the table uses the full panel instead of stranding empty pixels on the right. When there genuinely isn't room, it falls back to a horizontal scroll as before.
- **Collapse on small screens** - below a phone-sized width, the multi-column table swaps for a compact one-line-per-object list: type icon, name, a shortened ID, and version. Tap through to the same detail view.

And yes - the object list got its own **Copy for AI** button too, so you can hand your whole filtered set of objects to an agent in one click.

## What's next

More of the same philosophy: less friction between reading state and acting on it. If there's a page where you wish the AI hand-off were smarter, or a view that still feels cramped, that's exactly the kind of feedback that shapes the next update.

Happy building.
2 changes: 1 addition & 1 deletion apps/server/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "sui-cli-web-server",
"version": "1.5.0",
"version": "1.6.0",
"description": "Local server that bridges your browser to Sui CLI. Manage addresses, transfer SUI, deploy Move contracts - all from a beautiful web UI. Private keys never leave your machine.",
"type": "module",
"main": "./dist/index.js",
Expand Down
102 changes: 102 additions & 0 deletions apps/server/src/__tests__/DevstackExecutor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/**
* Tests for the devstack CLI executor.
*
* Two things here are load-bearing and both are easy to get quietly wrong.
* First, envelope parsing: the CLI is invoked through a Node that may print
* npm engine warnings to stdout before the JSON, so a naive JSON.parse of the
* whole stream fails on exactly the machines devstack targets. Second, the
* read-only verb allowlist: this executor is reachable from an unauthenticated
* HTTP server, and `up` boots Docker containers.
*
* Kept in its own file because the service suite mocks this module wholesale.
*/

import { describe, expect, it } from 'vitest';
import { DevstackExecutor, DevstackNotInstalledError } from '../cli/DevstackExecutor';

const executor = DevstackExecutor.getInstance();

/** parseEnvelope is private; call it the way the class does internally. */
const parse = (stdout: string) =>
(executor as unknown as { parseEnvelope: (s: string) => unknown }).parseEnvelope(stdout);

describe('DevstackExecutor', () => {
describe('envelope parsing', () => {
it('reads a clean envelope', () => {
const out =
'{"schemaVersion":1,"ok":true,"command":"status","elapsedMs":3,"data":{"present":false}}';

expect(parse(out)).toEqual({
schemaVersion: 1,
ok: true,
command: 'status',
elapsedMs: 3,
data: { present: false },
});
});

it('ignores npm engine warnings printed before the envelope', () => {
// Verbatim shape of what npm prints when running devstack under a Node
// its transitive deps dislike - observed on Node 25 with ini@7.
const noisy = [
'npm warn EBADENGINE Unsupported engine {',
"npm warn EBADENGINE package: 'ini@7.0.0',",
'npm warn EBADENGINE }',
'{"schemaVersion":1,"ok":true,"command":"doctor","elapsedMs":317,"data":{"reports":[]}}',
].join('\n');

expect(parse(noisy)).toMatchObject({ ok: true, command: 'doctor' });
});

it('keeps ok:false envelopes rather than treating them as failures to parse', () => {
const failed = '{"schemaVersion":1,"ok":false,"command":"config","elapsedMs":1,"data":null}';

expect(parse(failed)).toMatchObject({ ok: false, command: 'config' });
});

it('defaults a missing schemaVersion instead of rejecting the envelope', () => {
expect(parse('{"ok":true,"command":"status","data":{}}')).toMatchObject({
schemaVersion: 1,
elapsedMs: 0,
});
});

it('rejects output that is not JSON at all', () => {
expect(() => parse('devstack: command not found')).toThrow(/no JSON output/);
});

it('rejects malformed JSON', () => {
expect(() => parse('{"ok":true,')).toThrow(/not valid JSON/);
});

it('rejects JSON that is not a devstack envelope', () => {
// A different tool answering on the same name would otherwise be trusted.
expect(() => parse('{"hello":"world"}')).toThrow(/not a recognised envelope/);
});
});

describe('verb allowlist', () => {
it('refuses a mutating verb even when it is forced past the type', async () => {
// `up` requires Docker and starts containers; an unauthenticated POST
// must never reach it.
await expect(executor.run('up' as never, [], { cwd: process.cwd() })).rejects.toThrow(
/non-read-only verb/
);
});

it('checks the allowlist before it checks for the binary', async () => {
// Order matters: if absence were checked first, the refusal would leak
// as "not installed" and look like a fixable environment problem.
await expect(
executor.run('wipe' as never, [], { cwd: '/nonexistent-project-dir' })
).rejects.not.toBeInstanceOf(DevstackNotInstalledError);
});
});

describe('binary resolution', () => {
it('reports absence rather than throwing', () => {
expect(executor.resolveBinary('/nonexistent-project-dir')).toBeNull();
expect(executor.isInstalled('/nonexistent-project-dir')).toBe(false);
});
});
});
Loading
Loading