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

Filter by extension

Filter by extension


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

on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:

permissions:
contents: read

concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

jobs:
check:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
runs-on: ${{ matrix.os }}
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod
cache: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '24'
- name: Check Go formatting
run: test -z "$(gofmt -l cmd/ship)"
- name: Test local behavior without cloud credentials
run: go test -race ./cmd/ship
- name: Go vet
run: go vet ./cmd/ship
- name: Build without cgo
run: CGO_ENABLED=0 go build -trimpath -buildvcs=false -o bin/ship ./cmd/ship
- name: Check browser script
run: node --check cmd/ship/web/app.js
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,8 @@ __pycache__/
.venv/
validation/artifacts/
bin/
.vercel/
*.private.*

# Local Ship records and plaintext business secrets
.ship/
10 changes: 9 additions & 1 deletion PRODUCT.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Ship · 产品范围

项目名称已由用户确认为 **Ship**(2026-09-17,原名 UpOK)。源码、命令与新安装目录使用 `ship`;已有 UpOK 本地状态继续兼容读取。本仓库公开源码与设计,官网和预编译安装包尚未提供。
项目名称已由用户确认为 **Ship**(2026-09-17,原名 UpOK)。源码、命令与新安装目录使用 `ship`;新用户状态默认位于 `~/.ship`,已有 UpOK 本地状态继续兼容读取。本仓库公开源码与设计,官网和预编译安装包尚未提供。

当前推进到安装验收:独立安装目录和空白本地状态下,复用现有官方登录,已完成资源登记、实际发布、跨进程核对及数据库数据保留;支持 macOS arm64 的源码安装脚本和无云请求的安装检查已提供。真正的新用户 OAuth、独立外部试用仍未完成。正式 Free 验证因当前测试账号仍是 Trial 暂停,外部试用仍待独立验证。[验收记录](validation/results/upok-install-report.json)

Expand Down Expand Up @@ -144,3 +144,11 @@ Terraform / OpenTofu 可用于适合声明式管理的资源;不预先要求
免费是已确认的默认费用约束,兼容性与迁移性也是重要选择依据;不能为了得到免费目标而静默改写语言、数据库引擎或业务语义。若当前项目没有满足约束的组合,应明确说明并让用户选择调整范围或主动选择付费路径。

内部版已选择 Go 标准库程序、CLI 接入 Agent、内嵌网页及本地操作记录;公开安装包和更多接入范围继续按实际验收推进。本地样例、云端实际验收与内部版验收分别记录,当前个人云测试资源保留用于后续验证。

## 本地密钥存储决策(2026-09-17 更新)

用户选择优先采用简单的本地文件方案。业务密钥单独放在私有状态目录的 `secrets/项目名.json` 中,普通配置、CLI 输出和默认网页响应不包含密钥值。macOS/Linux 通过目录 0700、文件 0600 限制其他普通用户访问,密钥仍是明文,不能防止当前用户下有读取权限的程序。原钥匙串实现退出首版依赖;Windows ACL 和文件锁需另外适配并验收。存储迁移保留所有操作标记及未决写入状态。

## Vercel 发布验收(2026-09-17)

已有 Vercel Hobby 项目与 Neon Free 已通过 Ship CLI 的两轮源码发布、更新、HTTP/PostgreSQL 读写、数据保留和日志检查。普通部署保留平台现有 Secret,不要求读取或重写密码;数据库配置不可读时明确显示未核验。历史 Secret 写入在用户确认后按“经诊断未生效”归档,原字段和证据保留,没有重发。真实业务 Secret 元数据保持不变。[验收记录](validation/results/ship-vercel-acceptance.json)
18 changes: 12 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,22 @@ Local deployment management for your apps, your cloud accounts, and your coding

Ship 在本地运行,让已有编码 Agent 通过 CLI 部署、检查和维护应用。网页提供应用列表、日志和发布入口,应用与数据库运行在用户自己的云账号中。

**当前为 Alpha。** 已验证的发布组合是 Railway Trial + Neon Free。正式 Railway Free 和付费账号的发布路径尚未验收,程序会停止这些账号的发布操作。软件采用 MIT 开源;云资源的费用取决于供应商套餐。
**当前为 Alpha。** 已验证 Railway Trial + Neon Free,以及个人非商业用途的 Vercel Hobby + Neon Free HTTP 容器路径。正式 Railway Free 和付费账号的发布路径尚未验收,程序会停止这些账号的发布操作。软件采用 MIT 开源;云资源的费用与用途限制取决于供应商套餐。

## 已实现

- 关联已有 Railway 服务与 Neon PostgreSQL,核对资源归属、数据库目标和费用条件。
- 关联已有 Railway 或 Vercel 项目与 Neon PostgreSQL,核对资源归属和费用条件;数据库连接目标的可核验范围单独显示。
- 从 Dockerfile 项目发布源码,查看状态、日志和应用访问检查。
- 保存本地操作记录,中断后核对原部署;结果未知时阻止重复提交。
- CLI、极简网页和配套 Skill 共用执行逻辑与状态。

核心操作直接调用用户授权的供应商接口,不依赖作者运营的在线后台。首次创建云资源、数据库迁移、无人值守维护和更多供应商尚未实现。
核心操作直接调用用户授权的供应商接口,不依赖作者运营的在线后台。首次创建云资源、数据库迁移、无人值守维护和其他供应商尚未实现。

本地记录默认保存在 `~/.ship`,用户主动保存的业务密钥单独以受限权限的明文文件存放。Vercel 普通发布沿用平台已有 Secret,无需先导入或重写;不可读的数据库地址明确标为未核验。[Ship 云端验收](validation/results/ship-vercel-acceptance.json)

## 安装与启动

目前安装入口仅验收 **macOS arm64**,需要 Go 1.24+、Node.js 20.19+ 和 npm。
目前安装入口仅验收 **macOS arm64**,需要 Go 1.24+、Node.js 和 npm;Vercel 路径使用 Node.js 24 验收。

```sh
git clone https://github.com/unix2dos/ship.git
Expand Down Expand Up @@ -50,12 +52,14 @@ cd "$HOME/.local/share/ship"

截至 2026-09-17:

- 9 组 Go 检查、race 检测、`go vet`、前端语法与安装检查通过。
- Go 检查、race 检测、`go vet`、前端语法与安装检查通过,包括改名后的旧状态与发布记录兼容检查。
- 本地文件密钥存储、私有权限、跨进程读取、旧数据迁移与安装后的页面检查通过。[存储验收](validation/results/local-secret-files-report.json)
- 安装后的工具从空白本地状态关联资源、完成真实发布,再由新进程核对;原 PostgreSQL 数据保留。[安装验收记录](validation/results/upok-install-report.json)
- Go、Python、Node、Rust 的 Linux/amd64 容器样例通过 29 组本地检查。[本地结果](validation/RESULTS.md)
- Go 样例完成真实云端部署、更新、自然休眠唤醒与一次配置故障恢复。[云端结果](validation/CLOUD-RESULTS.md)
- Ship CLI 完成 Vercel Hobby + Neon 的两次真实 Go 容器发布、源码更新、PostgreSQL 读写、数据保留和日志检查,沿用原有 Secret。此前独立实验验证过闲置后访问,用户也确认过手动 Chrome 访问;自动化浏览器拦截未定位。[Vercel 结果](validation/VERCEL-RESULTS.md)

外部开发者独立复现、新用户 OAuth、正式 Free、其他本机系统和真实云间迁移仍未验收。早期 PORT 配置快照与运行值的差异保留为未决项。公开报告中的云项目、服务和数据库标识替换为占位符,测试时间、结果与源码指纹保留;本机凭证、个人讨论和原始私有记录不随仓库发布。
外部开发者独立复现、新用户 OAuth、正式 Railway Free、其他本机系统和真实云间迁移仍未验收。早期 PORT 配置快照与运行值的差异保留为未决项。公开报告中的云项目、服务和数据库标识替换为占位符,测试时间、结果与源码指纹保留;本机凭证、个人讨论和原始私有记录不随仓库发布。

在源码目录运行开发检查:

Expand All @@ -70,6 +74,8 @@ python3 validation/install-smoke.py "$HOME/.local/share/ship"

## 设计与反馈

首次外部试用可按 [独立试用清单](docs/FIRST-TRY.md) 进行。

- [产品范围](PRODUCT.md) · [领域术语](CONTEXT.md) · [架构决策](docs/adr/)
- [平台与兼容性研究](docs/research/)

Expand Down
62 changes: 53 additions & 9 deletions cmd/ship/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,19 @@ func validateReferences(p *Project) error {
if !slugPattern.MatchString(p.Name) {
return errors.New("name must use lowercase letters, digits and hyphens")
}
for _, id := range []string{p.Workspace, p.RailwayProject, p.Service, p.Environment, p.NeonOrg, p.NeonProject, p.NeonEndpoint} {
ids := []string{p.NeonOrg, p.NeonProject, p.NeonEndpoint}
switch p.Provider {
case "", "railway":
ids = append(ids, p.Workspace, p.RailwayProject, p.Service, p.Environment)
case "vercel":
if !strings.HasPrefix(p.VercelTeam, "team_") || !strings.HasPrefix(p.VercelProject, "prj_") {
return errors.New("Vercel requires explicit team and project IDs")
}
ids = append(ids, p.VercelTeam, p.VercelProject)
default:
return errors.New("unsupported compute provider")
}
for _, id := range ids {
if !idPattern.MatchString(id) {
return errors.New("all provider resource IDs must be explicit and valid")
}
Expand All @@ -49,9 +61,13 @@ func validateProject(p *Project) error {
if err != nil {
return errors.New("source directory does not exist")
}
info, err := os.Stat(filepath.Join(p.Source, "Dockerfile"))
dockerfile := "Dockerfile"
if p.Provider == "vercel" {
dockerfile = "Dockerfile.vercel"
}
info, err := os.Stat(filepath.Join(p.Source, dockerfile))
if err != nil || !info.Mode().IsRegular() {
return errors.New("internal alpha requires a source directory with a Dockerfile")
return fmt.Errorf("internal alpha requires a source directory with %s", dockerfile)
}
return nil
}
Expand All @@ -74,8 +90,23 @@ func (e *Engine) check(ctx context.Context, p Project) (ApplicationChecks, error
c := ApplicationChecks{At: time.Now().UTC(), Results: checkApplication(ctx, p.URL)}
return c, e.Store.saveChecks(p.Name, c)
}

func (e *Engine) logs(ctx context.Context, p Project) ([]string, error) {
known, err := e.Store.knownSecrets(p.Name)
if err != nil {
return nil, errors.New("local secret-file redaction context is unavailable; logs withheld")
}
lines, err := e.Providers.logs(ctx, p)
if err != nil {
return nil, err
}
for i := range lines {
lines[i] = redact(lines[i], known)
}
return lines, nil
}
func excluded(name string) bool {
for _, v := range []string{".git", "node_modules", ".venv", "__pycache__", "target", ".ship", ".upok", ".pdeploy", ".ssh", ".aws", ".netrc", ".npmrc"} {
for _, v := range []string{".git", ".vercel", "node_modules", ".venv", "__pycache__", "target", ".ship", ".upok", ".pdeploy", ".ssh", ".aws", ".netrc", ".npmrc"} {
if name == v {
return true
}
Expand Down Expand Up @@ -204,6 +235,12 @@ func (e *Engine) executePublish(ctx context.Context, p Project, op *Operation, w
e.record(op, "blocked", err.Error())
return err
}
known, err := e.Store.knownSecrets(p.Name)
if err != nil {
e.record(op, "blocked", "本地密钥文件不可用,无法完成源码密钥检查")
return err
}
pre.Secrets = append(pre.Secrets, known...)
stage, digest, count, err := bundleSource(p.Source, e.Store.Root, pre.Secrets)
if err != nil {
e.record(op, "blocked", err.Error())
Expand All @@ -216,13 +253,17 @@ func (e *Engine) executePublish(ctx context.Context, p Project, op *Operation, w
}
op.SourceHash = digest
op.SourceFiles = count
if p.Provider == "vercel" {
if err = prepareVercelSource(stage, p); err != nil {
e.record(op, "blocked", err.Error())
return err
}
}
// Durable intent precedes the only cloud mutation. Unknown outcomes are reconciled by this unique marker.
if err = e.record(op, "submitting", "正在提交源码;结果未确认前不会重复发布"); err != nil {
return err
}
args := append([]string{"up", stage, "--path-as-root"}, selectors(p)...)
args = append(args, "--detach", "--json", "--message", op.Marker)
_, submitErr := e.Providers.call(ctx, "railway", args...)
submitErr := e.Providers.submit(ctx, p, stage, op.Marker)
if err = e.record(op, "unknown", "提交结果待核对"); err != nil {
return err
}
Expand Down Expand Up @@ -262,7 +303,10 @@ func (e *Engine) reconcileOnce(ctx context.Context, p Project, op *Operation) er
op.DeploymentID = d.ID
op.ProviderState = d.Status
switch d.Status {
case "SUCCESS":
case "SUCCESS", "READY":
if p.Provider == "vercel" && !d.AliasAssigned {
return e.record(op, "deploying", "构建已完成,等待生产域名关联;不会重复提交")
}
c, err := e.check(ctx, p)
if err != nil {
return err
Expand All @@ -272,7 +316,7 @@ func (e *Engine) reconcileOnce(ctx context.Context, p Project, op *Operation) er
return e.record(op, "deployed", "平台已完成发布;应用访问检查单独显示")
case "SLEEPING":
return e.record(op, "deployed", "部署已完成;上次核对时服务处于休眠")
case "FAILED", "CRASHED", "REMOVED", "CANCELED", "CANCELLED", "SKIPPED":
case "FAILED", "ERROR", "CRASHED", "REMOVED", "CANCELED", "CANCELLED", "SKIPPED":
return e.record(op, "failed", "平台报告本次部署未运行;请查看日志后决定下一步")
default:
return e.record(op, "deploying", "已找到原部署,等待平台完成;不会创建替代资源")
Expand Down
Loading