diff --git a/.gitignore b/.gitignore index 4ad0b6e..953ac4a 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,8 @@ node_modules # Build outputs (except shared package which is needed for workspace imports) dist !packages/shared/dist +.astro/ +coverage/ # Logs npm-debug.log* diff --git a/README.md b/README.md index 923917a..f30c7c0 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ back. ![npm](https://img.shields.io/npm/v/sui-cli-web-server) ![Node](https://img.shields.io/badge/node-%3E%3D18.0.0-green) -**Live**: https://sui-cli-web-production.up.railway.app · **npm**: `sui-cli-web-server` +**Live**: https://sui-cli.dev · **npm**: `sui-cli-web-server` ## What you get @@ -88,7 +88,7 @@ Three pieces, one of which you already have: ``` ┌──────────────────────────────────────────────────────────────┐ │ Browser │ -│ https://sui-cli-web-production.up.railway.app │ +│ https://sui-cli.dev │ └──────────────────────────────────────────────────────────────┘ │ │ HTTP to http://localhost: @@ -135,7 +135,7 @@ npx sui-cli-web-server > called `sui-cli-web` while the package is `sui-cli-web-server` is what makes > this easy to get wrong; `npx` resolves the package name, not the binary. -Then open **https://sui-cli-web-production.up.railway.app** — it finds your +Then open **https://sui-cli.dev** — it finds your server automatically. > The UI discovers the server by scanning ports 3001–3005, 4001, 4002, 8001 @@ -177,10 +177,11 @@ so the blog routes do not exist in the deployment. ## Deployment -The hosted app runs on Railway from the repository root `Dockerfile` -(`railway.json` sets `builder: DOCKERFILE`). The build compiles every -workspace, then copies the marketing changelog output into the client's -`dist/blog`, and the server serves the whole thing. +The hosted app runs on Coolify from the repository root `Dockerfile` — +Coolify's own GitHub integration builds and deploys it on every push to +`master`, no separate CI deploy step. The build compiles every workspace, +then copies the marketing changelog output into the client's `dist/blog`, +and the server serves the whole thing. Two things worth knowing before changing it: @@ -217,7 +218,7 @@ lsof -ti:3001 | xargs kill -9 # macOS/Linux expose it without putting authentication in front of it first. - Private keys stay in your local Sui CLI config. Export is confirmation-gated and is the only path that returns a raw key. -- Everything is open source — [read it](https://github.com/hien-p/raycast-sui-cli). +- Everything is open source — [read it](https://github.com/CommandOSSLabs/sui-cli-web). ## Documentation @@ -225,7 +226,7 @@ lsof -ti:3001 | xargs kill -9 # macOS/Linux |---|---| | [Server README](apps/server/README.md) | The npm package: install, endpoints, configuration | | [MCP Server](packages/mcp/README.md) | Read-only MCP server for AI agents | -| [Changelog](https://sui-cli-web-production.up.railway.app/changelog) | What shipped, by date | +| [Changelog](https://sui-cli.dev/changelog) | What shipped, by date | Architecture notes (backend, dashboard, object inspector, MCP design, backlog) live in `docs/`, which is currently **gitignored** — they exist on a checkout @@ -244,7 +245,7 @@ MIT. ## Links -- [Live app](https://sui-cli-web-production.up.railway.app) +- [Live app](https://sui-cli.dev) - [npm package](https://www.npmjs.com/package/sui-cli-web-server) -- [GitHub](https://github.com/hien-p/raycast-sui-cli) +- [GitHub](https://github.com/CommandOSSLabs/sui-cli-web) - [Sui documentation](https://docs.sui.io) diff --git a/apps/marketing/.astro/collections/posts.schema.json b/apps/marketing/.astro/collections/posts.schema.json deleted file mode 100644 index 1433d8d..0000000 --- a/apps/marketing/.astro/collections/posts.schema.json +++ /dev/null @@ -1,109 +0,0 @@ -{ - "$ref": "#/definitions/posts", - "definitions": { - "posts": { - "type": "object", - "properties": { - "title": { - "type": "string" - }, - "description": { - "type": "string", - "maxLength": 160 - }, - "publishDate": { - "anyOf": [ - { - "type": "string", - "format": "date-time" - }, - { - "type": "string", - "format": "date" - }, - { - "type": "integer", - "format": "unix-time" - } - ] - }, - "updatedDate": { - "anyOf": [ - { - "type": "string", - "format": "date-time" - }, - { - "type": "string", - "format": "date" - }, - { - "type": "integer", - "format": "unix-time" - } - ] - }, - "category": { - "type": "string", - "enum": [ - "announcement", - "tutorial", - "wrapup", - "guide" - ] - }, - "tags": { - "type": "array", - "items": { - "type": "string" - }, - "default": [] - }, - "author": { - "type": "string", - "default": "Harry Phan" - }, - "heroImage": { - "type": "string" - }, - "youtubeId": { - "type": "string" - }, - "draft": { - "type": "boolean", - "default": false - }, - "sections": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "title": { - "type": "string" - } - }, - "required": [ - "id", - "title" - ], - "additionalProperties": false - } - }, - "$schema": { - "type": "string" - } - }, - "required": [ - "title", - "description", - "publishDate", - "category" - ], - "additionalProperties": false - } - }, - "$schema": "http://json-schema.org/draft-07/schema#" -} \ No newline at end of file diff --git a/apps/marketing/.astro/content-assets.mjs b/apps/marketing/.astro/content-assets.mjs deleted file mode 100644 index 2b8b823..0000000 --- a/apps/marketing/.astro/content-assets.mjs +++ /dev/null @@ -1 +0,0 @@ -export default new Map(); \ No newline at end of file diff --git a/apps/marketing/.astro/content-modules.mjs b/apps/marketing/.astro/content-modules.mjs deleted file mode 100644 index ac12a5b..0000000 --- a/apps/marketing/.astro/content-modules.mjs +++ /dev/null @@ -1,11 +0,0 @@ - -export default new Map([ -["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-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")]]); - \ No newline at end of file diff --git a/apps/marketing/.astro/content.d.ts b/apps/marketing/.astro/content.d.ts deleted file mode 100644 index c75eee8..0000000 --- a/apps/marketing/.astro/content.d.ts +++ /dev/null @@ -1,220 +0,0 @@ -declare module 'astro:content' { - interface Render { - '.mdx': Promise<{ - Content: import('astro').MDXContent; - headings: import('astro').MarkdownHeading[]; - remarkPluginFrontmatter: Record; - components: import('astro').MDXInstance<{}>['components']; - }>; - } -} - -declare module 'astro:content' { - export interface RenderResult { - Content: import('astro/runtime/server/index.js').AstroComponentFactory; - headings: import('astro').MarkdownHeading[]; - remarkPluginFrontmatter: Record; - } - interface Render { - '.md': Promise; - } - - export interface RenderedContent { - html: string; - metadata?: { - imagePaths: Array; - [key: string]: unknown; - }; - } -} - -declare module 'astro:content' { - type Flatten = T extends { [K: string]: infer U } ? U : never; - - export type CollectionKey = keyof AnyEntryMap; - export type CollectionEntry = Flatten; - - export type ContentCollectionKey = keyof ContentEntryMap; - export type DataCollectionKey = keyof DataEntryMap; - - type AllValuesOf = T extends any ? T[keyof T] : never; - type ValidContentEntrySlug = AllValuesOf< - ContentEntryMap[C] - >['slug']; - - export type ReferenceDataEntry< - C extends CollectionKey, - E extends keyof DataEntryMap[C] = string, - > = { - collection: C; - id: E; - }; - export type ReferenceContentEntry< - C extends keyof ContentEntryMap, - E extends ValidContentEntrySlug | (string & {}) = string, - > = { - collection: C; - slug: E; - }; - export type ReferenceLiveEntry = { - collection: C; - id: string; - }; - - /** @deprecated Use `getEntry` instead. */ - export function getEntryBySlug< - C extends keyof ContentEntryMap, - E extends ValidContentEntrySlug | (string & {}), - >( - collection: C, - // Note that this has to accept a regular string too, for SSR - entrySlug: E, - ): E extends ValidContentEntrySlug - ? Promise> - : Promise | undefined>; - - /** @deprecated Use `getEntry` instead. */ - export function getDataEntryById( - collection: C, - entryId: E, - ): Promise>; - - export function getCollection>( - collection: C, - filter?: (entry: CollectionEntry) => entry is E, - ): Promise; - export function getCollection( - collection: C, - filter?: (entry: CollectionEntry) => unknown, - ): Promise[]>; - - export function getLiveCollection( - collection: C, - filter?: LiveLoaderCollectionFilterType, - ): Promise< - import('astro').LiveDataCollectionResult, LiveLoaderErrorType> - >; - - export function getEntry< - C extends keyof ContentEntryMap, - E extends ValidContentEntrySlug | (string & {}), - >( - entry: ReferenceContentEntry, - ): E extends ValidContentEntrySlug - ? Promise> - : Promise | undefined>; - export function getEntry< - C extends keyof DataEntryMap, - E extends keyof DataEntryMap[C] | (string & {}), - >( - entry: ReferenceDataEntry, - ): E extends keyof DataEntryMap[C] - ? Promise - : Promise | undefined>; - export function getEntry< - C extends keyof ContentEntryMap, - E extends ValidContentEntrySlug | (string & {}), - >( - collection: C, - slug: E, - ): E extends ValidContentEntrySlug - ? Promise> - : Promise | undefined>; - export function getEntry< - C extends keyof DataEntryMap, - E extends keyof DataEntryMap[C] | (string & {}), - >( - collection: C, - id: E, - ): E extends keyof DataEntryMap[C] - ? string extends keyof DataEntryMap[C] - ? Promise | undefined - : Promise - : Promise | undefined>; - export function getLiveEntry( - collection: C, - filter: string | LiveLoaderEntryFilterType, - ): Promise, LiveLoaderErrorType>>; - - /** Resolve an array of entry references from the same collection */ - export function getEntries( - entries: ReferenceContentEntry>[], - ): Promise[]>; - export function getEntries( - entries: ReferenceDataEntry[], - ): Promise[]>; - - export function render( - entry: AnyEntryMap[C][string], - ): Promise; - - export function reference( - collection: C, - ): import('astro/zod').ZodEffects< - import('astro/zod').ZodString, - C extends keyof ContentEntryMap - ? ReferenceContentEntry> - : ReferenceDataEntry - >; - // Allow generic `string` to avoid excessive type errors in the config - // if `dev` is not running to update as you edit. - // Invalid collection names will be caught at build time. - export function reference( - collection: C, - ): import('astro/zod').ZodEffects; - - type ReturnTypeOrOriginal = T extends (...args: any[]) => infer R ? R : T; - type InferEntrySchema = import('astro/zod').infer< - ReturnTypeOrOriginal['schema']> - >; - - type ContentEntryMap = { - - }; - - type DataEntryMap = { - "posts": Record; - rendered?: RenderedContent; - filePath?: string; -}>; - - }; - - type AnyEntryMap = ContentEntryMap & DataEntryMap; - - type ExtractLoaderTypes = T extends import('astro/loaders').LiveLoader< - infer TData, - infer TEntryFilter, - infer TCollectionFilter, - infer TError - > - ? { data: TData; entryFilter: TEntryFilter; collectionFilter: TCollectionFilter; error: TError } - : { data: never; entryFilter: never; collectionFilter: never; error: never }; - type ExtractDataType = ExtractLoaderTypes['data']; - type ExtractEntryFilterType = ExtractLoaderTypes['entryFilter']; - type ExtractCollectionFilterType = ExtractLoaderTypes['collectionFilter']; - type ExtractErrorType = ExtractLoaderTypes['error']; - - type LiveLoaderDataType = - LiveContentConfig['collections'][C]['schema'] extends undefined - ? ExtractDataType - : import('astro/zod').infer< - Exclude - >; - type LiveLoaderEntryFilterType = - ExtractEntryFilterType; - type LiveLoaderCollectionFilterType = - ExtractCollectionFilterType; - type LiveLoaderErrorType = ExtractErrorType< - LiveContentConfig['collections'][C]['loader'] - >; - - export type ContentConfig = typeof import("../src/content/config.js"); - export type LiveContentConfig = never; -} diff --git a/apps/marketing/.astro/data-store.json b/apps/marketing/.astro/data-store.json deleted file mode 100644 index 063aeae..0000000 --- a/apps/marketing/.astro/data-store.json +++ /dev/null @@ -1 +0,0 @@ -[["Map",1,2,9,10],"meta::meta",["Map",3,4,5,6,7,8],"astro-version","5.16.6","content-config-digest","a48a46924400b102","astro-config-digest","{\"root\":{},\"srcDir\":{},\"publicDir\":{},\"outDir\":{},\"cacheDir\":{},\"site\":\"https://cli.firstmovers.io\",\"compressHTML\":true,\"base\":\"/blog\",\"trailingSlash\":\"ignore\",\"output\":\"static\",\"scopedStyleStrategy\":\"attribute\",\"build\":{\"format\":\"directory\",\"client\":{},\"server\":{},\"assets\":\"_astro\",\"serverEntry\":\"entry.mjs\",\"redirects\":true,\"inlineStylesheets\":\"auto\",\"concurrency\":1},\"server\":{\"open\":false,\"host\":false,\"port\":4321,\"streaming\":true,\"allowedHosts\":[]},\"redirects\":{},\"image\":{\"endpoint\":{\"route\":\"/_image\"},\"service\":{\"entrypoint\":\"astro/assets/services/sharp\",\"config\":{}},\"domains\":[],\"remotePatterns\":[],\"responsiveStyles\":false},\"devToolbar\":{\"enabled\":true},\"markdown\":{\"syntaxHighlight\":{\"type\":\"shiki\",\"excludeLangs\":[\"math\"]},\"shikiConfig\":{\"langs\":[],\"langAlias\":{},\"theme\":\"github-dark\",\"themes\":{},\"wrap\":false,\"transformers\":[]},\"remarkPlugins\":[],\"rehypePlugins\":[],\"remarkRehype\":{},\"gfm\":true,\"smartypants\":true},\"security\":{\"checkOrigin\":true,\"allowedDomains\":[]},\"env\":{\"schema\":{},\"validateSecrets\":false},\"experimental\":{\"clientPrerender\":false,\"contentIntellisense\":false,\"headingIdCompat\":false,\"preserveScriptOrder\":false,\"liveContentCollections\":false,\"csp\":false,\"staticImportMetaEnv\":false,\"chromeDevtoolsWorkspace\":false,\"failOnPrerenderConflict\":false,\"svgo\":false},\"legacy\":{\"collections\":false}}","posts",["Map",11,12,47,48,66,67,102,103,138,139,172,173],"getting-started-with-sui-cli-web",{"id":11,"data":13,"body":43,"filePath":44,"digest":45,"legacyId":46,"deferredRender":26},{"title":14,"description":15,"publishDate":16,"category":17,"tags":18,"author":25,"draft":26,"sections":27},"Getting Started: Deploy Your First Move Contract in 5 Minutes","Complete beginner's guide to Sui CLI Web. Learn to manage wallets, transfer SUI tokens, and deploy Move smart contracts from your browser.",["Date","2024-12-17T00:00:00.000Z"],"tutorial",[19,17,20,21,22,23,24],"beginner","wallet","quickstart","move-contracts","sui-deployment","first-movers","Harry Phan",true,[28,31,34,37,40],{"id":29,"title":30},"prerequisites","Prerequisites",{"id":32,"title":33},"installation","Installation",{"id":35,"title":36},"first-steps","First Steps",{"id":38,"title":39},"sending-tokens","Sending Tokens",{"id":41,"title":42},"keyboard-shortcuts","Keyboard Shortcuts","import { AccordionCTA } from '../../components/react/BouncyAccordion';\nimport { RevealOnScroll } from '../../components/react/TextReveal';\n\nWelcome to Sui CLI Web! This tutorial will get you from zero to hero in just 5 minutes.\n\n## Prerequisites\n\nBefore we start, make sure you have:\n\n- **Node.js 18+** installed ([download here](https://nodejs.org/))\n- **Sui CLI** installed ([installation guide](https://docs.sui.io/build/install))\n- A modern browser (Chrome, Firefox, Safari, or Edge)\n\nDon't have Sui CLI yet? Run this:\n\n```bash\n# macOS/Linux\ncurl -fsSL https://get.sui.io | sh\n\n# or with npm\nnpm install -g @mysten/sui\n```\n\n## Installation\n\nInstalling Sui CLI Web Server takes one command:\n\n```bash\nnpm install -g sui-cli-web-server\n```\n\nNow start the server:\n\n```bash\nsui-cli-web-server\n```\n\nYou should see:\n\n```\n🚀 Sui CLI Web Server v1.5.0\n📡 Server running at http://localhost:3001\n🔐 Security: localhost only (your keys are safe)\n✅ Ready to connect!\n```\n\n\u003CRevealOnScroll client:visible>\n## First Steps\n\n### Opening the App\n\nNavigate to [cli.firstmovers.io](https://cli.firstmovers.io) in your browser. The app will automatically detect your local server.\n\n> **Tip**: If you see \"Connecting...\", make sure your server is running!\n\n### Creating Your First Wallet\n\n1. Press `Cmd+K` (or `Ctrl+K` on Windows) to open the command palette\n2. Type \"create wallet\" and press Enter\n3. Choose a name for your wallet (e.g., \"dev-wallet\")\n4. Done! Your new wallet is ready to use\n\n### Getting Testnet Tokens\n\n1. Make sure you're on testnet (check the network indicator in the top right)\n2. Open command palette (`Cmd+K`)\n3. Type \"faucet\" and press Enter\n4. Wait a few seconds for tokens to arrive\n\nYour balance will update automatically!\n\u003C/RevealOnScroll>\n\n## Sending Tokens\n\nLet's send some SUI:\n\n### Method 1: Command Palette\n\n```\n1. Press Cmd+K\n2. Type \"send\" or \"transfer\"\n3. Enter recipient address\n4. Enter amount\n5. Confirm and sign\n```\n\n### Method 2: Direct Navigation\n\n```\n1. Press Cmd+T to open Transfer panel\n2. Select token (SUI)\n3. Paste recipient address\n4. Enter amount\n5. Click \"Send\" or press Enter\n```\n\n### Understanding Gas Fees\n\nSui uses a unique gas model:\n- Gas is paid in SUI tokens\n- Transactions typically cost 0.001-0.01 SUI\n- Failed transactions still consume gas\n\n\u003CAccordionCTA\n client:visible\n icon=\"⛽\"\n title=\"Learn about Gas Management\"\n description=\"Advanced gas management features like coin splitting and merging can help optimize your transaction costs.\"\n link=\"https://cli.firstmovers.io\"\n linkText=\"Open Gas Station\"\n/>\n\n## Keyboard Shortcuts\n\nMaster these shortcuts to navigate like a pro:\n\n| Shortcut | Action |\n|----------|--------|\n| `Cmd+K` | Open command palette |\n| `Cmd+1` | Go to Dashboard |\n| `Cmd+2` | Go to Wallet |\n| `Cmd+3` | Go to Objects |\n| `Cmd+T` | Quick Transfer |\n| `Cmd+N` | Switch Network |\n| `Esc` | Close modal/panel |\n| `↑/↓` | Navigate lists |\n| `Enter` | Select/Confirm |\n\n### Pro Tips\n\n1. **Quick Address Copy**: Click any address to copy it\n2. **Search Everything**: The command palette searches across all features\n3. **Network Switching**: `Cmd+N` lets you quickly switch between mainnet, testnet, and devnet\n\n## Next Steps\n\nNow that you're set up, explore these features:\n\n- **[Object Explorer](/blog/category/tutorial/)** — Browse your NFTs and owned objects\n- **[Gas Station](https://cli.firstmovers.io/app)** — Manage your gas coins efficiently\n- **[Why We Built This](/blog/posts/introducing-sui-cli-web/)** — The philosophy behind Sui CLI Web\n\n---\n\n## Troubleshooting\n\n### \"Cannot connect to server\"\n\n1. Make sure the server is running (`sui-cli-web-server`)\n2. Check if port 3001 is available\n3. Try restarting the server\n\n### \"Transaction failed\"\n\n1. Check your SUI balance (need gas!)\n2. Verify the recipient address is valid\n3. Make sure you're on the right network\n\n### Still stuck?\n\n- **[Read our FAQ](/blog/category/guide/)** — Common questions answered\n- **[Join the Community](https://cli.firstmovers.io/app/membership)** — Get help from other First Movers\n- **[Reach out on X](https://x.com/harry_phan06)** — DM me for support\n\n---\n\nHappy building! Check out our other tutorials:\n- [Advanced Gas Management](/blog/posts/advanced-gas-management)\n- [Working with Objects](/blog/posts/working-with-objects)\n- [Smart Contract Deployment](/blog/posts/smart-contract-deployment)","src/content/posts/getting-started-with-sui-cli-web.mdx","9fc66c323bb157b0","getting-started-with-sui-cli-web.mdx","introducing-sui-cli-web",{"id":47,"data":49,"body":62,"filePath":63,"digest":64,"legacyId":65,"deferredRender":26},{"title":50,"description":51,"publishDate":52,"category":53,"tags":54,"author":25,"heroImage":60,"draft":61},"Why I Built Sui CLI Web: The Fastest Tool for Sui First Movers","Discover how Sui CLI Web became the go-to development tool for Sui blockchain. Transfer tokens, deploy Move contracts, manage gas - all from your browser.",["Date","2024-12-19T00:00:00.000Z"],"announcement",[55,56,57,58,59,24,22],"launch","sui","cli","web3","developer-experience","/blog/images/posts/introducing-sui-cli-web/hero.png",false,"import { RevealOnScroll } from '../../components/react/TextReveal';\nimport { BrandedImage } from '../../components/react/BrandedImage';\nimport { ImagePreview } from '../../components/react/ImagePreview';\n\n## The Problem\n\nEvery Sui developer knows this workflow: copy an address from the terminal, paste it into another command, make a typo, start over. Switch networks, forget which one you're on, send to the wrong place. Deploy a contract, stare at a wall of JSON, hunt for your package ID.\n\n\u003Cfigure className=\"my-8\">\n \u003CBrandedImage\n client:visible\n src=\"/blog/images/posts/introducing-sui-cli-web/problem_cli.png\"\n alt=\"The typical Sui developer workflow - terminal with long hashes, multiple windows, constant context switching\"\n width={800}\n height={450}\n watermark=\"cli.firstmovers.io\"\n />\n \u003Cfigcaption className=\"text-center text-white/50 text-sm mt-3\">\n The reality: juggling terminals, copying hashes, and hoping you didn't make a typo\n \u003C/figcaption>\n\u003C/figure>\n\nThe Sui CLI is powerful. But \u003Cspan className=\"text-primary font-semibold\">powerful doesn't mean pleasant\u003C/span>.\n\nI was spending more time managing the tooling than actually building.\n\n\u003CRevealOnScroll client:visible>\n## The First Attempt: Raycast Extension\n\nMy first solution was a [Raycast](https://raycast.com) extension. If you're on macOS, Raycast is like Spotlight on steroids: a command palette for everything.\n\nI wrapped common Sui CLI commands into Raycast actions: check balances, transfer tokens, switch networks, inspect objects. All accessible with `⌘K`.\n\n\u003CImagePreview\n client:visible\n src=\"/blog/images/posts/introducing-sui-cli-web/raycast_cli.png\"\n alt=\"Sui CLI Raycast Extension\"\n caption=\"The Raycast extension. Fast, but macOS only.\"\n/>\n\nIt worked great. For me. On my Mac.\n\nBut when I wanted to share it with my team:\n\n> \"Cool! How do I use it?\"\n>\n> \"Install Raycast, then add my extension...\"\n>\n> \"I'm on Windows.\"\n\n\u003Cspan className=\"text-[#ef4444]\">A tool locked to one platform isn't really shareable.\u003C/span>\n\u003C/RevealOnScroll>\n\n\u003CRevealOnScroll client:visible>\n## Second Attempt: Desktop App\n\nNext, I tried building a cross-platform desktop app with Tauri. Same functionality, but packaged as a native application that runs on macOS, Windows, and Linux.\n\n\u003CImagePreview\n client:visible\n src=\"/blog/images/posts/introducing-sui-cli-web/tauri.png\"\n alt=\"Sui CLI Desktop App built with Tauri\"\n caption=\"The Tauri desktop app. Cross-platform, but still requires installation.\"\n/>\n\nIt worked. But sharing still meant: \"Download this installer, run it, trust this unsigned app, allow network access...\"\n\n\u003Cspan className=\"text-[#ef4444]\">Too much friction.\u003C/span>\n\u003C/RevealOnScroll>\n\n\u003CRevealOnScroll client:visible>\n## The Final Answer: Web\n\nWhat if there was nothing to install at all?\n\nA web app that talks to a local server. The UI runs in your browser. The server runs on your machine and executes CLI commands. Share a URL, and anyone can use it immediately.\n\nThe architecture was simple: **a web UI that talks to a local bridge server**. The bridge server runs on your machine and executes Sui CLI commands. Your keys never leave your computer.\n\n```\nBrowser (any platform) → localhost:3001 → Your Sui CLI\n ↑ ↑ ↑\n hosted UI runs locally your keys stay here\n```\n\nSetup takes one command:\n\n\u003CImagePreview\n client:visible\n src=\"/blog/images/posts/introducing-sui-cli-web/setup.png\"\n alt=\"Sui CLI Web Server setup - one command installation\"\n caption=\"One command. That's it. Your existing Sui CLI config is automatically detected.\"\n/>\n\nThree weeks of building later, I had something unexpected:\n\n\u003Cspan className=\"text-[#22c55e] font-semibold text-lg\">The web version was faster than the terminal.\u003C/span>\n\n\u003CImagePreview\n client:visible\n src=\"/blog/images/posts/introducing-sui-cli-web/first_version.png\"\n alt=\"First version of Sui CLI Web\"\n caption=\"The first working version. Keyboard-driven with visual feedback.\"\n/>\n\nNot because it did less. It did the same things. But keyboard shortcuts eliminated repetitive typing. `⌘K` to open any command. `Tab` to autocomplete. Visual feedback caught errors before I submitted transactions.\n\nThe same workflow patterns from Raycast, now accessible to everyone.\n\u003C/RevealOnScroll>\n\n\u003CRevealOnScroll client:visible>\n## But Is It Safe?\n\nHere's the problem with most web-based crypto tools: \u003Cspan className=\"text-[#ef4444]\">they want your private keys.\u003C/span>\n\nEvery browser wallet, every web app that can sign transactions: they all need access to your keys. That's a massive attack surface. One compromised website, one malicious extension, and your funds are gone.\n\nI refused to build another key-custody tool. But how do you build a web app that can sign transactions without holding keys?\n\nThe answer came from an unexpected place: **the architecture of development tools like VS Code's Live Share.**\n\n```\n┌─────────────────────────────────────────────────────────┐\n│ YOUR BROWSER │\n│ (hosted on cli.firstmovers.io) │\n│ │ │\n│ HTTP requests │\n│ ▼ │\n├─────────────────────────────────────────────────────────┤\n│ localhost:3001 │\n│ (runs on YOUR machine only) │\n│ │ │\n│ CLI commands │\n│ ▼ │\n├─────────────────────────────────────────────────────────┤\n│ YOUR SUI CLI │\n│ (your keys, your config) │\n└─────────────────────────────────────────────────────────┘\n```\n\nThe web UI is just a pretty face. All the real work happens on your machine through a tiny bridge server. \u003Cspan className=\"text-[#22c55e] font-semibold\">Your private keys never touch the internet.\u003C/span> They never leave your computer. The hosted web app literally cannot access them.\n\n\u003Cdiv className=\"p-4 rounded-xl bg-[#22c55e]/10 border border-[#22c55e]/30 my-6\">\n \u003Cp className=\"text-white/90 font-medium\">The best of both worlds: convenience of a web interface + security of local key storage.\u003C/p>\n\u003C/div>\n\u003C/RevealOnScroll>\n\n## Real Workflows, Real Examples\n\nEnough theory. Let me show you what using Sui CLI Web actually looks like.\n\n### Example 1: Checking Balances Across Networks\n\n**The old way (terminal):**\n```bash\nsui client switch --env testnet\nsui client gas\n# Copy output, parse mentally\nsui client switch --env mainnet\nsui client gas\n# Try to remember testnet numbers\n```\n\n**The Sui CLI Web way:**\nPress `Cmd+K`, type \"balance\". See all your addresses, all networks, all tokens in one view. Click to switch. No commands to remember.\n\n\u003CImagePreview\n client:visible\n src=\"/blog/images/posts/introducing-sui-cli-web/check_balance.png\"\n alt=\"Checking balances in Sui CLI Web\"\n caption=\"All wallets, all networks, one view.\"\n/>\n\n### Example 2: Transferring SUI Tokens\n\n**The old way:**\n```bash\n# First, find recipient address (where did I put it?)\n# Check balance to pick a coin\nsui client gas\n# Copy coin object ID\nsui client transfer-sui --to 0x1234...abcd --sui-coin-object-id 0xabc...123 --gas-budget 10000000\n# Hope you didn't typo anything\n```\n\n**The Sui CLI Web way:**\n`Cmd+K` → \"transfer\" → Select recipient from address book → Pick coin visually → Confirm. Done in 5 seconds with zero chance of address typos.\n\n\u003CImagePreview\n client:visible\n src=\"/blog/images/posts/introducing-sui-cli-web/transfer_tokens.png\"\n alt=\"Transferring SUI tokens in Sui CLI Web\"\n caption=\"Visual token selection. No more copying object IDs.\"\n/>\n\n### Example 3: Deploying a Move Contract\n\n**The old way:**\n```bash\ncd my-project\nsui move build\n# Scroll through warnings\nsui client publish --gas-budget 100000000\n# Wait... did it work? Parse the JSON output\n# Try to find the package ID in the wall of text\n```\n\n**The Sui CLI Web way:**\nOpen Move tab → Select project folder → Click Build → Review results visually → Click Deploy → See your package ID highlighted, ready to copy.\n\n\u003CImagePreview\n client:visible\n src=\"/blog/images/posts/introducing-sui-cli-web/deploy_contract.png\"\n alt=\"Deploying Move contract in Sui CLI Web\"\n caption=\"Build, deploy, copy package ID. All visual.\"\n/>\n\n\u003Cdiv className=\"p-6 rounded-xl bg-gradient-to-r from-primary/10 to-transparent border border-primary/20 my-8\">\n \u003Cp className=\"text-white/90 text-lg font-medium mb-2\">The Difference\u003C/p>\n \u003Cp className=\"text-white/60\">It's not about doing things the CLI can't do. It's about doing the same things faster, with fewer mistakes, and without breaking your flow.\u003C/p>\n\u003C/div>\n\n## What's Next\n\nThe core covers about \u003Cspan className=\"text-primary font-semibold\">95% of Sui CLI commands\u003C/span>: address management, token transfers, gas splitting/merging, Move development, PTB builder, transaction inspection, key management, dynamic fields, security verification. Most daily workflows are already here.\n\nBut there's still room to improve.\n\n\u003Cdiv className=\"space-y-4 my-6 not-prose\">\n \u003Cdiv className=\"p-4 rounded-xl bg-white/5 border border-white/10 border-l-4 border-l-primary\">\n \u003Ch4 className=\"text-white font-semibold mb-1\">Transaction history\u003C/h4>\n \u003Cp className=\"text-white/60 text-sm\">Browse recent activity, filter by type, search by hash. The data exists on-chain. It just needs a proper interface.\u003C/p>\n \u003C/div>\n\n \u003Cdiv className=\"p-4 rounded-xl bg-white/5 border border-white/10 border-l-4 border-l-[#22c55e]\">\n \u003Ch4 className=\"text-white font-semibold mb-1\">NFT gallery\u003C/h4>\n \u003Cp className=\"text-white/60 text-sm\">See your NFTs as images instead of object IDs. Grid view, collection grouping, quick transfer actions.\u003C/p>\n \u003C/div>\n\n \u003Cdiv className=\"p-4 rounded-xl bg-white/5 border border-white/10 border-l-4 border-l-[#eab308]\">\n \u003Ch4 className=\"text-white font-semibold mb-1\">Better onboarding\u003C/h4>\n \u003Cp className=\"text-white/60 text-sm\">Installation guides for Windows, Linux, macOS. Video walkthroughs for common tasks.\u003C/p>\n \u003C/div>\n\u003C/div>\n\nThe development philosophy stays simple: ship features that solve real problems. No bloat. Every addition has to earn its place.\n\n\u003Cdiv className=\"p-6 rounded-xl bg-gradient-to-r from-primary/20 to-transparent border border-primary/30 my-8\">\n \u003Cp className=\"text-white text-lg font-medium italic\">\"The best features come from real pain points.\"\u003C/p>\n \u003Cp className=\"text-white/60 text-sm mt-2\">If you're using the tool and hit friction, I want to know.\u003C/p>\n\u003C/div>\n\n---\n\n## Try It\n\n```bash\nnpx sui-cli-web-server\n```\n\nThen open [cli.firstmovers.io](https://cli.firstmovers.io). Your existing Sui CLI config works automatically.\n\n\u003Cdiv className=\"grid md:grid-cols-2 gap-4 my-8 not-prose\">\n \u003Ca href=\"https://cli.firstmovers.io\" target=\"_blank\" rel=\"noopener noreferrer\" className=\"block p-5 rounded-xl bg-white/5 border border-white/10 hover:border-primary/50 transition-colors\">\n \u003Ch4 className=\"text-white font-semibold mb-2\">Launch the App\u003C/h4>\n \u003Cp className=\"text-white/60 text-sm\">Jump straight in. The app will guide you through server setup if needed.\u003C/p>\n \u003C/a>\n\n \u003Ca href=\"https://x.com/harry_phan06\" target=\"_blank\" rel=\"noopener noreferrer\" className=\"block p-5 rounded-xl bg-white/5 border border-white/10 hover:border-primary/50 transition-colors\">\n \u003Ch4 className=\"text-white font-semibold mb-2\">Follow Updates\u003C/h4>\n \u003Cp className=\"text-white/60 text-sm\">Feature announcements and ecosystem insights on X.\u003C/p>\n \u003C/a>\n\u003C/div>\n\n---\n\nQuestions or feedback? Find me on [X](https://x.com/harry_phan06).","src/content/posts/introducing-sui-cli-web.mdx","63806472880f98af","introducing-sui-cli-web.mdx","21-day-sui-challenge-is-live",{"id":66,"data":68,"body":98,"filePath":99,"digest":100,"legacyId":101,"deferredRender":26},{"title":69,"description":70,"publishDate":71,"category":17,"tags":72,"author":25,"heroImage":78,"draft":61,"sections":79},"21-Day Sui Challenge: Learn Move from Zero","Join the 21-Day Sui Challenge - a beginner-friendly program to learn Move programming on Sui blockchain. One commit per day, zero coding experience required.",["Date","2024-12-20T00:00:00.000Z"],[73,74,19,75,76,24,77],"sui-challenge","learn-move","sui-cli","community","21-day-challenge","/blog/images/posts/21-day-sui-challenge/tweet-announcement.png",[80,83,86,89,92,95],{"id":81,"title":82},"why-this-matters","Why This Matters",{"id":84,"title":85},"setting-up-your-environment","Setting Up Your Environment",{"id":87,"title":88},"day-1-deep-dive","Day 1 Deep Dive",{"id":90,"title":91},"move-type-system","Move Type System",{"id":93,"title":94},"join-the-challenge","Join the Challenge",{"id":96,"title":97},"vietnamese-builders-level-up-your-journey","🇻🇳 First Movers Sprint","import { AccordionCTA } from '../../components/react/BouncyAccordion';\nimport { RevealOnScroll } from '../../components/react/TextReveal';\nimport { BrandedImage } from '../../components/react/BrandedImage';\nimport { ChallengeRoadmap, ObjectModelComparison, WorkflowCycle } from '../../components/react/ChallengeDiagrams';\n\n**The 21-Day Sui Challenge is officially live.**\n\n\u003Ca href=\"https://github.com/riva-labs/21Challenge\" target=\"_blank\" rel=\"noopener noreferrer\" className=\"block my-8 no-underline\">\n \u003CBrandedImage\n client:visible\n src=\"/blog/images/posts/21-day-sui-challenge/tweet-announcement.png\"\n alt=\"@ercandotsui announcing the 21-Day Sui Challenge on X\"\n width={550}\n height={538}\n watermark=\"cli.firstmovers.io\"\n />\n\u003C/a>\n\nThe rules are simple: **one commit a day for 21 days.**\n\nNo need to build the next big thing. No need to master everything at once. Just write some code, push it, and come back tomorrow.\n\n[@ercandotsui](https://x.com/ercandotsui) made this challenge because learning Move shouldn't feel like a mountain. It should feel like a habit — something small you do daily until it just clicks.\n\n\u003CAccordionCTA\n client:visible\n icon=\"📚\"\n title=\"21-Day Challenge Repository\"\n description=\"The complete curriculum lives here. Daily tasks, code examples, and a community of learners.\"\n link=\"https://github.com/riva-labs/21Challenge\"\n linkText=\"View on GitHub\"\n/>\n\n---\n\n## Why This Matters\n\nI've seen smart developers give up on blockchain. Not because they couldn't do it. They totally could. But because every tutorial out there assumes you already know Rust, already get how consensus works, already have opinions on VM stuff.\n\n**That's a lot to ask from someone just starting out.**\n\nMove is different. Sui's version of Move is even more different. But going from \"never touched Move\" to \"can actually build something\" has always been hard. There wasn't a clear path.\n\nThis challenge changes that.\n\nThe 21-Day Challenge breaks things into small pieces. Day 1? You define three constants. That's it. You type them, build, commit. Done.\n\nNo single day is magic. But doing it every day? **That's where it adds up.**\n\n\u003CObjectModelComparison client:visible />\n\n---\n\n## Setting Up Your Environment\n\nBefore writing any Move code, you need two things: the Sui CLI and a good way to interact with it.\n\n### Installing Sui CLI\n\nOpen your terminal and run:\n\n```bash\ncurl -sSfL https://raw.githubusercontent.com/Mystenlabs/suiup/main/install.sh | sh\nsuiup install sui@testnet\nsui --version\n```\n\nIf you see a version number, you're ready.\n\n### The Better Way: Sui CLI Web\n\nHere's the thing — the terminal is powerful but not always pleasant. I built [Sui CLI Web](https://cli.firstmovers.io) because I got tired of copying addresses, hunting for object IDs, and staring at walls of JSON.\n\n\u003Cfigure className=\"my-8\">\n \u003CBrandedImage\n client:visible\n src=\"/blog/images/posts/introducing-sui-cli-web/hero.png\"\n alt=\"Sui CLI Web - Visual interface for Sui blockchain\"\n width={1200}\n height={630}\n watermark=\"cli.firstmovers.io\"\n />\n \u003Cfigcaption className=\"text-center text-white/50 text-sm mt-3\">\n Sui CLI Web: A visual interface that talks to your local Sui CLI\n \u003C/figcaption>\n\u003C/figure>\n\nIt's a browser-based interface that connects to your local Sui CLI. Click what you need, see the results instantly.\n\n- **Check balance** — see all your coins at a glance\n- **Get testnet tokens** — one-click faucet\n- **View transactions** — explore your tx history\n- **Switch networks** — toggle between testnet/mainnet/devnet\n\n\u003Cfigure className=\"my-8\">\n \u003CBrandedImage\n client:visible\n src=\"/blog/images/posts/21-day-sui-challenge/sui-cli-web-build.png\"\n alt=\"Sui CLI Web Develop tab - Build Package and Run Tests\"\n width={800}\n height={792}\n watermark=\"cli.firstmovers.io\"\n />\n \u003Cfigcaption className=\"text-center text-white/50 text-sm mt-3\">\n The Develop tab: Build your package and run tests with one click\n \u003C/figcaption>\n\u003C/figure>\n\nYour private keys never leave your machine. The web interface just makes the CLI actually pleasant to use.\n\n\u003CAccordionCTA\n client:visible\n icon=\"🚀\"\n title=\"Try Sui CLI Web\"\n description=\"If you want the full story on why I built this and how it works, check out the deep dive.\"\n link=\"/blog/posts/introducing-sui-cli-web\"\n linkText=\"Read the Story\"\n/>\n\n### VS Code Setup\n\nGet [VS Code](https://code.visualstudio.com/) and install the **Move** extension by Mysten Labs. This gives you:\n- Syntax highlighting\n- Error detection as you type\n- Go-to-definition for Move code\n\nTrust me, you want this.\n\n### Clone the Challenge\n\n```bash\ngit clone https://github.com/riva-labs/21Challenge.git\ncd 21Challenge\n```\n\nVerify everything works:\n\n```bash\ncd day_01\nsui move build\n```\n\nSee \"Build successful\"? Let's dive into Day 1.\n\n---\n\n## Day 1 Deep Dive\n\nDay 1 looks simple on paper: \"Define some constants.\" But there's a lot hiding under the surface. Let me show you what I actually wrote for my Day 1.\n\n### The Module Structure\n\nEvery Move file starts with a module declaration:\n\n```move\nmodule challenge::day_01 {\n // Everything goes here\n}\n```\n\nThe format is `package_name::module_name`. Think of it like namespacing in other languages. The `challenge` part comes from your `Move.toml` file.\n\n### Basic Constants\n\nThe simplest thing you can do in Move:\n\n```move\nconst NUMBER: u64 = 42;\nconst FLAG: bool = true;\nconst MY_ADDRESS: address = @0x1;\n```\n\nThree types, three constants. But here's what's interesting about each:\n\n**`u64`** — This is an unsigned 64-bit integer. Move doesn't have signed integers. No negative numbers. This might feel limiting, but it eliminates an entire class of bugs (underflow to negative values).\n\n**`bool`** — Just `true` or `false`. Nothing fancy.\n\n**`address`** — This is unique to Move. Every object, every account, every package has an address. The `@` prefix is Move syntax for \"this is an address literal.\"\n\n### The Integer Type Spectrum\n\nMove has six integer sizes:\n\n```move\nconst TINY: u8 = 255; // 0 to 255\nconst SMALL: u16 = 65_535; // 0 to 65,535\nconst MEDIUM: u32 = 4_294_967_295; // 0 to ~4.3 billion\nconst LARGE: u64 = 18_446_744_073_709_551_615; // The big one\nconst HUGE: u128 = 340_282_366_920_938_463_463_374_607_431_768_211_455;\nconst MASSIVE: u256 = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF;\n```\n\nWhy so many? **Gas optimization.** If you're storing a number that's always between 0-255, using `u8` costs less than `u64`. On-chain, every byte matters.\n\nThe underscores (`1_000_000`) are just for readability. The compiler ignores them.\n\n### Number Formats\n\nMove supports different ways to write numbers:\n\n```move\nconst DECIMAL: u64 = 1_000_000; // One million\nconst HEX: u64 = 0xDEADBEEF; // Hex format\nconst HEX_BYTE: u8 = 0xFF; // 255 in hex\nconst WITH_SUFFIX: u8 = 100u8; // Explicit type suffix\n```\n\nThe hex format (`0x...`) is common when dealing with addresses or bit manipulation. The type suffix (`100u8`) lets you be explicit about the type without a separate declaration.\n\n### Addresses in Sui\n\n```move\nconst ZERO_ADDR: address = @0x0;\nconst SUI_FRAMEWORK: address = @0x2;\nconst STD_ADDR: address = @0x1;\nconst CHALLENGE_ADDR: address = @challenge;\n```\n\nSome addresses are special:\n- `@0x1` — The Move standard library\n- `@0x2` — The Sui framework (where `Coin`, `TxContext`, etc. live)\n- `@challenge` — A named address from your `Move.toml`\n\nNamed addresses are powerful. Instead of hardcoding hex strings, you reference them by name and set the actual value in config.\n\n---\n\n## Move Type System\n\nHere's where Move gets interesting. Let me show you things that would break in most languages but work perfectly here.\n\n### Type Casting (The Safe Way)\n\n```move\nfun widen_types(): u256 {\n let a: u8 = 100;\n let b: u16 = (a as u16); // u8 → u16: safe\n let c: u32 = (b as u32); // u16 → u32: safe\n let d: u64 = (c as u64); // u32 → u64: safe\n let e: u128 = (d as u128); // u64 → u128: safe\n (e as u256) // u128 → u256: safe\n}\n```\n\n**Widening** (small → large) is always safe. You can't lose data going from `u8` to `u64`.\n\nBut **narrowing** is dangerous:\n\n```move\nfun narrow_types_safe(): u8 {\n let val: u64 = 200; // fits in u8\n (val as u8) // works!\n}\n\n// But this ABORTS at runtime:\nfun this_will_crash(): u8 {\n let val: u64 = 256; // too big for u8!\n (val as u8) // ABORT\n}\n```\n\nMove doesn't silently truncate. If you try to squeeze 256 into a `u8`, the transaction aborts. **This is a feature.** Silent data loss is how billion-dollar bugs happen in other languages.\n\n### Safety Tests\n\nHere's what I love about Move. You can write tests that *expect* failures:\n\n```move\n#[test]\n#[expected_failure(arithmetic_error, location = Self)]\nfun test_overflow_aborts() {\n let max: u64 = 18_446_744_073_709_551_615;\n let _x = max + 1; // ABORT: overflow\n}\n\n#[test]\n#[expected_failure(arithmetic_error, location = Self)]\nfun test_underflow_aborts() {\n let zero: u64 = 0;\n let _x = zero - 1; // ABORT: underflow\n}\n\n#[test]\n#[expected_failure(arithmetic_error, location = Self)]\nfun test_division_by_zero_aborts() {\n let x: u64 = 100;\n let _y = x / 0; // ABORT: div by zero\n}\n```\n\nNo undefined behavior. No weird wrapping. If math goes wrong, **the transaction stops.** You can't accidentally drain a pool because of an underflow.\n\n### Bitwise Operations\n\nFor the systems programmers:\n\n```move\nfun bitwise_operations(): u64 {\n let a: u64 = 12; // Binary: 1100\n let b: u64 = 10; // Binary: 1010\n\n let _and = a & b; // 1000 = 8\n let _or = a | b; // 1110 = 14\n let xor = a ^ b; // 0110 = 6\n let lshift = a \u003C\u003C 2; // 110000 = 48\n let rshift = a >> 1; // 110 = 6\n\n xor + lshift + rshift // 60\n}\n```\n\nYou'll use these for flags, permissions, and compact data storage. Not Day 1 stuff usually, but good to know they exist.\n\n---\n\n## Running Your First Tests\n\nHere's the payoff. Run this in your Day 1 folder:\n\n```bash\nsui move test\n```\n\nYou should see something like:\n\n```\nRunning Move unit tests\n[ PASS ] challenge::day_01::test_basic_constants\n[ PASS ] challenge::day_01::test_type_widening\n[ PASS ] challenge::day_01::test_narrowing_aborts_on_overflow\n[ PASS ] challenge::day_01::test_bitwise\n[ PASS ] challenge::day_01::test_arithmetic\n...\nTest result: OK. 12 passed; 0 failed\n```\n\n**All green.** That's Day 1 done.\n\n\u003Cfigure className=\"my-8\">\n \u003CBrandedImage\n client:visible\n src=\"/blog/images/posts/21-day-sui-challenge/sui-cli-web-test.png\"\n alt=\"Sui CLI Web test results - 14 tests passed\"\n width={800}\n height={609}\n watermark=\"cli.firstmovers.io\"\n />\n \u003Cfigcaption className=\"text-center text-white/50 text-sm mt-3\">\n All 14 tests passing in Sui CLI Web\n \u003C/figcaption>\n\u003C/figure>\n\n\u003CWorkflowCycle client:visible />\n\nThat's Day 1 wrapped. You've learned Move's type system, written safety tests, and built your first module. Tomorrow we'll add functions and start making things actually *do* something.\n\n**The foundation is set. Now we build.**\n\n---\n\n## The Week Ahead\n\n\u003CRevealOnScroll client:visible>\n\n### Week 1: Foundations (Days 1-7)\n\n- **Day 1**: Modules and primitive types (you just did this!)\n- **Day 2**: Functions and visibility\n- **Day 3**: Structs and data modeling\n- **Day 4**: Abilities (`copy`, `drop`, `store`, `key`)\n- **Day 5**: References and borrowing\n- **Day 6**: Generics basics\n- **Day 7**: Your first Sui object\n\nBy Day 7, you'll build a simple counter — an object you can create, increment, and read. Not going to win hackathons, but it teaches you how Sui objects *actually* work.\n\n### Week 2: Objects (Days 8-14)\n\nThis is where Sui starts feeling different. You'll learn about ownership, shared objects, entry functions, and events. By Day 14, you'll mint an NFT.\n\n### Week 3: Patterns (Days 15-21)\n\nThe advanced stuff: generics, the \"hot potato\" pattern, capability-based security, and creating your own tokens. By Day 21, you'll deploy a complete dApp.\n\n\u003CChallengeRoadmap client:visible />\n\n\u003C/RevealOnScroll>\n\n---\n\n## Join the Challenge\n\n\u003CRevealOnScroll client:visible>\n\nReady to start?\n\n1. **[Clone the repo](https://github.com/riva-labs/21Challenge)**\n2. **[Join the WhatsApp group](https://chat.whatsapp.com/DZH6y2XMDQ58NY5DheUhQs?mode=hqrc)** for daily accountability\n3. **Follow [@ercandotsui](https://x.com/ercandotsui)** for official updates\n4. **Tweet your progress** with #SuiChallenge\n\n\u003CAccordionCTA\n client:visible\n icon=\"💬\"\n title=\"Join the Community\"\n description=\"50+ developers sharing daily progress. Questions answered, wins celebrated.\"\n link=\"https://chat.whatsapp.com/DZH6y2XMDQ58NY5DheUhQs?mode=hqrc\"\n linkText=\"Join WhatsApp\"\n/>\n\n### Resources\n\nIf you want to go deeper:\n\n- [Move Book](https://move-book.com) — The comprehensive language reference\n- [Sui Documentation](https://docs.sui.io) — Official docs from Mysten Labs\n- [Sui by Examples](https://suibyexamples.com) — Learn by building\n- [Sui CLI Web](https://cli.firstmovers.io) — Visual interface for Sui CLI\n\n---\n\n**One day. One commit. That's all it takes to start.**\n\nSee you on Day 2.\n\n---\n\n*Follow: [@ercandotsui](https://x.com/ercandotsui) | [@harry_phan06](https://x.com/harry_phan06)*\n\n\u003C/RevealOnScroll>\n\n---\n\n## 🇻🇳 Vietnamese Builders: Level Up Your Journey\n\n\u003CRevealOnScroll client:visible>\n\n**First Movers Sprint 2026 is here!**\n\nFollowing the success of CommandOSS Hacker House HCMC, First Movers Sprint 2026 is designed as a stepping-stone Hacker House — a space for builders in the Sui ecosystem to sharpen their skills, accelerate product development, and get fully prepared for the next CommandOSS Hacker House.\n\nWhether you're a developer or a non-technical business/growth builder, you're welcome to join!\n\n\u003Cfigure className=\"my-8\">\n \u003CBrandedImage\n client:visible\n src=\"/blog/images/posts/21-day-sui-challenge/first-movers-sprint.png\"\n alt=\"First Movers Sprint 2026 Hackathon\"\n width={800}\n height={800}\n watermark=\"firstmovers.io\"\n />\n\u003C/figure>\n\n**Why join First Movers Sprint?**\n\n- 💼 **Paid internship** at top Sui ecosystem projects (full-time)\n- 🏆 **Exciting prizes** and rewards\n- 🌟 **Learn directly** with top mentors from the Sui ecosystem\n\nCo-organized by [@firstmoversvn](https://x.com/firstmoversvn), [@0xCommandOSS](https://x.com/0xCommandOSS), and ITviec — the leading IT recruitment platform.\n\n\u003CAccordionCTA\n client:visible\n icon=\"🚀\"\n title=\"Join First Movers Sprint 2026\"\n description=\"A stepping-stone Hacker House for Vietnamese builders in the Sui ecosystem. Sharpen your skills, accelerate your product, and prepare for CommandOSS.\"\n link=\"https://luma.com/first-movers-sprint\"\n linkText=\"Apply Now\"\n/>\n\n\u003C/RevealOnScroll>","src/content/posts/21-day-sui-challenge-is-live.mdx","61cf1dbff8b1958b","21-day-sui-challenge-is-live.mdx","21-day-sui-challenge-day-2",{"id":102,"data":104,"body":134,"filePath":135,"digest":136,"legacyId":137,"deferredRender":26},{"title":105,"description":106,"publishDate":107,"category":17,"tags":108,"author":25,"heroImage":113,"draft":61,"sections":114},"21-Day Sui Challenge Day 2: Functions & Unit Testing in Move","Learn Move functions, visibility modifiers, and unit testing. Write your first test with assert_eq and expected_failure.",["Date","2024-12-21T00:00:00.000Z"],[73,74,109,110,111,112,19],"move-functions","unit-testing","sui-tutorial","day-2","/blog/images/posts/21-day-sui-challenge/day-2-hero.png",[115,118,121,124,127,130,133],{"id":116,"title":117},"the-anatomy-of-a-function","The Anatomy of a Function",{"id":119,"title":120},"visibility-and-access","Visibility and Access",{"id":122,"title":123},"return-patterns","Return Patterns",{"id":125,"title":126},"testing-deep-dive","Testing Deep Dive",{"id":128,"title":129},"complete-solution","Complete Solution",{"id":131,"title":132},"whats-next","What's Next",{"id":96,"title":97},"import { AccordionCTA } from '../../components/react/BouncyAccordion';\nimport { RevealOnScroll } from '../../components/react/TextReveal';\nimport { BrandedImage } from '../../components/react/BrandedImage';\n\n**Day 2 of the 21-Day Sui Challenge.**\n\nYesterday you defined constants and explored Move's type system. Today, we're making things *do* something. Functions are the heart of any program, and Move has some unique twists you need to understand.\n\n\u003CAccordionCTA\n client:visible\n icon=\"📚\"\n title=\"Day 2 Challenge\"\n description=\"Write a sum function and test it. Simple on the surface, deep when you look closer.\"\n link=\"https://github.com/riva-labs/21Challenge/tree/main/day_02\"\n linkText=\"View on GitHub\"\n/>\n\n---\n\n## The Anatomy of a Function\n\nLet's start with the basics. Here's what a Move function looks like:\n\n```move\npublic fun sum(a: u64, b: u64): u64 {\n a + b\n}\n```\n\nFive parts to understand:\n\n1. **`public`** — Visibility modifier (who can call this)\n2. **`fun`** — Keyword that declares a function\n3. **`sum`** — The function name\n4. **`(a: u64, b: u64)`** — Parameters with explicit types\n5. **`: u64`** — Return type\n\n### No Return Keyword Needed\n\nHere's the thing that trips up developers from other languages: **Move functions automatically return the last expression.**\n\n```move\n// These are identical:\npublic fun add_v1(a: u64, b: u64): u64 {\n return a + b // explicit return\n}\n\npublic fun add_v2(a: u64, b: u64): u64 {\n a + b // implicit return (preferred)\n}\n```\n\nNotice the missing semicolon in `add_v2`. That's intentional. In Move:\n- **Expression** (no semicolon) → returns a value\n- **Statement** (with semicolon) → returns nothing\n\n```move\n// This WON'T compile:\npublic fun broken(a: u64, b: u64): u64 {\n a + b; // semicolon makes it a statement, returns ()\n}\n// Error: expected 'u64', found '()'\n```\n\nThis is borrowed from Rust, and once you get it, it feels natural.\n\n---\n\n## Visibility and Access\n\nMove has four visibility levels, but for Day 2, you need to know two:\n\n### `public fun` — Callable from Anywhere\n\n```move\npublic fun greet(): vector\u003Cu8> {\n b\"Hello, Sui!\"\n}\n```\n\nAnyone can call this: other modules, other packages, even external transactions.\n\n### `fun` (no modifier) — Private\n\n```move\nfun internal_helper(x: u64): u64 {\n x * 2\n}\n```\n\nOnly functions in the *same module* can call this. It's hidden from the outside world.\n\n### Why This Matters\n\nGood Move code uses private functions for implementation details and only exposes what's necessary:\n\n```move\nmodule challenge::calculator {\n // Public API\n public fun calculate(a: u64, b: u64, op: u8): u64 {\n if (op == 0) add(a, b)\n else if (op == 1) subtract(a, b)\n else multiply(a, b)\n }\n\n // Private helpers\n fun add(a: u64, b: u64): u64 { a + b }\n fun subtract(a: u64, b: u64): u64 { a - b }\n fun multiply(a: u64, b: u64): u64 { a * b }\n}\n```\n\nExternal callers only see `calculate`. The internal logic is hidden and can be changed without breaking the public API.\n\n### A Note on Other Visibility Levels\n\nMove actually has two more visibility modifiers you'll encounter later:\n\n- **`public(package)`** — Only callable within the same package (not from other packages)\n- **`entry`** — Callable from transactions, but *not* from other Move code\n\nThe `entry` modifier is particularly useful for security. When you want users to call a function directly but prevent other smart contracts from wrapping it (to avoid composition attacks), use `entry`. You'll see this pattern when working with sensitive operations like token transfers.\n\nFor now, `public` and private (`fun`) are all you need.\n\n---\n\n## Return Patterns\n\nLet's go deeper than the basic `sum` function.\n\n### Multiple Return Values\n\nMove functions can return tuples:\n\n```move\npublic fun divide_with_remainder(a: u64, b: u64): (u64, u64) {\n let quotient = a / b;\n let remainder = a % b;\n (quotient, remainder)\n}\n\n// Usage:\nfun use_division() {\n let (q, r) = divide_with_remainder(17, 5);\n // q = 3, r = 2\n}\n```\n\nThis is cleaner than returning a struct for simple cases.\n\n### Early Returns with Conditions\n\nSometimes you need to bail out early:\n\n```move\npublic fun safe_divide(a: u64, b: u64): u64 {\n if (b == 0) {\n return 0 // Early return to avoid division by zero\n };\n a / b\n}\n```\n\nNote the semicolon after the `if` block — it's a statement here. The final `a / b` is the expression that gets returned for non-zero `b`.\n\n### Functions That Return Nothing\n\nSome functions are just for side effects:\n\n```move\npublic fun log_value(value: u64) {\n // In real code, this might emit an event\n // For now, it does nothing visible\n let _ = value;\n}\n```\n\nThe return type is `()` (unit type), which is implicit when you don't specify one.\n\n---\n\n## Testing Deep Dive\n\nThis is where Day 2 really shines. Move's testing system is built into the language, not bolted on as an afterthought.\n\n### Basic Test Structure\n\n```move\n#[test]\nfun test_sum() {\n let result = sum(1, 2);\n assert!(result == 3, 0);\n}\n```\n\nThe `#[test]` attribute tells the Move compiler this is a test function. It won't be included in production builds.\n\n### Assert vs Assert_eq\n\nMove gives you two main assertion tools:\n\n```move\n#[test_only]\nuse std::unit_test::assert_eq;\n\n#[test]\nfun test_with_assert() {\n let result = sum(10, 20);\n assert!(result == 30, 0); // Basic assertion\n}\n\n#[test]\nfun test_with_assert_eq() {\n let result = sum(10, 20);\n assert_eq!(result, 30); // More descriptive on failure\n}\n```\n\n`assert_eq!` is a macro (note the `!`) that gives better error messages when tests fail. Use it.\n\n### The `#[test_only]` Pattern\n\nYou don't want test utilities in production code:\n\n```move\nmodule challenge::day_02 {\n // This import only exists during testing\n #[test_only]\n use std::unit_test::assert_eq;\n\n public fun sum(a: u64, b: u64): u64 {\n a + b\n }\n\n // This function only exists during testing\n #[test_only]\n fun create_test_value(): u64 {\n 42\n }\n\n #[test]\n fun test_sum() {\n let expected = create_test_value();\n // ...\n }\n}\n```\n\nThe `#[test_only]` attribute on the import and helper function means they're stripped from the final bytecode. Your production module stays lean.\n\n### Testing Edge Cases\n\nGood tests cover more than the happy path:\n\n```move\n#[test]\nfun test_sum_zero() {\n assert_eq!(sum(0, 0), 0);\n assert_eq!(sum(0, 100), 100);\n assert_eq!(sum(100, 0), 100);\n}\n\n#[test]\nfun test_sum_large_numbers() {\n let large = 1_000_000_000_000;\n assert_eq!(sum(large, large), 2_000_000_000_000);\n}\n```\n\n### Expected Failures\n\nSometimes you *want* code to fail. Move lets you test for that:\n\n```move\n#[test]\n#[expected_failure(arithmetic_error, location = Self)]\nfun test_overflow_aborts() {\n let max: u64 = 18_446_744_073_709_551_615;\n let _result = sum(max, 1); // This overflows!\n}\n```\n\nIf the code *doesn't* abort, the test fails. This is crucial for security — you need to verify that dangerous operations actually get blocked.\n\n### Abort Codes\n\nYou can also test for specific abort codes:\n\n```move\npublic fun divide(a: u64, b: u64): u64 {\n assert!(b != 0, 1001); // Abort code 1001 for division by zero\n a / b\n}\n\n#[test]\n#[expected_failure(abort_code = 1001)]\nfun test_divide_by_zero() {\n let _result = divide(10, 0); // Should abort with code 1001\n}\n```\n\nThis is how professional Move code handles errors — each failure has a unique code that can be caught and tested.\n\n---\n\n## Complete Solution\n\nHere's my complete Day 2 implementation. It goes well beyond the minimum requirements:\n\n```move\n/// DAY 2: Primitive Types & Simple Functions - Complete Solution\n///\n/// This demonstrates:\n/// - Basic functions with parameters and return values\n/// - Multiple function patterns (helpers, wrappers, mathematical)\n/// - Comprehensive testing strategies\n/// - Edge case handling\n\nmodule challenge::day_02 {\n // ═══════════════════════════════════════════════════════════════\n // IMPORTS\n // ═══════════════════════════════════════════════════════════════\n\n #[test_only]\n use std::unit_test::assert_eq;\n\n // ═══════════════════════════════════════════════════════════════\n // CONSTANTS\n // ═══════════════════════════════════════════════════════════════\n\n /// Error code for division by zero\n const E_DIVISION_BY_ZERO: u64 = 1001;\n\n /// Error code for invalid input\n const E_INVALID_INPUT: u64 = 1002;\n\n // ═══════════════════════════════════════════════════════════════\n // CORE FUNCTIONS\n // ═══════════════════════════════════════════════════════════════\n\n /// Adds two numbers\n /// The basic function required by Day 2\n public fun sum(a: u64, b: u64): u64 {\n a + b\n }\n\n /// Subtracts b from a\n /// Note: Will abort if b > a (no negative numbers in Move!)\n public fun subtract(a: u64, b: u64): u64 {\n a - b\n }\n\n /// Multiplies two numbers\n public fun multiply(a: u64, b: u64): u64 {\n a * b\n }\n\n /// Divides a by b with explicit zero-check\n public fun divide(a: u64, b: u64): u64 {\n assert!(b != 0, E_DIVISION_BY_ZERO);\n a / b\n }\n\n /// Returns quotient and remainder\n public fun divide_with_remainder(a: u64, b: u64): (u64, u64) {\n assert!(b != 0, E_DIVISION_BY_ZERO);\n (a / b, a % b)\n }\n\n // ═══════════════════════════════════════════════════════════════\n // HELPER FUNCTIONS (Private)\n // ═══════════════════════════════════════════════════════════════\n\n /// Checks if a number is even\n fun is_even(n: u64): bool {\n n % 2 == 0\n }\n\n /// Checks if a number is odd\n fun is_odd(n: u64): bool {\n !is_even(n)\n }\n\n /// Returns the larger of two numbers\n fun max(a: u64, b: u64): u64 {\n if (a > b) a else b\n }\n\n /// Returns the smaller of two numbers\n fun min(a: u64, b: u64): u64 {\n if (a \u003C b) a else b\n }\n\n // ═══════════════════════════════════════════════════════════════\n // ADVANCED FUNCTIONS\n // ═══════════════════════════════════════════════════════════════\n\n /// Calculates power: base^exponent\n /// Uses iterative approach to avoid stack overflow\n public fun power(base: u64, exponent: u64): u64 {\n if (exponent == 0) {\n return 1\n };\n\n let mut result = 1;\n let mut i = 0;\n while (i \u003C exponent) {\n result = result * base;\n i = i + 1;\n };\n result\n }\n\n /// Calculates factorial: n!\n /// n! = n * (n-1) * (n-2) * ... * 1\n public fun factorial(n: u64): u64 {\n if (n \u003C= 1) {\n return 1\n };\n\n let mut result = 1;\n let mut i = 2;\n while (i \u003C= n) {\n result = result * i;\n i = i + 1;\n };\n result\n }\n\n /// Calculates the average of two numbers (integer division)\n public fun average(a: u64, b: u64): u64 {\n // Safe average that doesn't overflow\n // Instead of (a + b) / 2, use a/2 + b/2 + (a%2 + b%2)/2\n let half_a = a / 2;\n let half_b = b / 2;\n let remainder = ((a % 2) + (b % 2)) / 2;\n half_a + half_b + remainder\n }\n\n /// Calculates absolute difference between two numbers\n public fun abs_diff(a: u64, b: u64): u64 {\n if (a > b) a - b else b - a\n }\n\n /// Clamps a value between min and max bounds\n public fun clamp(value: u64, min_val: u64, max_val: u64): u64 {\n assert!(min_val \u003C= max_val, E_INVALID_INPUT);\n if (value \u003C min_val) {\n min_val\n } else if (value > max_val) {\n max_val\n } else {\n value\n }\n }\n\n // ═══════════════════════════════════════════════════════════════\n // TESTS - Basic\n // ═══════════════════════════════════════════════════════════════\n\n #[test]\n fun test_sum_basic() {\n assert_eq!(sum(1, 2), 3);\n }\n\n #[test]\n fun test_sum_zero() {\n assert_eq!(sum(0, 0), 0);\n assert_eq!(sum(0, 100), 100);\n assert_eq!(sum(100, 0), 100);\n }\n\n #[test]\n fun test_sum_large() {\n let billion = 1_000_000_000;\n assert_eq!(sum(billion, billion), 2_000_000_000);\n }\n\n #[test]\n fun test_subtract() {\n assert_eq!(subtract(10, 3), 7);\n assert_eq!(subtract(100, 100), 0);\n assert_eq!(subtract(5, 0), 5);\n }\n\n #[test]\n fun test_multiply() {\n assert_eq!(multiply(6, 7), 42);\n assert_eq!(multiply(0, 1000), 0);\n assert_eq!(multiply(1, 999), 999);\n }\n\n #[test]\n fun test_divide() {\n assert_eq!(divide(20, 4), 5);\n assert_eq!(divide(17, 5), 3); // Integer division\n assert_eq!(divide(0, 10), 0);\n }\n\n // ═══════════════════════════════════════════════════════════════\n // TESTS - Advanced\n // ═══════════════════════════════════════════════════════════════\n\n #[test]\n fun test_divide_with_remainder() {\n let (q, r) = divide_with_remainder(17, 5);\n assert_eq!(q, 3);\n assert_eq!(r, 2);\n\n let (q2, r2) = divide_with_remainder(20, 4);\n assert_eq!(q2, 5);\n assert_eq!(r2, 0);\n }\n\n #[test]\n fun test_power() {\n assert_eq!(power(2, 0), 1);\n assert_eq!(power(2, 1), 2);\n assert_eq!(power(2, 10), 1024);\n assert_eq!(power(3, 4), 81);\n assert_eq!(power(10, 6), 1_000_000);\n }\n\n #[test]\n fun test_factorial() {\n assert_eq!(factorial(0), 1);\n assert_eq!(factorial(1), 1);\n assert_eq!(factorial(5), 120);\n assert_eq!(factorial(10), 3_628_800);\n }\n\n #[test]\n fun test_average() {\n assert_eq!(average(10, 20), 15);\n assert_eq!(average(0, 100), 50);\n assert_eq!(average(7, 8), 7); // Integer division: (7+8)/2 = 7\n }\n\n #[test]\n fun test_average_no_overflow() {\n // This would overflow with naive (a+b)/2 approach\n let max = 18_446_744_073_709_551_615u64;\n let result = average(max, max);\n assert_eq!(result, max);\n }\n\n #[test]\n fun test_abs_diff() {\n assert_eq!(abs_diff(10, 7), 3);\n assert_eq!(abs_diff(7, 10), 3);\n assert_eq!(abs_diff(5, 5), 0);\n }\n\n #[test]\n fun test_clamp() {\n assert_eq!(clamp(5, 0, 10), 5); // Within range\n assert_eq!(clamp(0, 5, 10), 5); // Below min\n assert_eq!(clamp(15, 0, 10), 10); // Above max\n }\n\n // ═══════════════════════════════════════════════════════════════\n // TESTS - Private Functions (test_only access)\n // ═══════════════════════════════════════════════════════════════\n\n #[test]\n fun test_is_even() {\n assert!(is_even(0), 0);\n assert!(is_even(2), 1);\n assert!(is_even(100), 2);\n assert!(!is_even(1), 3);\n assert!(!is_even(99), 4);\n }\n\n #[test]\n fun test_is_odd() {\n assert!(is_odd(1), 0);\n assert!(is_odd(99), 1);\n assert!(!is_odd(0), 2);\n assert!(!is_odd(100), 3);\n }\n\n #[test]\n fun test_max_min() {\n assert_eq!(max(10, 5), 10);\n assert_eq!(max(5, 10), 10);\n assert_eq!(max(7, 7), 7);\n\n assert_eq!(min(10, 5), 5);\n assert_eq!(min(5, 10), 5);\n assert_eq!(min(7, 7), 7);\n }\n\n // ═══════════════════════════════════════════════════════════════\n // TESTS - Expected Failures\n // ═══════════════════════════════════════════════════════════════\n\n #[test]\n #[expected_failure(abort_code = E_DIVISION_BY_ZERO)]\n fun test_divide_by_zero() {\n let _result = divide(10, 0);\n }\n\n #[test]\n #[expected_failure(abort_code = E_DIVISION_BY_ZERO)]\n fun test_divide_with_remainder_by_zero() {\n let (_q, _r) = divide_with_remainder(10, 0);\n }\n\n #[test]\n #[expected_failure(arithmetic_error, location = Self)]\n fun test_subtract_underflow() {\n let _result = subtract(5, 10); // 5 - 10 underflows\n }\n\n #[test]\n #[expected_failure(arithmetic_error, location = Self)]\n fun test_sum_overflow() {\n let max = 18_446_744_073_709_551_615u64;\n let _result = sum(max, 1);\n }\n\n #[test]\n #[expected_failure(abort_code = E_INVALID_INPUT)]\n fun test_clamp_invalid_bounds() {\n let _result = clamp(5, 10, 5); // min > max is invalid\n }\n}\n```\n\n---\n\n## Running the Tests\n\nNavigate to your Day 2 folder and run:\n\n```bash\ncd day_02\nsui move test\n```\n\nYou should see all tests passing:\n\n```\nRunning Move unit tests\n[ PASS ] challenge::day_02::test_abs_diff\n[ PASS ] challenge::day_02::test_average\n[ PASS ] challenge::day_02::test_average_no_overflow\n[ PASS ] challenge::day_02::test_clamp\n[ PASS ] challenge::day_02::test_clamp_invalid_bounds\n[ PASS ] challenge::day_02::test_divide\n[ PASS ] challenge::day_02::test_divide_by_zero\n[ PASS ] challenge::day_02::test_divide_with_remainder\n[ PASS ] challenge::day_02::test_divide_with_remainder_by_zero\n[ PASS ] challenge::day_02::test_factorial\n[ PASS ] challenge::day_02::test_is_even\n[ PASS ] challenge::day_02::test_is_odd\n[ PASS ] challenge::day_02::test_max_min\n[ PASS ] challenge::day_02::test_multiply\n[ PASS ] challenge::day_02::test_power\n[ PASS ] challenge::day_02::test_subtract\n[ PASS ] challenge::day_02::test_subtract_underflow\n[ PASS ] challenge::day_02::test_sum_basic\n[ PASS ] challenge::day_02::test_sum_large\n[ PASS ] challenge::day_02::test_sum_overflow\n[ PASS ] challenge::day_02::test_sum_zero\nTest result: OK. 21 passed; 0 failed\n```\n\n\u003Cfigure className=\"my-8\">\n \u003CBrandedImage\n client:visible\n src=\"/blog/images/posts/21-day-sui-challenge/day-2-test-results.png\"\n alt=\"Day 2 Move tests passing - 21 tests for functions and testing\"\n width={800}\n height={600}\n watermark=\"cli.firstmovers.io\"\n />\n \u003Cfigcaption className=\"text-center text-white/50 text-sm mt-3\">\n Run your tests with Sui CLI Web — all tests passing with detailed output\n \u003C/figcaption>\n\u003C/figure>\n\n---\n\n## Key Takeaways\n\n\u003CRevealOnScroll client:visible>\n\n### 1. Functions Return Last Expression\n\nNo `return` keyword needed. Just leave off the semicolon:\n\n```move\npublic fun add(a: u64, b: u64): u64 {\n a + b // ← This is returned\n}\n```\n\n### 2. Visibility Controls Access\n\n- `public fun` — Anyone can call\n- `fun` — Only this module can call\n\n### 3. Tests Are First-Class\n\nThe `#[test]` attribute integrates testing into the language:\n\n```move\n#[test]\nfun test_something() {\n assert_eq!(1 + 1, 2);\n}\n```\n\n### 4. Expected Failures Are Powerful\n\nTest that your code fails correctly:\n\n```move\n#[test]\n#[expected_failure(abort_code = 1001)]\nfun test_should_fail() {\n // This must abort with code 1001\n}\n```\n\n### 5. Error Codes Matter\n\nDefine constants for error codes:\n\n```move\nconst E_DIVISION_BY_ZERO: u64 = 1001;\n```\n\nThis makes debugging easier and tests more precise.\n\n\u003C/RevealOnScroll>\n\n---\n\n## What's Next\n\n\u003CRevealOnScroll client:visible>\n\nTomorrow in **Day 3**, we move from functions to **data structures**. You'll learn:\n\n- How to define structs in Move\n- The difference between `struct` and Sui objects\n- How to create, modify, and destroy data\n\nFunctions let you *do* things. Structs let you *hold* things. Together, they're the foundation for everything you'll build on Sui.\n\n\u003CAccordionCTA\n client:visible\n icon=\"🔗\"\n title=\"Continue the Challenge\"\n description=\"Day 3 covers structs and data modeling. Time to give your functions something to work with.\"\n link=\"https://github.com/riva-labs/21Challenge/tree/main/day_03\"\n linkText=\"View Day 3\"\n/>\n\n\u003C/RevealOnScroll>\n\n---\n\n## Commit Your Progress\n\n```bash\ncd day_02\nsui move test\ngit add day_02/\ngit commit -m \"Day 2: practice functions and testing\"\n```\n\n**Day 2 complete.** You've learned how Move functions work, written tests that verify behavior, and tested for expected failures. That's real Move development.\n\nSee you on Day 3.\n\n---\n\n*Follow: [@ercandotsui](https://x.com/ercandotsui) | [@harry_phan06](https://x.com/harry_phan06)*\n\n---\n\n## 🇻🇳 Vietnamese Builders: Level Up Your Journey\n\n\u003CRevealOnScroll client:visible>\n\n**First Movers Sprint 2026 is here!**\n\nFollowing the success of CommandOSS Hacker House HCMC, First Movers Sprint 2026 is designed as a stepping-stone Hacker House — a space for builders in the Sui ecosystem to sharpen their skills, accelerate product development, and get fully prepared for the next CommandOSS Hacker House.\n\nWhether you're a developer or a non-technical business/growth builder, you're welcome to join!\n\n\u003Cfigure className=\"my-8\">\n \u003CBrandedImage\n client:visible\n src=\"/blog/images/posts/21-day-sui-challenge/first-movers-sprint.png\"\n alt=\"First Movers Sprint 2026 Hackathon\"\n width={800}\n height={800}\n watermark=\"firstmovers.io\"\n />\n\u003C/figure>\n\n**Why join First Movers Sprint?**\n\n- 💼 **Paid internship** at top Sui ecosystem projects (full-time)\n- 🏆 **Exciting prizes** and rewards\n- 🌟 **Learn directly** with top mentors from the Sui ecosystem\n\nCo-organized by [@firstmoversvn](https://x.com/firstmoversvn), [@0xCommandOSS](https://x.com/0xCommandOSS), and ITviec — the leading IT recruitment platform.\n\n\u003CAccordionCTA\n client:visible\n icon=\"🚀\"\n title=\"Join First Movers Sprint 2026\"\n description=\"A stepping-stone Hacker House for Vietnamese builders in the Sui ecosystem. Sharpen your skills, accelerate your product, and prepare for CommandOSS.\"\n link=\"https://luma.com/first-movers-sprint\"\n linkText=\"Apply Now\"\n/>\n\n\u003C/RevealOnScroll>","src/content/posts/21-day-sui-challenge-day-2.mdx","f5a3395681f1d0f2","21-day-sui-challenge-day-2.mdx","21-day-sui-challenge-day-3",{"id":138,"data":140,"body":168,"filePath":169,"digest":170,"legacyId":171,"deferredRender":26},{"title":141,"description":142,"publishDate":143,"category":17,"tags":144,"author":25,"heroImage":148,"draft":61,"sections":149},"21-Day Sui Challenge Day 3: Structs & Abilities in Move","Learn to define custom data types with structs. Understand Move's ability system: copy, drop, store, and key.",["Date","2025-12-22T00:00:00.000Z"],[73,74,145,146,111,147,19],"structs","abilities","day-3","/blog/images/posts/21-day-sui-challenge/day-3-hero.png",[150,153,156,159,162,163,166,167],{"id":151,"title":152},"what-are-structs","What Are Structs",{"id":154,"title":155},"the-ability-system","The Ability System",{"id":157,"title":158},"working-with-structs","Working with Structs",{"id":160,"title":161},"patterns-and-best-practices","Patterns & Best Practices",{"id":128,"title":129},{"id":164,"title":165},"try-it-live-with-sui-cli-web","Try It Live",{"id":131,"title":132},{"id":96,"title":97},"import { AccordionCTA } from '../../components/react/BouncyAccordion';\nimport { RevealOnScroll } from '../../components/react/TextReveal';\nimport { BrandedImage } from '../../components/react/BrandedImage';\n\n**Day 3 of the 21-Day Sui Challenge.**\n\nYou've mastered primitives and functions. Now it's time to create your own data types. Structs are how you model real-world concepts in Move — and they come with a unique twist called **abilities**.\n\n\u003CAccordionCTA\n client:visible\n icon=\"📚\"\n title=\"Day 3 Challenge\"\n description=\"Create a Habit struct with name and completed status. Learn the foundations of data modeling in Move.\"\n link=\"https://github.com/riva-labs/21Challenge/tree/main/day_03\"\n linkText=\"View on GitHub\"\n/>\n\n\n\n## What Are Structs\n\nA **struct** is a custom data type that groups related fields together. Think of it as a blueprint for creating objects.\n\n```move\npublic struct Habit has copy, drop {\n name: vector\u003Cu8>,\n completed: bool,\n}\n```\n\nThis defines a `Habit` type with two fields:\n- `name` — A byte vector (we'll use proper strings later)\n- `completed` — Whether the habit is done\n\n### Creating Struct Instances\n\nOnce you've defined a struct, you create instances like this:\n\n```move\nlet habit = Habit {\n name: b\"Exercise\",\n completed: false,\n};\n```\n\nEvery field must be initialized. No partial construction allowed — this prevents bugs from uninitialized data.\n\n### Field Shorthand\n\nWhen your variable name matches the field name, use shorthand:\n\n```move\nfun create_habit(name: vector\u003Cu8>, completed: bool): Habit {\n // Long form\n Habit { name: name, completed: completed }\n\n // Shorthand (preferred)\n Habit { name, completed }\n}\n```\n\n\n\n## The Ability System\n\nHere's where Move gets interesting. Every type has **abilities** that control what you can do with it.\n\n### The Four Abilities\n\n| Ability | What It Allows |\n|---------|----------------|\n| `copy` | Value can be copied (duplicated) |\n| `drop` | Value can be discarded (goes out of scope) |\n| `store` | Value can be stored inside other structs |\n| `key` | Value can be stored directly in global storage (Sui objects!) |\n\n> ⚠️ **Important:** Not all combinations are valid! Structs with `key` cannot have `copy` because Sui objects have unique IDs that can't be duplicated.\n\n### Why This Matters\n\nIn most languages, you can copy or discard any value. Move is different — **you must explicitly grant these permissions**.\n\n```move\n// This struct can be copied and dropped\npublic struct SafeData has copy, drop {\n value: u64,\n}\n\n// This struct CANNOT be copied or dropped!\npublic struct PreciousData {\n value: u64,\n}\n```\n\nTry to copy `PreciousData` and the compiler stops you. Try to let it go out of scope without using it — error. This is **linear typing**, and it's how Move prevents resource leaks and double-spending.\n\n### Common Ability Combinations\n\nFor Day 3, you'll mostly use:\n\n```move\n// Simple data (can copy, can drop)\npublic struct SimpleData has copy, drop {\n value: u64,\n}\n\n// Storable data (can be nested in other structs)\npublic struct StorableData has copy, drop, store {\n value: u64,\n}\n```\n\nLater, when we create Sui objects, you'll use `key` and `store`:\n\n```move\n// Sui Object (has unique ID, stored on-chain)\npublic struct MyObject has key, store {\n id: UID,\n value: u64,\n}\n```\n\nBut that's Day 7. For now, stick with `copy` and `drop`.\n\n\n\n## Working with Structs\n\n### Constructor Functions\n\nThe convention is to create a `new_*` function:\n\n```move\npublic fun new_habit(name: vector\u003Cu8>): Habit {\n Habit {\n name,\n completed: false,\n }\n}\n```\n\nThis encapsulates initialization logic. Users don't need to know the internal structure — they just call `new_habit`.\n\n### Accessor Functions (Getters)\n\nTo read struct fields from outside the module, you need getter functions:\n\n```move\npublic fun name(habit: &Habit): &vector\u003Cu8> {\n &habit.name\n}\n\npublic fun is_completed(habit: &Habit): bool {\n habit.completed\n}\n```\n\nNotice the `&` — we're borrowing, not taking ownership. This lets you read without consuming the value.\n\n### Mutator Functions (Setters)\n\nTo modify fields, use mutable references:\n\n```move\npublic fun complete(habit: &mut Habit) {\n habit.completed = true;\n}\n\npublic fun rename(habit: &mut Habit, new_name: vector\u003Cu8>) {\n habit.name = new_name;\n}\n```\n\nThe `&mut` means \"mutable borrow\" — you can change the value but must give it back.\n\n### Destructuring\n\nYou can unpack a struct to get its fields:\n\n```move\npublic fun unpack_habit(habit: Habit): (vector\u003Cu8>, bool) {\n let Habit { name, completed } = habit;\n (name, completed)\n}\n```\n\nThis is the only way to \"destroy\" a struct that doesn't have `drop`. The fields are extracted and the struct wrapper is gone.\n\n\n\n## Patterns and Best Practices\n\n### Pattern 1: Immutable Data\n\nFor simple value types that never change:\n\n```move\npublic struct Point has copy, drop {\n x: u64,\n y: u64,\n}\n\npublic fun new_point(x: u64, y: u64): Point {\n Point { x, y }\n}\n\npublic fun x(p: &Point): u64 { p.x }\npublic fun y(p: &Point): u64 { p.y }\n```\n\nNo setters — once created, a Point is fixed.\n\n### Pattern 2: Builder Pattern\n\nFor complex structs with many optional fields:\n\n```move\npublic struct Config has copy, drop {\n timeout: u64,\n retries: u64,\n debug: bool,\n}\n\npublic fun default_config(): Config {\n Config {\n timeout: 1000,\n retries: 3,\n debug: false,\n }\n}\n\npublic fun with_timeout(config: Config, timeout: u64): Config {\n Config { timeout, ..config }\n}\n\npublic fun with_debug(config: Config, debug: bool): Config {\n Config { debug, ..config }\n}\n```\n\nWait — Move doesn't have `..config` spread syntax! Here's how you actually do it:\n\n```move\npublic fun with_timeout(config: Config, timeout: u64): Config {\n Config {\n timeout,\n retries: config.retries,\n debug: config.debug,\n }\n}\n```\n\n### Pattern 3: Newtype Pattern\n\nWrap primitives to add type safety:\n\n```move\npublic struct UserId has copy, drop, store {\n value: u64,\n}\n\npublic struct PostId has copy, drop, store {\n value: u64,\n}\n\n// Now you can't accidentally pass a UserId where a PostId is expected!\npublic fun get_post(post_id: PostId): Post { ... }\n```\n\n\n\n## Complete Solution\n\nHere's my comprehensive Day 3 implementation:\n\n```move\n/// DAY 3: Structs & Abilities - Complete Solution\n///\n/// This demonstrates:\n/// - Struct definitions with various ability combinations\n/// - Constructor patterns (new_*, default_*, from_*)\n/// - Accessor and mutator functions\n/// - Destructuring and field access\n/// - Common struct patterns\n\nmodule challenge::day_03 {\n // ═══════════════════════════════════════════════════════════════\n // IMPORTS\n // ═══════════════════════════════════════════════════════════════\n\n #[test_only]\n use std::unit_test::assert_eq;\n\n // ═══════════════════════════════════════════════════════════════\n // STRUCTS\n // ═══════════════════════════════════════════════════════════════\n\n /// A simple habit tracker\n /// - copy: Can be duplicated\n /// - drop: Can go out of scope\n public struct Habit has copy, drop {\n name: vector\u003Cu8>,\n completed: bool,\n }\n\n /// A more detailed habit with streak tracking\n public struct DetailedHabit has copy, drop {\n name: vector\u003Cu8>,\n description: vector\u003Cu8>,\n completed: bool,\n streak: u64,\n total_completions: u64,\n }\n\n /// A 2D point (immutable data pattern)\n public struct Point has copy, drop {\n x: u64,\n y: u64,\n }\n\n /// Configuration with defaults (builder pattern)\n public struct Config has copy, drop {\n max_habits: u64,\n reminder_enabled: bool,\n streak_goal: u64,\n }\n\n /// Wrapper for type safety (newtype pattern)\n public struct HabitId has copy, drop, store {\n value: u64,\n }\n\n // ═══════════════════════════════════════════════════════════════\n // CONSTRUCTORS\n // ═══════════════════════════════════════════════════════════════\n\n /// Creates a new habit (required by Day 3 challenge)\n public fun new_habit(name: vector\u003Cu8>): Habit {\n Habit {\n name,\n completed: false,\n }\n }\n\n /// Creates a habit that's already completed\n public fun new_completed_habit(name: vector\u003Cu8>): Habit {\n Habit {\n name,\n completed: true,\n }\n }\n\n /// Creates a detailed habit with all fields\n public fun new_detailed_habit(\n name: vector\u003Cu8>,\n description: vector\u003Cu8>,\n ): DetailedHabit {\n DetailedHabit {\n name,\n description,\n completed: false,\n streak: 0,\n total_completions: 0,\n }\n }\n\n /// Creates a new point\n public fun new_point(x: u64, y: u64): Point {\n Point { x, y }\n }\n\n /// Creates default configuration\n public fun default_config(): Config {\n Config {\n max_habits: 10,\n reminder_enabled: true,\n streak_goal: 21,\n }\n }\n\n /// Creates a HabitId\n public fun new_habit_id(value: u64): HabitId {\n HabitId { value }\n }\n\n // ═══════════════════════════════════════════════════════════════\n // ACCESSORS (Getters)\n // ═══════════════════════════════════════════════════════════════\n\n /// Gets the habit name (by reference)\n public fun habit_name(habit: &Habit): &vector\u003Cu8> {\n &habit.name\n }\n\n /// Checks if habit is completed\n public fun is_completed(habit: &Habit): bool {\n habit.completed\n }\n\n /// Gets point x coordinate\n public fun point_x(p: &Point): u64 {\n p.x\n }\n\n /// Gets point y coordinate\n public fun point_y(p: &Point): u64 {\n p.y\n }\n\n /// Gets the streak from a detailed habit\n public fun get_streak(habit: &DetailedHabit): u64 {\n habit.streak\n }\n\n /// Gets total completions\n public fun get_total_completions(habit: &DetailedHabit): u64 {\n habit.total_completions\n }\n\n /// Gets the raw value from HabitId\n public fun habit_id_value(id: &HabitId): u64 {\n id.value\n }\n\n // ═══════════════════════════════════════════════════════════════\n // MUTATORS (Setters)\n // ═══════════════════════════════════════════════════════════════\n\n /// Marks a habit as complete\n public fun complete_habit(habit: &mut Habit) {\n habit.completed = true;\n }\n\n /// Resets a habit to incomplete\n public fun reset_habit(habit: &mut Habit) {\n habit.completed = false;\n }\n\n /// Renames a habit\n public fun rename_habit(habit: &mut Habit, new_name: vector\u003Cu8>) {\n habit.name = new_name;\n }\n\n /// Completes a detailed habit and updates stats\n public fun complete_detailed_habit(habit: &mut DetailedHabit) {\n if (!habit.completed) {\n habit.completed = true;\n habit.streak = habit.streak + 1;\n habit.total_completions = habit.total_completions + 1;\n }\n }\n\n /// Resets daily status (keeps streak if was completed)\n public fun reset_daily(habit: &mut DetailedHabit) {\n if (!habit.completed) {\n // Missed a day, reset streak\n habit.streak = 0;\n };\n habit.completed = false;\n }\n\n // ═══════════════════════════════════════════════════════════════\n // UTILITY FUNCTIONS\n // ═══════════════════════════════════════════════════════════════\n\n /// Unpacks a habit into its components\n public fun unpack_habit(habit: Habit): (vector\u003Cu8>, bool) {\n let Habit { name, completed } = habit;\n (name, completed)\n }\n\n /// Calculates distance between two points\n public fun distance_squared(p1: &Point, p2: &Point): u64 {\n let dx = if (p1.x > p2.x) { p1.x - p2.x } else { p2.x - p1.x };\n let dy = if (p1.y > p2.y) { p1.y - p2.y } else { p2.y - p1.y };\n dx * dx + dy * dy\n }\n\n /// Creates a new config with custom max_habits\n public fun config_with_max_habits(config: Config, max_habits: u64): Config {\n Config {\n max_habits,\n reminder_enabled: config.reminder_enabled,\n streak_goal: config.streak_goal,\n }\n }\n\n /// Creates a new config with custom streak_goal\n public fun config_with_streak_goal(config: Config, streak_goal: u64): Config {\n Config {\n max_habits: config.max_habits,\n reminder_enabled: config.reminder_enabled,\n streak_goal,\n }\n }\n\n // ═══════════════════════════════════════════════════════════════\n // TESTS - Basic Struct Operations\n // ═══════════════════════════════════════════════════════════════\n\n #[test]\n fun test_new_habit() {\n let habit = new_habit(b\"Exercise\");\n assert_eq!(is_completed(&habit), false);\n }\n\n #[test]\n fun test_habit_name() {\n let habit = new_habit(b\"Read\");\n assert_eq!(*habit_name(&habit), b\"Read\");\n }\n\n #[test]\n fun test_complete_habit() {\n let mut habit = new_habit(b\"Meditate\");\n assert_eq!(is_completed(&habit), false);\n\n complete_habit(&mut habit);\n assert_eq!(is_completed(&habit), true);\n }\n\n #[test]\n fun test_reset_habit() {\n let mut habit = new_completed_habit(b\"Code\");\n assert_eq!(is_completed(&habit), true);\n\n reset_habit(&mut habit);\n assert_eq!(is_completed(&habit), false);\n }\n\n #[test]\n fun test_rename_habit() {\n let mut habit = new_habit(b\"Run\");\n rename_habit(&mut habit, b\"Jog\");\n assert_eq!(*habit_name(&habit), b\"Jog\");\n }\n\n #[test]\n fun test_unpack_habit() {\n let habit = new_completed_habit(b\"Sleep\");\n let (name, completed) = unpack_habit(habit);\n assert_eq!(name, b\"Sleep\");\n assert_eq!(completed, true);\n }\n\n // ═══════════════════════════════════════════════════════════════\n // TESTS - Detailed Habit\n // ═══════════════════════════════════════════════════════════════\n\n #[test]\n fun test_detailed_habit_creation() {\n let habit = new_detailed_habit(b\"Learn Move\", b\"Daily coding practice\");\n assert_eq!(get_streak(&habit), 0);\n assert_eq!(get_total_completions(&habit), 0);\n }\n\n #[test]\n fun test_detailed_habit_completion() {\n let mut habit = new_detailed_habit(b\"Exercise\", b\"30 min workout\");\n\n complete_detailed_habit(&mut habit);\n assert_eq!(get_streak(&habit), 1);\n assert_eq!(get_total_completions(&habit), 1);\n\n // Completing again same day should not increase\n complete_detailed_habit(&mut habit);\n assert_eq!(get_streak(&habit), 1);\n assert_eq!(get_total_completions(&habit), 1);\n }\n\n #[test]\n fun test_streak_continues() {\n let mut habit = new_detailed_habit(b\"Read\", b\"Read 20 pages\");\n\n // Day 1\n complete_detailed_habit(&mut habit);\n reset_daily(&mut habit);\n assert_eq!(get_streak(&habit), 1);\n\n // Day 2\n complete_detailed_habit(&mut habit);\n reset_daily(&mut habit);\n assert_eq!(get_streak(&habit), 2);\n\n // Day 3\n complete_detailed_habit(&mut habit);\n assert_eq!(get_streak(&habit), 3);\n assert_eq!(get_total_completions(&habit), 3);\n }\n\n #[test]\n fun test_streak_breaks() {\n let mut habit = new_detailed_habit(b\"Meditate\", b\"10 min\");\n\n // Day 1 - complete\n complete_detailed_habit(&mut habit);\n reset_daily(&mut habit);\n assert_eq!(get_streak(&habit), 1);\n\n // Day 2 - miss (don't complete before reset)\n reset_daily(&mut habit);\n assert_eq!(get_streak(&habit), 0);\n\n // Day 3 - start again\n complete_detailed_habit(&mut habit);\n assert_eq!(get_streak(&habit), 1);\n }\n\n // ═══════════════════════════════════════════════════════════════\n // TESTS - Point Operations\n // ═══════════════════════════════════════════════════════════════\n\n #[test]\n fun test_point_creation() {\n let p = new_point(10, 20);\n assert_eq!(point_x(&p), 10);\n assert_eq!(point_y(&p), 20);\n }\n\n #[test]\n fun test_distance_same_point() {\n let p = new_point(5, 5);\n assert_eq!(distance_squared(&p, &p), 0);\n }\n\n #[test]\n fun test_distance_calculation() {\n let p1 = new_point(0, 0);\n let p2 = new_point(3, 4);\n // Distance squared = 3^2 + 4^2 = 9 + 16 = 25\n assert_eq!(distance_squared(&p1, &p2), 25);\n }\n\n // ═══════════════════════════════════════════════════════════════\n // TESTS - Config Operations\n // ═══════════════════════════════════════════════════════════════\n\n #[test]\n fun test_default_config() {\n let config = default_config();\n assert_eq!(config.max_habits, 10);\n assert_eq!(config.reminder_enabled, true);\n assert_eq!(config.streak_goal, 21);\n }\n\n #[test]\n fun test_config_builder() {\n let config = default_config();\n let config = config_with_max_habits(config, 20);\n let config = config_with_streak_goal(config, 30);\n\n assert_eq!(config.max_habits, 20);\n assert_eq!(config.streak_goal, 30);\n assert_eq!(config.reminder_enabled, true); // Unchanged\n }\n\n // ═══════════════════════════════════════════════════════════════\n // TESTS - HabitId (Newtype Pattern)\n // ═══════════════════════════════════════════════════════════════\n\n #[test]\n fun test_habit_id() {\n let id = new_habit_id(42);\n assert_eq!(habit_id_value(&id), 42);\n }\n\n #[test]\n fun test_copy_ability() {\n let habit = new_habit(b\"Test\");\n let habit_copy = habit; // Copy happens here\n\n // Both are valid\n assert_eq!(is_completed(&habit), false);\n assert_eq!(is_completed(&habit_copy), false);\n }\n}\n```\n\n## Running the Tests\n\n```bash\ncd day_03\nsui move test\n```\n\nExpected output:\n\n```\nRunning Move unit tests\n[ PASS ] challenge::day_03::test_complete_habit\n[ PASS ] challenge::day_03::test_config_builder\n[ PASS ] challenge::day_03::test_copy_ability\n[ PASS ] challenge::day_03::test_default_config\n[ PASS ] challenge::day_03::test_detailed_habit_completion\n[ PASS ] challenge::day_03::test_detailed_habit_creation\n[ PASS ] challenge::day_03::test_distance_calculation\n[ PASS ] challenge::day_03::test_distance_same_point\n[ PASS ] challenge::day_03::test_habit_id\n[ PASS ] challenge::day_03::test_habit_name\n[ PASS ] challenge::day_03::test_new_habit\n[ PASS ] challenge::day_03::test_point_creation\n[ PASS ] challenge::day_03::test_rename_habit\n[ PASS ] challenge::day_03::test_reset_habit\n[ PASS ] challenge::day_03::test_streak_breaks\n[ PASS ] challenge::day_03::test_streak_continues\n[ PASS ] challenge::day_03::test_unpack_habit\nTest result: OK. 17 passed; 0 failed\n```\n\n## Try It Live with Sui CLI Web\n\nAfter running tests, let's **deploy and interact** with your Habit struct on-chain!\n\n### Step 1: Deploy Your Package\n\nUse the **One-Click Workflow** in Sui CLI Web:\n\n1. Open [cli.firstmovers.io](https://cli.firstmovers.io)\n2. Navigate to **Move Dev Studio** (`/app/move`)\n3. Click **Build → Test → Publish**\n\n\u003Cfigure className=\"my-8\">\n \u003Cimg\n src=\"/blog/images/posts/21-day-sui-challenge/day-3-deploy.png\"\n alt=\"Deploy Day 3 package with One-Click Workflow\"\n className=\"rounded-xl border border-white/10 w-full\"\n />\n \u003Cfigcaption className=\"text-center text-white/50 text-sm mt-3\">\n One-Click Workflow: Build, Test, and Publish in one step\n \u003C/figcaption>\n\u003C/figure>\n\n### Step 2: Load Your Package\n\nAfter publishing, switch to the **Interact** tab:\n\n1. Copy your **Package ID** from the publish result\n2. Paste it in \"Load Package\"\n3. Click **Load** — you'll see your modules listed!\n\n\u003Cfigure className=\"my-8\">\n \u003Cimg\n src=\"/blog/images/posts/21-day-sui-challenge/day-3-load-package.png\"\n alt=\"Load published package in Interact tab\"\n className=\"rounded-xl border border-white/10 w-full\"\n />\n \u003Cfigcaption className=\"text-center text-white/50 text-sm mt-3\">\n Package loaded with day_03 and day_03_solution modules\n \u003C/figcaption>\n\u003C/figure>\n\n### Step 3: Call `new_habit` Function\n\nLet's create a new habit on-chain:\n\n1. Expand **day_03_solution** module\n2. Click **new_habit** function\n3. Fill in parameters:\n - `name` (vector<u8>): Type `Exercise` and click **Convert**\n4. Click **Call Function**\n\n\u003Cfigure className=\"my-8\">\n \u003Cimg\n src=\"/blog/images/posts/21-day-sui-challenge/day-3-new-habit.png\"\n alt=\"Calling new_habit function\"\n className=\"rounded-xl border border-white/10 w-full\"\n />\n \u003Cfigcaption className=\"text-center text-white/50 text-sm mt-3\">\n Creating a new Habit with name \"Exercise\"\n \u003C/figcaption>\n\u003C/figure>\n\n### Step 4: Call `create_habit` with Custom Status\n\nTry creating a habit that's already completed:\n\n1. Click **create_habit** function\n2. Fill in parameters:\n - `name` (vector<u8>): Type `Meditate` → **Convert**\n - `completed` (bool): Select **true**\n3. Click **Call Function**\n\n\u003Cfigure className=\"my-8\">\n \u003Cimg\n src=\"/blog/images/posts/21-day-sui-challenge/day-3-create-habit.png\"\n alt=\"Calling create_habit with completed=true\"\n className=\"rounded-xl border border-white/10 w-full\"\n />\n \u003Cfigcaption className=\"text-center text-white/50 text-sm mt-3\">\n Creating a pre-completed habit using create_habit\n \u003C/figcaption>\n\u003C/figure>\n\n> **💡 Tip:** The `vector\u003Cu8>` type expects bytes. Sui CLI Web's **Convert** button automatically converts your text to the correct byte format!\n\n## Key Takeaways\n\n\u003CRevealOnScroll client:visible>\n\n### 1. Structs Group Related Data\n\n```move\npublic struct Habit has copy, drop {\n name: vector\u003Cu8>,\n completed: bool,\n}\n```\n\n### 2. Abilities Control What You Can Do\n\n- `copy` — Value can be duplicated\n- `drop` — Value can be discarded\n- `store` — Can be stored in other structs\n- `key` — Can be a Sui object (Day 7!)\n\n### 3. Convention: `new_*` Constructors\n\n```move\npublic fun new_habit(name: vector\u003Cu8>): Habit {\n Habit { name, completed: false }\n}\n```\n\n### 4. References for Access\n\n- `&Habit` — Read-only borrow\n- `&mut Habit` — Mutable borrow\n\n### 5. Destructuring Unpacks Structs\n\n```move\nlet Habit { name, completed } = habit;\n```\n\n\u003C/RevealOnScroll>\n\n## What's Next\n\n\u003CRevealOnScroll client:visible>\n\nTomorrow in **Day 4**, we dive deeper into the ability system:\n\n- What happens when a struct *doesn't* have `drop`?\n- The `store` ability and nested structs\n- Preparing for Sui objects with `key`\n\nStructs are the foundation. Abilities determine what they can become.\n\n\u003CAccordionCTA\n client:visible\n icon=\"🔗\"\n title=\"Continue the Challenge\"\n description=\"Day 4 explores abilities in depth. Critical knowledge for building Sui objects.\"\n link=\"https://github.com/riva-labs/21Challenge/tree/main/day_04\"\n linkText=\"View Day 4\"\n/>\n\n\u003C/RevealOnScroll>\n\n## Commit Your Progress\n\n```bash\ncd day_03\nsui move test\ngit add day_03/\ngit commit -m \"Day 3: add Habit struct and constructor\"\n```\n\n**Day 3 complete.** You've learned how to define custom data types, control their behavior with abilities, and follow Move conventions for constructors and accessors.\n\nSee you on Day 4.\n\n*Follow: [@ercandotsui](https://x.com/ercandotsui) | [@harry_phan06](https://x.com/harry_phan06)*\n\n## 🇻🇳 Vietnamese Builders: Level Up Your Journey\n\n\u003CRevealOnScroll client:visible>\n\n**First Movers Sprint 2026 is here!**\n\nFollowing the success of CommandOSS Hacker House HCMC, First Movers Sprint 2026 is designed as a stepping-stone Hacker House — a space for builders in the Sui ecosystem to sharpen their skills, accelerate product development, and get fully prepared for the next CommandOSS Hacker House.\n\nWhether you're a developer or a non-technical business/growth builder, you're welcome to join!\n\n\u003Cfigure className=\"my-8\">\n \u003CBrandedImage\n client:visible\n src=\"/blog/images/posts/21-day-sui-challenge/first-movers-sprint.png\"\n alt=\"First Movers Sprint 2026 Hackathon\"\n width={800}\n height={800}\n watermark=\"firstmovers.io\"\n />\n\u003C/figure>\n\n**Why join First Movers Sprint?**\n\n- 💼 **Paid internship** at top Sui ecosystem projects (full-time)\n- 🏆 **Exciting prizes** and rewards\n- 🌟 **Learn directly** with top mentors from the Sui ecosystem\n\nCo-organized by [@firstmoversvn](https://x.com/firstmoversvn), [@0xCommandOSS](https://x.com/0xCommandOSS), and ITviec — the leading IT recruitment platform.\n\n\u003CAccordionCTA\n client:visible\n icon=\"🚀\"\n title=\"Join First Movers Sprint 2026\"\n description=\"A stepping-stone Hacker House for Vietnamese builders in the Sui ecosystem. Sharpen your skills, accelerate your product, and prepare for CommandOSS.\"\n link=\"https://luma.com/first-movers-sprint\"\n linkText=\"Apply Now\"\n/>\n\n\u003C/RevealOnScroll>","src/content/posts/21-day-sui-challenge-day-3.mdx","2591aae911e6b344","21-day-sui-challenge-day-3.mdx","21-day-sui-challenge-day-4",{"id":172,"data":174,"body":201,"filePath":202,"digest":203,"legacyId":204,"deferredRender":26},{"title":175,"description":176,"publishDate":177,"category":17,"tags":178,"author":25,"heroImage":182,"draft":61,"sections":183},"21-Day Sui Challenge Day 4: Vectors & Ownership","Learn dynamic collections with vectors and understand Move's ownership model. Build a HabitList that grows, shrinks, and teaches you memory safety.",["Date","2025-12-23T00:00:00.000Z"],[73,74,179,180,111,181,19],"vectors","ownership","day-4","/blog/images/posts/21-day-sui-challenge/day-4-hero.png",[184,187,190,193,196,197,198,199],{"id":185,"title":186},"vectors-dynamic-lists","Vectors: Dynamic Lists",{"id":188,"title":189},"ownership-the-big-idea","Ownership: The Big Idea",{"id":191,"title":192},"from-bytes-to-strings","From Bytes to Strings",{"id":194,"title":195},"building-habitlist","Building HabitList",{"id":128,"title":129},{"id":164,"title":165},{"id":131,"title":132},{"id":96,"title":200},"First Movers Sprint","import { AccordionCTA } from '../../components/react/BouncyAccordion';\nimport { RevealOnScroll } from '../../components/react/TextReveal';\nimport { BrandedImage } from '../../components/react/BrandedImage';\n\n**Day 4 of the 21-Day Sui Challenge.**\n\nYou've learned structs and abilities. Now it's time to make things *grow*. Vectors let you store collections of data, and ownership determines who controls that data. Together, they form the foundation for every real application you'll build on Sui.\n\n\u003CAccordionCTA\n client:visible\n icon=\"📚\"\n title=\"Day 4 Challenge\"\n description=\"Build a HabitList using vectors. Learn ownership by adding, removing, and iterating over habits.\"\n link=\"https://github.com/riva-labs/21Challenge/tree/main/day_04\"\n linkText=\"View on GitHub\"\n/>\n\n\n\n## Vectors: Dynamic Lists\n\nA **vector** is a built-in type in Move that stores a collection of elements. Unlike arrays in other languages, vectors can grow and shrink at runtime. The vector type doesn't need to be imported from a module.\n\n### Vector Literals (Move 2024)\n\nThe cleanest way to create vectors:\n\n```move\n// Empty vector with explicit type\nlet empty: vector\u003Cbool> = vector[];\n\n// Vector with initial values\nlet numbers: vector\u003Cu8> = vector[10, 20, 30];\n\n// Nested vectors\nlet matrix: vector\u003Cvector\u003Cu8>> = vector[\n vector[1, 2],\n vector[3, 4]\n];\n\n// Byte vectors (strings)\nlet hello = b\"Hello\"; // vector\u003Cu8>\n```\n\n### Common Vector Operations\n\nThe `std::vector` module provides these standard operations:\n\n| Operation | What It Does |\n|-----------|--------------|\n| `push_back` | Adds an element to the end of the vector |\n| `pop_back` | Removes the last element from the vector |\n| `length` | Returns the number of elements in the vector |\n| `is_empty` | Returns true if the vector is empty |\n| `remove` | Removes an element at a given index |\n| `borrow` | Gets a read-only reference to an element |\n| `borrow_mut` | Gets a mutable reference to an element |\n\n**Example usage:**\n\n```move\nlet mut v = vector[10u8, 20, 30];\n\n// Check properties\nassert!(v.length() == 3);\nassert!(!v.is_empty());\n\n// Add and remove\nv.push_back(40);\nlet last = v.pop_back(); // returns 40\n\n// Access elements\nlet first = v.borrow(0); // &u8, read-only\n*v.borrow_mut(0) = 99; // modify in place\n```\n\n### Index Syntax (Move 2024)\n\nMove 2024 introduced borrowing operators that make vector access cleaner:\n\n```move\nlet v = vector[10, 20, 30];\n\n// These are equivalent:\nlet first = &v[0]; // calls vector::borrow(v, 0)\nlet first = v.borrow(0);\n\n// Mutable access:\nlet first_mut = &mut v[0]; // calls vector::borrow_mut(v, 0)\n\n// Copy value (if type has copy):\nlet first_copy = v[0]; // calls *vector::borrow(v, 0)\n```\n\n### Ability Inheritance\n\nVectors inherit abilities from their element type. This is important:\n\n```move\n// u64 has copy and drop, so vector\u003Cu64> does too\nlet v1: vector\u003Cu64> = vector[1, 2, 3];\nlet v2 = v1; // Copy works\n\n// But if elements don't have copy...\n// vector\u003CSomeNonCopyType> won't have copy either\n```\n\nThis is Move protecting you. You cannot accidentally duplicate a vector of NFTs because NFTs don't have `copy`.\n\n\n\n## Ownership: The Big Idea\n\nHere's the concept that makes Move special: **every value has exactly one owner at a time.**\n\nReferences are a way to *show* a value to a function without giving up ownership. When you create a reference, you're \"borrowing\" the value.\n\n### Immutable References (&)\n\nUse `&` when you need to read but not modify:\n\n```move\npublic fun is_valid(card: &Card): bool {\n card.uses > 0\n}\n\n// Usage\nlet card = new_card();\nlet valid = is_valid(&card); // borrow immutably\n// card is still valid here\n```\n\nThe function cannot modify `card`. The original owner retains control after the call.\n\n### Mutable References (&mut)\n\nUse `&mut` when you need to modify the value:\n\n```move\npublic fun use_card(card: &mut Card) {\n assert!(card.uses > 0, ENoUses);\n card.uses = card.uses - 1;\n}\n\n// Usage\nlet mut card = new_card();\nuse_card(&mut card); // borrow mutably\n// card is still valid, but modified\n```\n\nThe function can read and modify `card`. The original owner keeps ownership.\n\n### Pass by Value (Ownership Transfer)\n\nWhen you omit `&` or `&mut`, ownership transfers:\n\n```move\npublic fun recycle(card: Card) {\n // This function now owns card\n let Card { uses: _ } = card;\n // card is destroyed here\n}\n\n// Usage\nlet card = new_card();\nrecycle(card);\n// card is NO LONGER VALID here\n// Using it would cause a compile error\n```\n\n### The Borrowing Rules\n\n1. **One owner**: Every value has exactly one owner at a time\n2. **Move by default**: Passing by value transfers ownership\n3. **Borrow to share**: Use `&` for read-only, `&mut` for read-write\n4. **No dangling references**: References must remain valid\n\nThese rules prevent entire categories of bugs:\n- No use-after-free\n- No double-free\n- No data races\n- No dangling pointers\n\n\n\n## From Bytes to Strings\n\nDay 3 used `vector\u003Cu8>` for text. Day 4 introduces the `String` type, which is cleaner and safer.\n\n### String Structure\n\nMove doesn't have a native string type, but the standard library provides one. It's essentially a wrapped byte vector with UTF-8 validation:\n\n```move\n// In std::string module\npublic struct String has copy, drop, store {\n bytes: vector\u003Cu8>,\n}\n```\n\n### Creating Strings\n\n```move\nuse std::string::{Self, String};\n\n// From bytes using utf8\nlet hello: String = string::utf8(b\"Hello\");\n\n// Convenient method syntax\nlet world = b\"World\".to_string();\n\n// Safe creation with validation\nlet maybe_str = b\"Hello\".try_to_string(); // Returns Option\u003CString>\nassert!(maybe_str.is_some());\n\nlet invalid = b\"\\xFF\".try_to_string(); // Invalid UTF-8\nassert!(invalid.is_none());\n```\n\n### String Operations\n\n| Operation | Example | Description |\n|-----------|---------|-------------|\n| `length` | `str.length()` | Returns byte count (not characters!) |\n| `is_empty` | `str.is_empty()` | Returns true if empty |\n| `append` | `str.append(other)` | Concatenates another string |\n| `sub_string` | `str.sub_string(0, 5)` | Extracts a substring |\n| `bytes` | `str.bytes()` | Gets `&vector\u003Cu8>` |\n\n**Important:** String length is in *bytes*, not characters. UTF-8 uses variable-length encoding (1-4 bytes per character).\n\n### Why Use String Over vector<u8>\n\n1. **Semantic clarity**: `String` clearly indicates text data\n2. **UTF-8 validation**: Invalid encodings are caught early\n3. **Better API**: More string-specific operations\n4. **Industry standard**: Recommended for new Move code\n\n\n\n## Building HabitList\n\nLet's combine vectors, ownership, and strings to build something practical.\n\n### The Structs\n\n```move\npublic struct Habit has copy, drop, store {\n name: String,\n completed: bool,\n}\n\npublic struct HabitList has drop {\n habits: vector\u003CHabit>,\n}\n```\n\nNotice `HabitList` has `drop` but not `copy`. You can destroy it, but you cannot duplicate it. This prevents copies of your tracker floating around with stale data.\n\n### Constructor\n\n```move\npublic fun empty_list(): HabitList {\n HabitList {\n habits: vector::empty(),\n }\n}\n```\n\n### Adding Habits\n\nHere ownership gets interesting:\n\n```move\npublic fun add_habit(list: &mut HabitList, habit: Habit) {\n vector::push_back(&mut list.habits, habit);\n}\n\n// Usage:\nlet mut list = empty_list();\nlet habit = new_habit(string::utf8(b\"Exercise\"));\nadd_habit(&mut list, habit);\n// habit ownership moved into the list\n```\n\nWe borrow `list` mutably so we keep ownership of the list. But we take `habit` by value, so it moves into the vector.\n\n### Getting Habits\n\n```move\n// Borrow a habit (read-only)\npublic fun get_habit(list: &HabitList, index: u64): &Habit {\n vector::borrow(&list.habits, index)\n}\n\n// Get the count\npublic fun habit_count(list: &HabitList): u64 {\n vector::length(&list.habits)\n}\n```\n\n### Modifying Habits\n\n```move\npublic fun complete_habit(list: &mut HabitList, index: u64) {\n let habit = vector::borrow_mut(&mut list.habits, index);\n habit.completed = true;\n}\n```\n\nWe borrow the list mutably, then borrow the specific habit mutably, then modify it. The chain of `&mut` flows through each level.\n\n### Removing Habits\n\n```move\n// Remove and return the last habit\npublic fun remove_last(list: &mut HabitList): Habit {\n vector::pop_back(&mut list.habits)\n}\n\n// Remove at index (swap with last, then pop)\npublic fun remove_at(list: &mut HabitList, index: u64): Habit {\n vector::swap_remove(&mut list.habits, index)\n}\n```\n\n`swap_remove` is O(1). It swaps the target element with the last one, then pops. Use this when order doesn't matter.\n\n\n\n## Complete Solution\n\nHere's the full Day 4 implementation with comprehensive tests:\n\n```move\n/// DAY 4: Vectors & Ownership\n///\n/// Learn:\n/// - Vector creation and manipulation\n/// - String type for text data\n/// - Ownership and borrowing patterns\n/// - Building collection-based structs\n\nmodule challenge::day_04 {\n use std::string::{Self, String};\n use std::vector;\n\n #[test_only]\n use std::unit_test::assert_eq;\n\n // ═══════════════════════════════════════════════════════════════\n // CONSTANTS\n // ═══════════════════════════════════════════════════════════════\n\n const E_INDEX_OUT_OF_BOUNDS: u64 = 1001;\n const E_EMPTY_LIST: u64 = 1002;\n const MAX_HABITS: u64 = 100;\n const E_LIST_FULL: u64 = 1003;\n\n // ═══════════════════════════════════════════════════════════════\n // STRUCTS\n // ═══════════════════════════════════════════════════════════════\n\n /// A habit with String name\n /// copy: Can duplicate\n /// drop: Can discard\n /// store: Can be stored in other structs\n public struct Habit has copy, drop, store {\n name: String,\n completed: bool,\n }\n\n /// A collection of habits\n /// drop: Can discard (no copy prevents accidental duplication)\n public struct HabitList has drop {\n habits: vector\u003CHabit>,\n }\n\n // ═══════════════════════════════════════════════════════════════\n // CONSTRUCTORS\n // ═══════════════════════════════════════════════════════════════\n\n public fun new_habit(name: String): Habit {\n Habit { name, completed: false }\n }\n\n public fun new_habit_from_bytes(name_bytes: vector\u003Cu8>): Habit {\n new_habit(string::utf8(name_bytes))\n }\n\n public fun empty_list(): HabitList {\n HabitList { habits: vector::empty() }\n }\n\n // ═══════════════════════════════════════════════════════════════\n // ACCESSORS\n // ═══════════════════════════════════════════════════════════════\n\n public fun habit_name(habit: &Habit): &String {\n &habit.name\n }\n\n public fun is_completed(habit: &Habit): bool {\n habit.completed\n }\n\n public fun habit_count(list: &HabitList): u64 {\n vector::length(&list.habits)\n }\n\n public fun is_empty(list: &HabitList): bool {\n vector::is_empty(&list.habits)\n }\n\n public fun get_habit(list: &HabitList, index: u64): &Habit {\n assert!(index \u003C habit_count(list), E_INDEX_OUT_OF_BOUNDS);\n vector::borrow(&list.habits, index)\n }\n\n public fun completed_count(list: &HabitList): u64 {\n let mut count = 0;\n let len = habit_count(list);\n let mut i = 0;\n while (i \u003C len) {\n if (vector::borrow(&list.habits, i).completed) {\n count = count + 1;\n };\n i = i + 1;\n };\n count\n }\n\n public fun completion_percentage(list: &HabitList): u64 {\n let total = habit_count(list);\n if (total == 0) return 100;\n (completed_count(list) * 100) / total\n }\n\n // ═══════════════════════════════════════════════════════════════\n // MUTATORS\n // ═══════════════════════════════════════════════════════════════\n\n public fun add_habit(list: &mut HabitList, habit: Habit) {\n assert!(habit_count(list) \u003C MAX_HABITS, E_LIST_FULL);\n vector::push_back(&mut list.habits, habit);\n }\n\n public fun add_habit_by_name(list: &mut HabitList, name: String) {\n add_habit(list, new_habit(name));\n }\n\n public fun complete_habit(list: &mut HabitList, index: u64) {\n assert!(index \u003C habit_count(list), E_INDEX_OUT_OF_BOUNDS);\n vector::borrow_mut(&mut list.habits, index).completed = true;\n }\n\n public fun reset_habit(list: &mut HabitList, index: u64) {\n assert!(index \u003C habit_count(list), E_INDEX_OUT_OF_BOUNDS);\n vector::borrow_mut(&mut list.habits, index).completed = false;\n }\n\n public fun toggle_habit(list: &mut HabitList, index: u64) {\n assert!(index \u003C habit_count(list), E_INDEX_OUT_OF_BOUNDS);\n let habit = vector::borrow_mut(&mut list.habits, index);\n habit.completed = !habit.completed;\n }\n\n public fun remove_last(list: &mut HabitList): Habit {\n assert!(!is_empty(list), E_EMPTY_LIST);\n vector::pop_back(&mut list.habits)\n }\n\n public fun remove_at(list: &mut HabitList, index: u64): Habit {\n assert!(index \u003C habit_count(list), E_INDEX_OUT_OF_BOUNDS);\n vector::swap_remove(&mut list.habits, index)\n }\n\n public fun complete_all(list: &mut HabitList) {\n let len = habit_count(list);\n let mut i = 0;\n while (i \u003C len) {\n vector::borrow_mut(&mut list.habits, i).completed = true;\n i = i + 1;\n };\n }\n\n public fun clear(list: &mut HabitList) {\n list.habits = vector::empty();\n }\n\n // ═══════════════════════════════════════════════════════════════\n // UTILITY\n // ═══════════════════════════════════════════════════════════════\n\n public fun contains_habit(list: &HabitList, name: &String): bool {\n let len = habit_count(list);\n let mut i = 0;\n while (i \u003C len) {\n if (vector::borrow(&list.habits, i).name == *name) {\n return true\n };\n i = i + 1;\n };\n false\n }\n\n public fun unpack_habit(habit: Habit): (String, bool) {\n let Habit { name, completed } = habit;\n (name, completed)\n }\n\n // ═══════════════════════════════════════════════════════════════\n // TESTS\n // ═══════════════════════════════════════════════════════════════\n\n #[test]\n fun test_new_habit() {\n let habit = new_habit(string::utf8(b\"Exercise\"));\n assert_eq!(is_completed(&habit), false);\n assert_eq!(*habit_name(&habit), string::utf8(b\"Exercise\"));\n }\n\n #[test]\n fun test_empty_list() {\n let list = empty_list();\n assert_eq!(habit_count(&list), 0);\n assert!(is_empty(&list), 0);\n }\n\n #[test]\n fun test_add_and_get_habit() {\n let mut list = empty_list();\n add_habit(&mut list, new_habit(string::utf8(b\"Exercise\")));\n add_habit(&mut list, new_habit(string::utf8(b\"Read\")));\n\n assert_eq!(habit_count(&list), 2);\n assert_eq!(*habit_name(get_habit(&list, 1)), string::utf8(b\"Read\"));\n }\n\n #[test]\n fun test_complete_and_toggle() {\n let mut list = empty_list();\n add_habit(&mut list, new_habit(string::utf8(b\"Exercise\")));\n\n complete_habit(&mut list, 0);\n assert!(is_completed(get_habit(&list, 0)), 0);\n\n toggle_habit(&mut list, 0);\n assert!(!is_completed(get_habit(&list, 0)), 1);\n }\n\n #[test]\n fun test_remove_operations() {\n let mut list = empty_list();\n add_habit(&mut list, new_habit(string::utf8(b\"A\")));\n add_habit(&mut list, new_habit(string::utf8(b\"B\")));\n add_habit(&mut list, new_habit(string::utf8(b\"C\")));\n\n let removed = remove_at(&mut list, 0);\n assert_eq!(*habit_name(&removed), string::utf8(b\"A\"));\n assert_eq!(habit_count(&list), 2);\n }\n\n #[test]\n fun test_completion_stats() {\n let mut list = empty_list();\n add_habit(&mut list, new_habit(string::utf8(b\"A\")));\n add_habit(&mut list, new_habit(string::utf8(b\"B\")));\n add_habit(&mut list, new_habit(string::utf8(b\"C\")));\n add_habit(&mut list, new_habit(string::utf8(b\"D\")));\n\n complete_habit(&mut list, 0);\n complete_habit(&mut list, 1);\n\n assert_eq!(completed_count(&list), 2);\n assert_eq!(completion_percentage(&list), 50);\n }\n\n #[test]\n #[expected_failure(abort_code = E_INDEX_OUT_OF_BOUNDS)]\n fun test_get_invalid_index() {\n let list = empty_list();\n let _habit = get_habit(&list, 0);\n }\n\n #[test]\n #[expected_failure(abort_code = E_EMPTY_LIST)]\n fun test_remove_from_empty() {\n let mut list = empty_list();\n let _habit = remove_last(&mut list);\n }\n\n #[test]\n fun test_ownership_with_copy() {\n let habit = new_habit(string::utf8(b\"Exercise\"));\n let habit_copy = habit; // Habit has copy, so this works\n\n // Both are valid\n assert_eq!(is_completed(&habit), false);\n assert_eq!(is_completed(&habit_copy), false);\n }\n}\n```\n\n## Running the Tests\n\n```bash\ncd day_04\nsui move test\n```\n\nExpected output:\n\n```\nRunning Move unit tests\n[ PASS ] challenge::day_04::test_add_and_get_habit\n[ PASS ] challenge::day_04::test_complete_and_toggle\n[ PASS ] challenge::day_04::test_completion_stats\n[ PASS ] challenge::day_04::test_empty_list\n[ PASS ] challenge::day_04::test_get_invalid_index\n[ PASS ] challenge::day_04::test_new_habit\n[ PASS ] challenge::day_04::test_ownership_with_copy\n[ PASS ] challenge::day_04::test_remove_from_empty\n[ PASS ] challenge::day_04::test_remove_operations\nTest result: OK. 9 passed; 0 failed\n```\n\n## Try It Live with Sui CLI Web\n\nBuild and test your package using Move Dev Studio:\n\n1. Open [cli.firstmovers.io](https://cli.firstmovers.io)\n2. Navigate to **Move** section (`/app/move`)\n3. Click **Build** then **Test**\n\n\u003Cfigure className=\"my-8\">\n \u003Cimg\n src=\"/blog/images/posts/21-day-sui-challenge/day-4-test.png\"\n alt=\"Day 4 test results\"\n className=\"rounded-xl border border-white/10 w-full\"\n />\n \u003Cfigcaption className=\"text-center text-white/50 text-sm mt-3\">\n All tests passing in Move Dev Studio\n \u003C/figcaption>\n\u003C/figure>\n\n> **Note:** Day 4 functions don't have `entry`, so they can't be called from transactions yet. On-chain interaction comes in Day 7 when we create Sui objects.\n\n## Key Takeaways\n\n\u003CRevealOnScroll client:visible>\n\n### 1. Vectors Are Built-In Dynamic Arrays\n\n```move\nlet v = vector[1, 2, 3];\nv.push_back(4);\nlet len = v.length();\n```\n\n### 2. Ownership Is Exclusive\n\nEvery value has one owner. Pass by value to transfer, use references to borrow.\n\n```move\nfun consume(habit: Habit) { ... } // takes ownership\nfun read(habit: &Habit) { ... } // borrows immutably\nfun modify(habit: &mut Habit) { ... } // borrows mutably\n```\n\n### 3. Use String for Text\n\nThe `String` type validates UTF-8 and is cleaner than raw `vector\u003Cu8>`.\n\n```move\nlet s = string::utf8(b\"Hello\");\nlet s = b\"Hello\".to_string(); // Move 2024\n```\n\n### 4. Abilities Flow Through Collections\n\nVectors inherit abilities from elements. No `copy` on elements means no `copy` on vector.\n\n### 5. swap_remove Is O(1)\n\nWhen order doesn't matter, `swap_remove` beats regular removal.\n\n\u003C/RevealOnScroll>\n\n## What's Next\n\n\u003CRevealOnScroll client:visible>\n\nTomorrow in **Day 5**, we dive deep into references:\n\n- Immutable vs mutable borrows\n- Borrowing rules and safety guarantees\n- When to use each reference type\n\nDay 4 touched on references. Day 5 masters them.\n\n\u003CAccordionCTA\n client:visible\n icon=\"🔗\"\n title=\"Continue the Challenge\"\n description=\"Day 5 explores references in depth. Master borrowing to write efficient, safe code.\"\n link=\"https://github.com/riva-labs/21Challenge/tree/main/day_05\"\n linkText=\"View Day 5\"\n/>\n\n\u003C/RevealOnScroll>\n\n## Commit Your Progress\n\n```bash\ncd day_04\nsui move test\ngit add day_04/\ngit commit -m \"Day 4: vectors and ownership basics\"\n```\n\n**Day 4 complete.** You've learned dynamic collections and Move's ownership model. These concepts are foundational. Every Sui object you create will use them.\n\nSee you on Day 5.\n\n*Follow: [@ercandotsui](https://x.com/ercandotsui) | [@harry_phan06](https://x.com/harry_phan06)*\n\n## Vietnamese Builders: Level Up Your Journey\n\n\u003CRevealOnScroll client:visible>\n\n**First Movers Sprint 2026 is here!**\n\nFollowing the success of CommandOSS Hacker House HCMC, First Movers Sprint 2026 is designed as a stepping-stone Hacker House. A space for builders in the Sui ecosystem to sharpen their skills, accelerate product development, and prepare for the next CommandOSS Hacker House.\n\nWhether you're a developer or a non-technical business/growth builder, you're welcome to join!\n\n\u003Cfigure className=\"my-8\">\n \u003CBrandedImage\n client:visible\n src=\"/blog/images/posts/21-day-sui-challenge/first-movers-sprint.png\"\n alt=\"First Movers Sprint 2026 Hackathon\"\n width={800}\n height={800}\n watermark=\"firstmovers.io\"\n />\n\u003C/figure>\n\n**Why join First Movers Sprint?**\n\n- **Paid internship** at top Sui ecosystem projects (full-time)\n- **Exciting prizes** and rewards\n- **Learn directly** with top mentors from the Sui ecosystem\n\nCo-organized by [@firstmoversvn](https://x.com/firstmoversvn), [@0xCommandOSS](https://x.com/0xCommandOSS), and ITviec.\n\n\u003CAccordionCTA\n client:visible\n icon=\"🚀\"\n title=\"Join First Movers Sprint 2026\"\n description=\"A stepping-stone Hacker House for Vietnamese builders in the Sui ecosystem. Sharpen your skills, accelerate your product, and prepare for CommandOSS.\"\n link=\"https://luma.com/first-movers-sprint\"\n linkText=\"Apply Now\"\n/>\n\n\u003C/RevealOnScroll>","src/content/posts/21-day-sui-challenge-day-4.mdx","5f89c56c898a2a72","21-day-sui-challenge-day-4.mdx"] \ No newline at end of file diff --git a/apps/marketing/.astro/settings.json b/apps/marketing/.astro/settings.json deleted file mode 100644 index a84d8fb..0000000 --- a/apps/marketing/.astro/settings.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "_variables": { - "lastUpdateCheck": 1766113062680 - } -} \ No newline at end of file diff --git a/apps/marketing/.astro/types.d.ts b/apps/marketing/.astro/types.d.ts deleted file mode 100644 index 03d7cc4..0000000 --- a/apps/marketing/.astro/types.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -/// -/// \ No newline at end of file diff --git a/apps/marketing/astro.config.mjs b/apps/marketing/astro.config.mjs index 5d659e3..6e981a2 100644 --- a/apps/marketing/astro.config.mjs +++ b/apps/marketing/astro.config.mjs @@ -6,7 +6,7 @@ import react from '@astrojs/react'; // https://astro.build/config export default defineConfig({ - site: 'https://sui-cli-web-production.up.railway.app', + site: 'https://sui-cli.dev', base: '/blog', integrations: [ mdx(), diff --git a/apps/marketing/public/images/posts/21-day-sui-challenge/day-2-hero.png b/apps/marketing/public/images/posts/21-day-sui-challenge/day-2-hero.png new file mode 100644 index 0000000..b8af6e8 Binary files /dev/null and b/apps/marketing/public/images/posts/21-day-sui-challenge/day-2-hero.png differ diff --git a/apps/marketing/public/images/posts/21-day-sui-challenge/day-2-test-results.png b/apps/marketing/public/images/posts/21-day-sui-challenge/day-2-test-results.png new file mode 100644 index 0000000..b8af6e8 Binary files /dev/null and b/apps/marketing/public/images/posts/21-day-sui-challenge/day-2-test-results.png differ diff --git a/apps/marketing/public/images/posts/21-day-sui-challenge/day-3-create-habit.png b/apps/marketing/public/images/posts/21-day-sui-challenge/day-3-create-habit.png new file mode 100644 index 0000000..0680969 Binary files /dev/null and b/apps/marketing/public/images/posts/21-day-sui-challenge/day-3-create-habit.png differ diff --git a/apps/marketing/public/images/posts/21-day-sui-challenge/day-3-deploy.png b/apps/marketing/public/images/posts/21-day-sui-challenge/day-3-deploy.png new file mode 100644 index 0000000..0a06f89 Binary files /dev/null and b/apps/marketing/public/images/posts/21-day-sui-challenge/day-3-deploy.png differ diff --git a/apps/marketing/public/images/posts/21-day-sui-challenge/day-3-hero.png b/apps/marketing/public/images/posts/21-day-sui-challenge/day-3-hero.png new file mode 100644 index 0000000..4867591 Binary files /dev/null and b/apps/marketing/public/images/posts/21-day-sui-challenge/day-3-hero.png differ diff --git a/apps/marketing/public/images/posts/21-day-sui-challenge/day-3-load-package.png b/apps/marketing/public/images/posts/21-day-sui-challenge/day-3-load-package.png new file mode 100644 index 0000000..1111894 Binary files /dev/null and b/apps/marketing/public/images/posts/21-day-sui-challenge/day-3-load-package.png differ diff --git a/apps/marketing/public/images/posts/21-day-sui-challenge/day-3-new-habit.png b/apps/marketing/public/images/posts/21-day-sui-challenge/day-3-new-habit.png new file mode 100644 index 0000000..f44416d Binary files /dev/null and b/apps/marketing/public/images/posts/21-day-sui-challenge/day-3-new-habit.png differ diff --git a/apps/marketing/public/images/posts/21-day-sui-challenge/day-4-hero.png b/apps/marketing/public/images/posts/21-day-sui-challenge/day-4-hero.png new file mode 100644 index 0000000..4624d33 Binary files /dev/null and b/apps/marketing/public/images/posts/21-day-sui-challenge/day-4-hero.png differ diff --git a/apps/marketing/public/images/posts/21-day-sui-challenge/day-4-test.png b/apps/marketing/public/images/posts/21-day-sui-challenge/day-4-test.png new file mode 100644 index 0000000..4624d33 Binary files /dev/null and b/apps/marketing/public/images/posts/21-day-sui-challenge/day-4-test.png differ diff --git a/apps/marketing/public/images/posts/21-day-sui-challenge/day-5-hero.png b/apps/marketing/public/images/posts/21-day-sui-challenge/day-5-hero.png new file mode 100644 index 0000000..7083ae8 Binary files /dev/null and b/apps/marketing/public/images/posts/21-day-sui-challenge/day-5-hero.png differ diff --git a/apps/marketing/public/images/posts/21-day-sui-challenge/day-5-test.png b/apps/marketing/public/images/posts/21-day-sui-challenge/day-5-test.png new file mode 100644 index 0000000..7083ae8 Binary files /dev/null and b/apps/marketing/public/images/posts/21-day-sui-challenge/day-5-test.png differ diff --git a/apps/marketing/public/images/posts/21-day-sui-challenge/first-movers-sprint.png b/apps/marketing/public/images/posts/21-day-sui-challenge/first-movers-sprint.png new file mode 100644 index 0000000..252576e Binary files /dev/null and b/apps/marketing/public/images/posts/21-day-sui-challenge/first-movers-sprint.png differ diff --git a/apps/marketing/public/images/posts/21-day-sui-challenge/sui-cli-web-build.png b/apps/marketing/public/images/posts/21-day-sui-challenge/sui-cli-web-build.png new file mode 100644 index 0000000..36db6c2 Binary files /dev/null and b/apps/marketing/public/images/posts/21-day-sui-challenge/sui-cli-web-build.png differ diff --git a/apps/marketing/public/images/posts/21-day-sui-challenge/sui-cli-web-test.png b/apps/marketing/public/images/posts/21-day-sui-challenge/sui-cli-web-test.png new file mode 100644 index 0000000..818dcc6 Binary files /dev/null and b/apps/marketing/public/images/posts/21-day-sui-challenge/sui-cli-web-test.png differ diff --git a/apps/marketing/public/images/posts/21-day-sui-challenge/tweet-announcement.png b/apps/marketing/public/images/posts/21-day-sui-challenge/tweet-announcement.png new file mode 100644 index 0000000..529eaae Binary files /dev/null and b/apps/marketing/public/images/posts/21-day-sui-challenge/tweet-announcement.png differ diff --git a/apps/marketing/public/images/posts/introducing-sui-cli-web/check_balance.png b/apps/marketing/public/images/posts/introducing-sui-cli-web/check_balance.png new file mode 100644 index 0000000..a9c5acc Binary files /dev/null and b/apps/marketing/public/images/posts/introducing-sui-cli-web/check_balance.png differ diff --git a/apps/marketing/public/images/posts/introducing-sui-cli-web/deploy_contract.png b/apps/marketing/public/images/posts/introducing-sui-cli-web/deploy_contract.png new file mode 100644 index 0000000..7c9d7d6 Binary files /dev/null and b/apps/marketing/public/images/posts/introducing-sui-cli-web/deploy_contract.png differ diff --git a/apps/marketing/public/images/posts/introducing-sui-cli-web/first_version.png b/apps/marketing/public/images/posts/introducing-sui-cli-web/first_version.png new file mode 100644 index 0000000..d3266c2 Binary files /dev/null and b/apps/marketing/public/images/posts/introducing-sui-cli-web/first_version.png differ diff --git a/apps/marketing/public/images/posts/introducing-sui-cli-web/hero.png b/apps/marketing/public/images/posts/introducing-sui-cli-web/hero.png new file mode 100644 index 0000000..d70ec4e Binary files /dev/null and b/apps/marketing/public/images/posts/introducing-sui-cli-web/hero.png differ diff --git a/apps/marketing/public/images/posts/introducing-sui-cli-web/problem_cli.png b/apps/marketing/public/images/posts/introducing-sui-cli-web/problem_cli.png new file mode 100644 index 0000000..143f118 Binary files /dev/null and b/apps/marketing/public/images/posts/introducing-sui-cli-web/problem_cli.png differ diff --git a/apps/marketing/public/images/posts/introducing-sui-cli-web/raycast_cli.png b/apps/marketing/public/images/posts/introducing-sui-cli-web/raycast_cli.png new file mode 100644 index 0000000..2043c8d Binary files /dev/null and b/apps/marketing/public/images/posts/introducing-sui-cli-web/raycast_cli.png differ diff --git a/apps/marketing/public/images/posts/introducing-sui-cli-web/setup.png b/apps/marketing/public/images/posts/introducing-sui-cli-web/setup.png new file mode 100644 index 0000000..20d4aee Binary files /dev/null and b/apps/marketing/public/images/posts/introducing-sui-cli-web/setup.png differ diff --git a/apps/marketing/public/images/posts/introducing-sui-cli-web/tauri.png b/apps/marketing/public/images/posts/introducing-sui-cli-web/tauri.png new file mode 100644 index 0000000..0267e5e Binary files /dev/null and b/apps/marketing/public/images/posts/introducing-sui-cli-web/tauri.png differ diff --git a/apps/marketing/public/images/posts/introducing-sui-cli-web/transfer_tokens.png b/apps/marketing/public/images/posts/introducing-sui-cli-web/transfer_tokens.png new file mode 100644 index 0000000..08b2e2b Binary files /dev/null and b/apps/marketing/public/images/posts/introducing-sui-cli-web/transfer_tokens.png differ diff --git a/apps/marketing/public/og-image-original.png b/apps/marketing/public/og-image-original.png new file mode 100644 index 0000000..d70ec4e Binary files /dev/null and b/apps/marketing/public/og-image-original.png differ diff --git a/apps/marketing/public/og-image.png b/apps/marketing/public/og-image.png new file mode 100644 index 0000000..860e650 Binary files /dev/null and b/apps/marketing/public/og-image.png differ diff --git a/apps/marketing/public/robots.txt b/apps/marketing/public/robots.txt index f0b1cdf..ab175ff 100644 --- a/apps/marketing/public/robots.txt +++ b/apps/marketing/public/robots.txt @@ -1,5 +1,5 @@ # Sui CLI Web Blog - robots.txt -# https://sui-cli-web-production.up.railway.app +# https://sui-cli.dev User-agent: * Allow: / @@ -9,4 +9,4 @@ Disallow: /_astro/ Disallow: /api/ # Sitemap location -Sitemap: https://sui-cli-web-production.up.railway.app/blog/sitemap-index.xml +Sitemap: https://sui-cli.dev/blog/sitemap-index.xml diff --git a/apps/marketing/src/components/react/BrandedImage.tsx b/apps/marketing/src/components/react/BrandedImage.tsx index e6367f3..a3cc894 100644 --- a/apps/marketing/src/components/react/BrandedImage.tsx +++ b/apps/marketing/src/components/react/BrandedImage.tsx @@ -21,7 +21,7 @@ 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: sui-cli-web-production.up.railway.app) + * - Branded watermark overlay (default: sui-cli.dev) * - Works with both online URLs and local images */ export function BrandedImage({ @@ -29,7 +29,7 @@ export function BrandedImage({ alt = 'Image', width = 400, height = 300, - watermark = 'sui-cli-web-production.up.railway.app', + watermark = 'sui-cli.dev', className = '', enableTilt = true, maxTilt = 15, diff --git a/apps/marketing/src/components/react/TwitterEmbed.tsx b/apps/marketing/src/components/react/TwitterEmbed.tsx index c824541..16cdc5e 100644 --- a/apps/marketing/src/components/react/TwitterEmbed.tsx +++ b/apps/marketing/src/components/react/TwitterEmbed.tsx @@ -32,7 +32,7 @@ declare global { export function TwitterEmbed({ tweetUrl, theme = 'dark', - watermark = 'sui-cli-web-production.up.railway.app', + watermark = 'sui-cli.dev', maxTilt = 12, }: TwitterEmbedProps) { const containerRef = useRef(null); diff --git a/apps/marketing/src/pages/404.astro b/apps/marketing/src/pages/404.astro index d8b3785..a1df556 100644 --- a/apps/marketing/src/pages/404.astro +++ b/apps/marketing/src/pages/404.astro @@ -68,7 +68,7 @@ import AnimatedGradientBackground from '../components/ui/animated-gradient-backg Back to Blog
@@ -17,10 +17,10 @@ ## Why sui-cli-web-server? -**Your private keys stay on YOUR machine.** This package runs a local server that connects the [web interface](https://sui-cli-web-production.up.railway.app) to your locally installed Sui CLI. No keys are ever transmitted to external servers. +**Your private keys stay on YOUR machine.** This package runs a local server that connects the [web interface](https://sui-cli.dev) to your locally installed Sui CLI. No keys are ever transmitted to external servers. ``` -Browser (sui-cli-web-production.up.railway.app) ←→ Local Server (this package) ←→ Sui CLI (your machine) +Browser (sui-cli.dev) ←→ Local Server (this package) ←→ Sui CLI (your machine) ``` ## Quick Start @@ -38,7 +38,7 @@ The installed binary is called `sui-cli-web`, but the package is fetches a different, older package (1.0.2) that is no longer updated, so use the full name. -Then open **https://sui-cli-web-production.up.railway.app** - it connects automatically. +Then open **https://sui-cli.dev** - it connects automatically. ## Features @@ -147,7 +147,7 @@ that happens to hold the port. | `POST` | `/community/join` | Join community | | `GET` | `/community/tier/:address` | Get tier info | -[Full API Documentation →](https://github.com/hien-p/raycast-sui-cli/blob/main/API_REFERENCE.md) +[Full API Documentation →](https://github.com/CommandOSSLabs/sui-cli-web/blob/main/API_REFERENCE.md) ## Example Usage @@ -209,7 +209,7 @@ a signal, be aware you may be listening on every interface. faucet. Sized for one local user, not for public traffic. - **CORS is an allowlist**, not a wildcard — but it only governs which web pages may read responses. It stops nothing that is not a browser. -- **Open source** — [audit the code](https://github.com/hien-p/raycast-sui-cli). +- **Open source** — [audit the code](https://github.com/CommandOSSLabs/sui-cli-web). ## Troubleshooting @@ -237,14 +237,14 @@ cargo install --locked sui # Other ### CORS errors Make sure you're accessing via: -- `https://sui-cli-web-production.up.railway.app` +- `https://sui-cli.dev` - `http://localhost:5173` (dev mode) ## Development ```bash # Clone the repo -git clone https://github.com/hien-p/raycast-sui-cli.git +git clone https://github.com/CommandOSSLabs/sui-cli-web.git cd raycast-sui-cli/packages/server # Install dependencies @@ -265,7 +265,7 @@ npm run build ## Related -- [Web Interface](https://sui-cli-web-production.up.railway.app) - Beautiful UI for this server +- [Web Interface](https://sui-cli.dev) - Beautiful UI for this server - [Sui Documentation](https://docs.sui.io) - Official Sui docs - [Move Language](https://move-language.github.io/move/) - Smart contract language @@ -277,6 +277,6 @@ MIT © [hien-p](https://github.com/hien-p)
-**[sui-cli-web-production.up.railway.app](https://sui-cli-web-production.up.railway.app)** · Made with ❤️ for the Sui community +**[sui-cli.dev](https://sui-cli.dev)** · Made with ❤️ for the Sui community
diff --git a/apps/server/package.json b/apps/server/package.json index 510be29..973c6b5 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -43,7 +43,7 @@ "type": "git", "url": "https://github.com/CommandOSSLabs/sui-cli-web.git" }, - "homepage": "https://sui-cli-web-production.up.railway.app", + "homepage": "https://sui-cli.dev", "bugs": { "url": "https://github.com/CommandOSSLabs/sui-cli-web/issues" }, diff --git a/apps/server/src/services/DevstackService.ts b/apps/server/src/services/DevstackService.ts index 632528a..979c435 100644 --- a/apps/server/src/services/DevstackService.ts +++ b/apps/server/src/services/DevstackService.ts @@ -72,6 +72,16 @@ function packageId(value: string | { id: string }): string { return typeof value === 'string' ? value : value.id; } +function normalizePackages( + packages: Record | undefined +): Record { + const normalized: Record = {}; + for (const [pkg, id] of Object.entries(packages ?? {})) { + normalized[pkg] = packageId(id); + } + return normalized; +} + interface CacheEntry { value: T; /** Wall-clock expiry for capability probes. */ @@ -249,9 +259,7 @@ export class DevstackService { faucet: net.faucet, graphql: net.graphql, local: net.local, - packages: Object.fromEntries( - Object.entries(net.packages ?? {}).map(([pkg, id]) => [pkg, packageId(id)]) - ), + packages: normalizePackages(net.packages), }; } diff --git a/apps/web/index.html b/apps/web/index.html index c08c3a1..3abbcd4 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -30,14 +30,14 @@ - + - + - + @@ -46,10 +46,10 @@ - + - + diff --git a/apps/web/public/images/landing/addresses.png b/apps/web/public/images/landing/addresses.png new file mode 100644 index 0000000..46fa429 Binary files /dev/null and b/apps/web/public/images/landing/addresses.png differ diff --git a/apps/web/public/images/landing/app_commands.png b/apps/web/public/images/landing/app_commands.png new file mode 100644 index 0000000..cf7b61a Binary files /dev/null and b/apps/web/public/images/landing/app_commands.png differ diff --git a/apps/web/public/images/landing/coins_management.png b/apps/web/public/images/landing/coins_management.png new file mode 100644 index 0000000..d60a4ca Binary files /dev/null and b/apps/web/public/images/landing/coins_management.png differ diff --git a/apps/web/public/images/landing/move-studio.png b/apps/web/public/images/landing/move-studio.png new file mode 100644 index 0000000..d6075bf Binary files /dev/null and b/apps/web/public/images/landing/move-studio.png differ diff --git a/apps/web/public/images/landing/objects_management.png b/apps/web/public/images/landing/objects_management.png new file mode 100644 index 0000000..b960bee Binary files /dev/null and b/apps/web/public/images/landing/objects_management.png differ diff --git a/apps/web/public/images/landing/package_deployed_profiles.png b/apps/web/public/images/landing/package_deployed_profiles.png new file mode 100644 index 0000000..63383db Binary files /dev/null and b/apps/web/public/images/landing/package_deployed_profiles.png differ diff --git a/apps/web/public/images/landing/package_workflows.png b/apps/web/public/images/landing/package_workflows.png new file mode 100644 index 0000000..900e145 Binary files /dev/null and b/apps/web/public/images/landing/package_workflows.png differ diff --git a/apps/web/public/llms.txt b/apps/web/public/llms.txt new file mode 100644 index 0000000..8336137 --- /dev/null +++ b/apps/web/public/llms.txt @@ -0,0 +1,78 @@ +# Sui CLI Web + +> A keyboard-first web interface over the Sui CLI you already have installed. It is not a wallet: there is no account to create, nothing to connect, no key to paste. A small server runs on your machine and shells out to your own `sui` binary against your own `~/.sui` config; the browser only renders what comes back — private keys never leave your machine. + +## Quick start + +- Install the Sui CLI if you haven't: `brew install sui` (macOS) or `cargo install --locked sui` (any platform) +- Run the local companion server and leave it running: `npx sui-cli-web-server` +- Open https://sui-cli.dev — it auto-discovers the local server by scanning ports 3001-3005, 4001, 4002, 8001, 8080 +- Use the exact package name `sui-cli-web-server`. `sui-cli-web` is a different, older published package (1.0.2) that is no longer updated. + +## How it works + +Three pieces, one of which you already have: browser (`https://sui-cli.dev`) <-> local server (`npx sui-cli-web-server`, binds `127.0.0.1`) <-> your `sui` CLI and `~/.sui/sui_config/*`. There is no database and no server-side state — the source of truth is whatever `sui client`/`sui keytool` report, so nothing can drift out of sync with your CLI. The hosted UI at sui-cli.dev serves the interface only; it has no Sui config or keystore of its own. + +## Features (Account) + +- [Manage Addresses](https://sui-cli.dev/app/addresses): view, switch, and create wallets +- [Switch Network](https://sui-cli.dev/app/environments): change network - devnet, testnet, mainnet, localnet, or a custom RPC + +## Features (Assets) + +- [My Objects](https://sui-cli.dev/app/objects): browse NFTs, capabilities, and owned objects - ownership-kind detection, Display metadata, TransferPolicy/Kiosk cards, version history +- [My Coins](https://sui-cli.dev/app/coins): view every coin type held, with one-tap transfer, split and merge +- [My Packages](https://sui-cli.dev/app/packages): explore packages you've published - modules, functions, types, and automatic Capability/Witness/Hot-Potato Move design-pattern detection +- [Dynamic Fields](https://sui-cli.dev/app/dynamic-fields): query dynamic fields on any object + +## Features (Actions) + +- [Transfer](https://sui-cli.dev/app/transfer): send SUI or objects to another address, with a gas dry-run before signing +- [Request Faucet](https://sui-cli.dev/app/faucet): get free devnet/testnet tokens + +## Features (Development) + +- [Move Studio](https://sui-cli.dev/app/move): build, test, publish and upgrade Move packages +- [Transaction Inspector](https://sui-cli.dev/app/inspector): inspect and replay past transactions, analyze where gas went +- [Developer Tools](https://sui-cli.dev/app/devtools): coverage, disassembly, package summary +- [Devstack Bridge](https://sui-cli.dev/app/devstack): read a local devstack project and switch to its network +- [Derived Address Calculator](https://sui-cli.dev/app/derived-objects): compute a derived object's address from a parent + key + +## Features (Security) + +- [Key Management](https://sui-cli.dev/app/keytool): generate keys, sign messages, multi-sig - key export is confirmation-gated +- [Verification Tools](https://sui-cli.dev/app/security): verify source, verify bytecode, decode transactions + +## Also included + +- Dashboard: every wallet in one table (balance, object count, packages published), balance-over-time, coin-type breakdown, 12-week activity heatmap +- Walrus Memory: decoded blob details, memwal accounts, Seal-based decryption, delegate-key management - in the Object Inspector's Walrus Memory tab +- Command palette: press Cmd+K / Ctrl+K anywhere in the app for any of the above +- Installable as a PWA + +## For AI agents + +- `@sui-cli-web/mcp`: a read-only MCP server exposing environment, wallet, balance and object queries through this project's own validated, rate-limited API — an agent gets answers without shell access to `sui` or private keys ever entering its context. + +## API (local server) + +- Base URL: `http://localhost:/api` (default port 3001) +- `GET /health` — liveness + service identity, outside the `/api` prefix +- `GET /api/addresses`, `POST /api/addresses/create`, `POST /api/addresses/switch` +- `POST /api/transfers/sui`, `POST /api/transfers/sui/dry-run`, `POST /api/transfers/object` +- `POST /api/gas/split`, `POST /api/gas/merge` +- `GET /api/environments`, `POST /api/environments/switch`, `POST /api/environments` +- `POST /api/move/build`, `POST /api/move/test`, `POST /api/packages/publish`, `POST /api/packages/upgrade` +- Full endpoint reference: [Server README](https://github.com/CommandOSSLabs/sui-cli-web/blob/master/apps/server/README.md) + +## Docs + +- [GitHub repository](https://github.com/CommandOSSLabs/sui-cli-web) +- [Changelog](https://sui-cli.dev/changelog): what shipped, by date +- [Sui documentation](https://docs.sui.io) + +## Security + +- Private keys never leave your machine — all signing happens through your local Sui CLI; the only endpoint that returns a raw key is confirmation-gated +- No per-request authentication on the local server. Fine on loopback (127.0.0.1) — anything that can reach it is already running as you. Not fine if exposed on a public interface without authentication in front of it. +- Open source, MIT licensed diff --git a/apps/web/public/og-image.png b/apps/web/public/og-image.png new file mode 100644 index 0000000..68c1b4c Binary files /dev/null and b/apps/web/public/og-image.png differ diff --git a/apps/web/public/og-image.svg b/apps/web/public/og-image.svg index b2b03ff..d81ba9f 100644 --- a/apps/web/public/og-image.svg +++ b/apps/web/public/og-image.svg @@ -64,7 +64,7 @@ Open Source | Privacy-Focused | Developer Tools - sui-cli-web-production.up.railway.app + sui-cli.dev diff --git a/apps/web/public/robots.txt b/apps/web/public/robots.txt index 6021a2a..90c9c2d 100644 --- a/apps/web/public/robots.txt +++ b/apps/web/public/robots.txt @@ -1,5 +1,5 @@ # Sui CLI Web - robots.txt -# https://sui-cli-web-production.up.railway.app +# https://sui-cli.dev User-agent: * Allow: / @@ -9,4 +9,4 @@ Disallow: /api/ Disallow: /_internal/ # Blog sitemap (served at /blog) -Sitemap: https://sui-cli-web-production.up.railway.app/blog/sitemap-index.xml +Sitemap: https://sui-cli.dev/blog/sitemap-index.xml diff --git a/apps/web/public/sitemap.xml b/apps/web/public/sitemap.xml index 580bed8..a914127 100644 --- a/apps/web/public/sitemap.xml +++ b/apps/web/public/sitemap.xml @@ -6,7 +6,7 @@ - https://sui-cli-web-production.up.railway.app/ + https://sui-cli.dev/ 2025-12-22 weekly 1.0 @@ -14,7 +14,7 @@ - https://sui-cli-web-production.up.railway.app/setup + https://sui-cli.dev/setup 2025-12-22 monthly 0.9 @@ -22,7 +22,7 @@ - https://sui-cli-web-production.up.railway.app/app + https://sui-cli.dev/app 2025-12-22 weekly 0.8 @@ -30,7 +30,7 @@ - https://sui-cli-web-production.up.railway.app/membership + https://sui-cli.dev/membership 2025-12-22 monthly 0.7 @@ -38,7 +38,7 @@ - https://sui-cli-web-production.up.railway.app/blog + https://sui-cli.dev/blog 2025-12-22 daily 0.8 diff --git a/apps/web/src/components/HomePage/FounderIntro.tsx b/apps/web/src/components/HomePage/FounderIntro.tsx new file mode 100644 index 0000000..d58abc6 --- /dev/null +++ b/apps/web/src/components/HomePage/FounderIntro.tsx @@ -0,0 +1,40 @@ +import type { ReactNode } from 'react'; +import { Reveal } from './wireframe/reveal'; + +function Highlight({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +export function FounderIntro() { + return ( +
+ {/* Decorative out-of-focus text behind the statement below - same + layered-depth motif as the testimonial wall's blur, just static + instead of hover-driven. Purely visual, hidden from assistive tech. */} + + +
+ +

+ $ who built it +

+ +

+ Hey, I'm HARRY. Publishing a package used to be four + terminal tabs and a prayer, so I built this to make it one keystroke. I work at{' '} + COMMANDOSS and live in VIETNAM. +

+
+
+
+ ); +} diff --git a/apps/web/src/components/HomePage/TestimonialWall.tsx b/apps/web/src/components/HomePage/TestimonialWall.tsx index 9a8920c..8244c70 100644 --- a/apps/web/src/components/HomePage/TestimonialWall.tsx +++ b/apps/web/src/components/HomePage/TestimonialWall.tsx @@ -18,11 +18,6 @@ export function TestimonialWall() {

- - Publishing a package used to be four terminal tabs and a prayer. - Now it's one keystroke and I can actually read the object - changes. - The gas breakdown alone paid for itself — we caught a loop that was burning 40% of our budget on a single entry function. diff --git a/apps/web/src/components/HomePage/index.tsx b/apps/web/src/components/HomePage/index.tsx index b509b2f..bdbfe92 100644 --- a/apps/web/src/components/HomePage/index.tsx +++ b/apps/web/src/components/HomePage/index.tsx @@ -17,8 +17,10 @@ import { useNavigate } from 'react-router-dom'; import { APP_VERSION } from '@/config/version'; import { useTheme } from '@/contexts/ThemeContext'; import { StructuredData } from '../SEO/StructuredData'; -import { HexFloatHero } from './HexFloatHero'; +import { StickyCardsSection } from '../ui/sticky-cards'; import { Features4 } from './Features4'; +import { FounderIntro } from './FounderIntro'; +import { HexFloatHero } from './HexFloatHero'; import { TestimonialWall } from './TestimonialWall'; export function HomePage() { @@ -95,7 +97,7 @@ export function HomePage() {
setMobileMenuOpen(false)} @@ -223,9 +225,15 @@ export function HomePage() { {/* ============ FEATURES (Auto-cycling tabbed) ============ */} + {/* ============ FEATURES IN ACTION (sticky scroll screenshots) ============ */} + + {/* ============ TESTIMONIAL WALL (hover to highlight) ============ */} + {/* ============ FOUNDER INTRO ============ */} + + {/* ============ FINAL CTA ============ */}
@@ -272,7 +280,7 @@ export function HomePage() {
$ - curl https://sui-cli-web-production.up.railway.app{typedPath} + curl https://sui-cli.dev{typedPath} diff --git a/apps/web/src/components/SEO/StructuredData.tsx b/apps/web/src/components/SEO/StructuredData.tsx index e410b3d..7e585c0 100644 --- a/apps/web/src/components/SEO/StructuredData.tsx +++ b/apps/web/src/components/SEO/StructuredData.tsx @@ -12,16 +12,16 @@ const organizationSchema = { '@context': 'https://schema.org', '@type': 'Organization', name: 'Sui CLI Web', - url: 'https://sui-cli-web-production.up.railway.app', - logo: 'https://sui-cli-web-production.up.railway.app/sui-logo.png', + url: 'https://sui-cli.dev', + logo: 'https://sui-cli.dev/sui-logo.png', sameAs: [ - 'https://github.com/hien-p/raycast-sui-cli', + 'https://github.com/CommandOSSLabs/sui-cli-web', 'https://www.npmjs.com/package/sui-cli-web-server', ], contactPoint: { '@type': 'ContactPoint', contactType: 'technical support', - url: 'https://github.com/hien-p/raycast-sui-cli/issues', + url: 'https://github.com/CommandOSSLabs/sui-cli-web/issues', }, }; @@ -48,10 +48,10 @@ const softwareApplicationSchema = { 'Gas Coin Management', 'Multi-Environment Support', ], - screenshot: 'https://sui-cli-web-production.up.railway.app/og-image.svg', + screenshot: 'https://sui-cli.dev/og-image.png', softwareVersion: '1.1.0', downloadUrl: 'https://www.npmjs.com/package/sui-cli-web-server', - installUrl: 'https://sui-cli-web-production.up.railway.app/setup', + installUrl: 'https://sui-cli.dev/setup', }; // WebSite schema - For sitelinks search box @@ -59,10 +59,10 @@ const websiteSchema = { '@context': 'https://schema.org', '@type': 'WebSite', name: 'Sui CLI Web', - url: 'https://sui-cli-web-production.up.railway.app', + url: 'https://sui-cli.dev', potentialAction: { '@type': 'SearchAction', - target: 'https://sui-cli-web-production.up.railway.app/app?q={search_term_string}', + target: 'https://sui-cli.dev/app?q={search_term_string}', 'query-input': 'required name=search_term_string', }, }; @@ -76,13 +76,13 @@ const setupBreadcrumbSchema = { '@type': 'ListItem', position: 1, name: 'Home', - item: 'https://sui-cli-web-production.up.railway.app', + item: 'https://sui-cli.dev', }, { '@type': 'ListItem', position: 2, name: 'Setup', - item: 'https://sui-cli-web-production.up.railway.app/setup', + item: 'https://sui-cli.dev/setup', }, ], }; @@ -124,7 +124,7 @@ const howToSchema = { { '@type': 'HowToStep', name: 'Access the Web Interface', - text: 'Open https://sui-cli-web-production.up.railway.app in your browser', + text: 'Open https://sui-cli.dev in your browser', }, ], }; diff --git a/apps/web/src/components/SetupPage/SetupChat.tsx b/apps/web/src/components/SetupPage/SetupChat.tsx index a1bdfd3..2cc917e 100644 --- a/apps/web/src/components/SetupPage/SetupChat.tsx +++ b/apps/web/src/components/SetupPage/SetupChat.tsx @@ -500,7 +500,7 @@ export function SetupChat({ diff --git a/apps/web/src/components/ui/sticky-cards.tsx b/apps/web/src/components/ui/sticky-cards.tsx index 8bf100f..0f6a8fe 100644 --- a/apps/web/src/components/ui/sticky-cards.tsx +++ b/apps/web/src/components/ui/sticky-cards.tsx @@ -6,7 +6,7 @@ import { useTransform, } from "framer-motion"; import { useEffect, useRef, useState } from "react"; -import { Terminal, Send, Zap, Network, Droplets, Users, Package, Eye } from "lucide-react"; +import { Terminal, Zap, Network, Package, Eye } from "lucide-react"; // Feature cards data with images - matching actual app screenshots const FEATURE_CARDS = [ @@ -71,16 +71,15 @@ const FEATURE_CARDS = [ icon: Package, gradient: 'from-pink-500/20 to-rose-500/20', borderColor: 'border-pink-500/30', - image: '/images/landing/package_deployted_profiles.png', + image: '/images/landing/package_deployed_profiles.png', }, ]; interface StickyCardProps { card: typeof FEATURE_CARDS[0]; - index: number; } -function StickyCard({ card, index }: StickyCardProps) { +function StickyCard({ card }: StickyCardProps) { const vertMargin = 10; const container = useRef(null); const [maxScrollY, setMaxScrollY] = useState(Infinity); @@ -168,14 +167,14 @@ export function StickyCardsSection() {
{/* Section header */}
- Features in Action -

+ Features in Action +

See what you can do

- {FEATURE_CARDS.map((card, idx) => ( - + {FEATURE_CARDS.map((card) => ( + ))}
); diff --git a/apps/web/src/lib/analytics.ts b/apps/web/src/lib/analytics.ts index e70c014..10c545d 100644 --- a/apps/web/src/lib/analytics.ts +++ b/apps/web/src/lib/analytics.ts @@ -26,7 +26,8 @@ function getGAId(): string { // Map domains to their GA IDs (fallback if env var not set) const gaIds: Record = { - 'sui-cli-web-production.up.railway.app': 'G-Z37RHCYYX6', // Primary domain + 'sui-cli.dev': 'G-Z37RHCYYX6', // Primary domain + 'sui-cli-web-production.up.railway.app': 'G-Z37RHCYYX6', // Legacy Railway domain, kept for continuity localhost: 'G-Z37RHCYYX6', // Use primary GA for local development }; diff --git a/apps/web/src/lib/wireframe_lib/metadata.ts b/apps/web/src/lib/wireframe_lib/metadata.ts deleted file mode 100644 index dd9f4c1..0000000 --- a/apps/web/src/lib/wireframe_lib/metadata.ts +++ /dev/null @@ -1,129 +0,0 @@ -import type { Metadata } from "next"; - -export const siteConfig = { - name: "Wireframe Template", - description: - "A baseline for products that move quickly. Wireframe-stage scaffolding for teams that ship before the brand lands.", - url: "https://example.com", - ogImage: "/og-image.png", - creator: "@yourhandle", - authors: [ - { - name: "Your Name", - url: "https://example.com", - }, - ], - keywords: [ - "landing page", - "template", - "Next.js", - "React", - "Tailwind CSS", - "TypeScript", - ], -} as const; - -export const baseMetadata: Metadata = { - metadataBase: new URL(siteConfig.url), - title: { - default: siteConfig.name, - template: `%s | ${siteConfig.name}`, - }, - description: siteConfig.description, - keywords: [...siteConfig.keywords], - authors: [...siteConfig.authors], - creator: siteConfig.creator, - publisher: siteConfig.name, - robots: { - index: true, - follow: true, - googleBot: { - index: true, - follow: true, - "max-video-preview": -1, - "max-image-preview": "large", - "max-snippet": -1, - }, - }, - alternates: { - canonical: "/", - }, - openGraph: { - type: "website", - locale: "en_US", - url: siteConfig.url, - title: siteConfig.name, - description: siteConfig.description, - siteName: siteConfig.name, - images: [ - { - url: siteConfig.ogImage, - width: 1200, - height: 630, - alt: siteConfig.name, - }, - ], - }, - twitter: { - card: "summary_large_image", - title: siteConfig.name, - description: siteConfig.description, - images: [siteConfig.ogImage], - creator: siteConfig.creator, - }, - icons: { - icon: "/favicon.ico", - shortcut: "/favicon-16x16.png", - apple: "/apple-icon.png", - }, - manifest: "/site.webmanifest", -}; - -export function createMetadata({ - title, - description, - path = "/", - image, - noIndex = false, -}: { - title?: string; - description?: string; - path?: string; - image?: string; - noIndex?: boolean; -}): Metadata { - const url = `${siteConfig.url}${path}`; - const ogImage = image ?? siteConfig.ogImage; - - return { - title, - description, - alternates: { - canonical: path, - }, - openGraph: { - title: title ?? siteConfig.name, - description: description ?? siteConfig.description, - url, - images: [ - { - url: ogImage, - width: 1200, - height: 630, - alt: title ?? siteConfig.name, - }, - ], - }, - twitter: { - title: title ?? siteConfig.name, - description: description ?? siteConfig.description, - images: [ogImage], - }, - ...(noIndex && { - robots: { - index: false, - follow: false, - }, - }), - }; -} diff --git a/docs/architecture/BACKEND_ARCHITECTURE.md b/docs/architecture/BACKEND_ARCHITECTURE.md index d70b8a4..33574c5 100644 --- a/docs/architecture/BACKEND_ARCHITECTURE.md +++ b/docs/architecture/BACKEND_ARCHITECTURE.md @@ -40,19 +40,23 @@ binds `127.0.0.1` and runs next to the user's own `sui` CLI and keystore. The hosted web UI talks to it over `http://localhost:`. Nothing is exposed beyond the machine. -**Hosted deployment (what is live now).** The same server also runs on Railway -at `sui-cli-web-production.up.railway.app`, where it serves the built UI as +**Hosted deployment (what is live now).** The same server also runs on +Coolify at `sui-cli.dev`, where it serves the built UI as static files and answers `/api/*` on the public internet. The bind address is decided at startup (`apps/server/src/index.ts`): ```ts -const isCloud = !!(RAILWAY_STATIC_URL || RAILWAY_SERVICE_ID || PORT); +const PUBLIC_HOSTNAME = process.env.PUBLIC_HOSTNAME; +const isCloud = !!PUBLIC_HOSTNAME; const HOST = process.env.HOST || (isCloud ? '0.0.0.0' : '127.0.0.1'); ``` -Note that a bare `PORT` variable is enough to flip this — any platform that -injects `PORT` gets `0.0.0.0`. +This used to key off Railway's `RAILWAY_STATIC_URL`/`RAILWAY_SERVICE_ID`, but +that broke the moment the app moved to Coolify, which sets neither. A hosted +deployment now sets `PUBLIC_HOSTNAME` explicitly; a bare `PORT` no longer +implies `0.0.0.0` on its own, since a user picking a non-default port for +their own local install isn't a cloud deployment. **There is no authentication on any endpoint.** Locally that is fine: the trust boundary is the loopback interface, and anything that can reach @@ -65,7 +69,7 @@ work that gives the hosted instance access to real key material has to add authentication first. CORS is an allowlist, never a wildcard. It now holds exactly one hosted -origin — `https://sui-cli-web-production.up.railway.app` — plus regexes for +origin — `https://sui-cli.dev` — plus regexes for `localhost` and `127.0.0.1`, whatever the platform reports as this deployment's own domain, and anything in `ALLOWED_ORIGINS`. The earlier hosts (`cli.firstmovers.io`, `harriweb3.dev`, the Vercel previews) were removed on