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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Overview

`phio` is a CLI tool for managing PocketHost instances. It provides commands to authenticate, deploy code, watch for changes, and tail logs from PocketHost instances.

## Development Commands

- **Run CLI in dev mode**: `npm run dev` or `tsx ./src/cli.ts`
- **Test CLI command**: `bunx phio <command>` (after building)
- **Format code**: Uses Prettier with organize-imports plugin (config in package.json)

## Architecture

### Entry Point & Command Structure

- **Entry**: `src/cli.ts` - Uses Commander.js to register all commands
- **Commands**: Each command in `src/commands/` exports a factory function that returns a Commander Command instance
- All commands follow the pattern: `export const XCommand = () => new Command('name')...`

### Authentication Flow

1. **Environment variables** (highest priority): `PHIO_USERNAME`, `PHIO_PASSWORD`, `PHIO_INSTANCE_NAME`
2. **Saved config** (fallback): Stored in `~/.config/phio/config.json` (or `PHIO_HOME` if set)
3. Config stores: `email` and `pb_auth` (PocketBase auth cookie)
4. `getClient()` in `src/lib/getClient.ts` manages a singleton PocketBase client instance
5. `ensureLoggedIn()` prompts for login if needed

### Instance Configuration Resolution

The CLI looks for instance names in this order:
1. Environment variable: `PHIO_INSTANCE_NAME`
2. `package.json` under `pockethost.instanceName`
3. `pockethost.json` under `instanceName`
4. Command-line argument if provided

This is handled by `savedInstanceName()` in `src/lib/defaultInstanceId.ts`.

### Deploy/Dev Architecture

- **DevCommand**: Uses `chokidar` to watch files and FTP deploy on changes
- Debounces uploads (200ms) and uses Bottleneck for concurrency control (maxConcurrent: 1)
- File matching uses `multimatch` with include/exclude patterns
- Default includes: `pb_*`, `package.json`, `bun.lockb`, `patches`
- Default excludes: `pb_data` and its contents

- **DeployCommand**: One-time deploy using the same `deployMyCode()` function
- FTP server: `ftp.pockethost.io`
- Username: `__auth__` (special auth marker)
- Password: PocketBase auth cookie

- **FTP Deploy**: Uses `@samkirkland/ftp-deploy` from a forked GitHub repo (benallfree/ftp-deploy#132389e)

### Logs Streaming

- **LogsCommand**: Streams logs using Server-Sent Events (SSE) via `@sentool/fetch-event-source`
- Fetches from: `https://{instanceName}.pockethost.io/logs`
- Implements automatic reconnection with exponential backoff (100ms initial)
- Returns `[Promise, Unsubscribe]` tuple for clean shutdown
- Handles `SIGINT`, `SIGTERM`, `SIGHUP` for graceful termination

### Utilities

- **Task runner** (`src/lib/Task.ts`): Runs async tasks with `ora` spinners for visual feedback
- **Constants** (`src/lib/constants.ts`): Centralized environment variable access and path resolution
- **Config** (`src/lib/config.ts`): JSON file-based config storage using `fs-extra`

## TypeScript Configuration

- Uses `moduleResolution: "bundler"` (optimized for Bun/modern bundlers)
- `skipLibCheck: true` is set to avoid lib errors with the simplified tsconfig
- Runtime: Designed to run with `tsx` or `bun`

## PocketBase Integration

- API URL: `https://pockethost-central.pockethost.io` (configurable via `PHIO_MOTHERSHIP_URL`)
- Collections used:
- `users`: Authentication
- `instances`: Instance management (fields in `src/lib/InstanceFields.ts`)
- Authentication persisted via PocketBase auth cookie

## Key Dependencies

- `commander`: CLI framework
- `pocketbase`: PocketBase SDK
- `@inquirer/prompts`: Interactive prompts
- `chokidar`: File watching
- `ora`: Progress spinners
- `bottleneck`: Rate limiting
- `multimatch`: Glob pattern matching
- Custom SSE implementation via submodule: `@sentool/fetch-event-source`
74 changes: 74 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,80 @@ bunx phio deploy [instance]
bunx phio logs [instance]
```

**List remote files**

```bash
bunx phio lsfiles [instance] [path]

# List pb_public directory (default)
bunx phio lsfiles

# List a specific directory
bunx phio lsfiles my-instance pb_hooks

# Output example:
# Connecting to FTP server...
# Listing contents of my-instance/pb_public:
#
# DIR assets 2025-01-06
# FILE index.html (464 B) 2025-01-06
# FILE vite.svg (1.46 KB) 2025-01-06
```

**Sync local directory to remote**

Features:
- Scans local directory and remote pb_public
- Shows preview of changes (NEW, UPDATE, DELETE)
- Requires confirmation unless --yes flag is used
- Uploads all local files
- Optionally deletes remote files with --delete flag

Preview output:
- Shows files to upload (NEW/UPDATE) with sizes
- Shows files to delete (if --delete is used)
- Limits preview to 20 files, shows count if more
- Confirms before making changes
-
```bash
bunx phio sync-public [instance] [options]

# Options:
# -l, --local-dir <path> Local directory to sync (default: "./pb_public")
# -r, --remote-dir <path> Remote directory (default: "pb_public")
# -y, --yes Skip confirmation prompt
# --delete Delete remote files not in local directory

# Examples:
bunx phio sync-public # Sync ./pb_public to pb_public
bunx phio sync-public -l ./dist # Sync ./dist to pb_public
bunx phio sync-public -l ./dist -r pb_public # Explicit local and remote
bunx phio sync-public -l ./hooks -r pb_hooks # Sync hooks directory
bunx phio sync-public --yes --delete # Auto-confirm and delete extra files

# bunx phio sync-public -l ./dist
# Connecting to FTP server...
# Scanning remote directory...
# Scanning local directory...

# 📋 Preview of changes:

# 📤 Files to upload (3):
# UPDATE index.html (1.09 MB)
# UPDATE vite.svg (1.46 KB)
# NEW test.md (0 B)

# ? Proceed with syncing 3 uploads and 0 deletions? (y/N) Yes

# 📤 Uploading files...
# ✓ index.html
# ✓ vite.svg
# ✓ test.md

# ✅ Sync complete!
```


## Configuration

Use `pockethost` in your `package.json` to save your instance name so you don't need to keep typing it:
Expand Down
25 changes: 19 additions & 6 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"env-var": "^7.5.0",
"event-stream": "^4.0.1",
"fs-extra": "^11.3.0",
"globby": "^15.0.0",
"multimatch": "^7.0.0",
"ora": "^8.2.0",
"pocketbase": "^0.25.1",
Expand All @@ -64,4 +65,4 @@
"prettier-plugin-organize-imports"
]
}
}
}
4 changes: 4 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ import { DevCommand } from './commands/DevCommand'
import { InfoCommand } from './commands/InfoCommand'
import { LinkCommand } from './commands/LinkCommand'
import { ListCommand } from './commands/ListCommand'
import { ListFilesCommand } from './commands/ListFilesCommand'
import { LoginCommand } from './commands/LoginCommand'
import { LogoutCommand } from './commands/LogoutCommand'
import { LogsCommand } from './commands/LogsCommand'
import { SyncPublicCommand } from './commands/SyncPublicCommand'
import { WhoAmICommand } from './commands/WhoAmICommand'

program
Expand All @@ -20,6 +22,8 @@ program
.addCommand(DevCommand())
.addCommand(WhoAmICommand())
.addCommand(ListCommand())
.addCommand(ListFilesCommand())
.addCommand(SyncPublicCommand())
.addCommand(LinkCommand())
.addCommand(DeployCommand())
.addCommand(LogoutCommand())
Expand Down
82 changes: 82 additions & 0 deletions src/commands/ListFilesCommand.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { Client } from 'basic-ftp'
import { Command } from 'commander'
import { savedInstanceName } from '../lib/defaultInstanceId'
import { ensureLoggedIn } from '../lib/ensureLoggedIn'
import { getClient, getInstanceBySubdomainCnameOrId } from '../lib/getClient'

export const listFiles = async (
instanceName: string,
path: string = 'pb_public'
) => {
if (!instanceName) {
throw new Error(
`No instance name provided and none was found in package.json or pockethost.json. Use 'phio link <instance>'`
)
}

await ensureLoggedIn()
const pbClient = await getClient()

// Verify instance exists
try {
await getInstanceBySubdomainCnameOrId(instanceName)
} catch (error) {
throw new Error(`Instance ${instanceName} not found`)
}

const ftpClient = new Client()
ftpClient.ftp.verbose = false

try {
console.log(`Connecting to FTP server...`)
await ftpClient.access({
host: 'ftp.pockethost.io',
user: '__auth__',
password: pbClient.authStore.exportToCookie(),
secure: false,
})

const remotePath = `${instanceName}/${path}`
console.log(`Listing contents of ${remotePath}:\n`)

const files = await ftpClient.list(remotePath)

if (files.length === 0) {
console.log('(empty directory)')
} else {
files.forEach((file) => {
const type = file.isDirectory ? 'DIR ' : 'FILE'
const size = file.isDirectory ? '' : `(${formatBytes(file.size)})`
const date = file.modifiedAt
? file.modifiedAt.toISOString().split('T')[0]
: ''
console.log(
`${type} ${file.name.padEnd(40)} ${size.padEnd(12)} ${date}`
)
})
}
} catch (error) {
if (error instanceof Error) {
throw new Error(`Failed to list files: ${error.message}`)
}
throw error
} finally {
ftpClient.close()
}
}

function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]
}

export const ListFilesCommand = () => {
return new Command('lsfiles')
.argument('[instanceName]', 'Instance name', savedInstanceName())
.argument('[path]', 'Path to list (relative to instance root)', 'pb_public')
.description('List files in instance directory')
.action(listFiles)
}
Loading