# sc — someones.computer deploy agent

A single static Go binary you run inside your project. It reads your local
`docker-compose.yml`, packages every `build:` context into a compressed tarball,
and uploads the compose file + contexts to
[someones.computer](https://git.grey.ooo/Someones.Computer) as a new immutable
Deployment. The platform builds the contexts into images and reconciles them onto
Docker Swarm.

> Status: **early.** `login` stores a token and `deploy` packages the compose
> project + build contexts and POSTs them to `/applications/{slug}/bundle`, which
> the platform now accepts (contexts are cached and a `pending` Deployment is
> recorded). `apps`, `logs` and the `start`/`stop` toggles are in — see
> `docs/agent-cli.md` in the platform repo for the wire contract.

## Install

```sh
curl -fsSL https://someones.computer/install.sh | sh
```

The platform serves the binaries, not this repo: every build is mirrored into
someones.computer's object store, and <https://someones.computer/install> lists
the archives and their checksums for every platform (macOS, Linux and Windows ×
amd64/arm64). The script picks the right one, verifies it against the checksum
published with it, and drops it in `/usr/local/bin` — or `~/.local/bin` when that
needs root, which it will not ask for.

Two channels: **stable** is the latest `v*` tag, **dev** is the last push to
`main`. `?channel=dev` on either URL switches. See
[Releasing](#releasing) for what publishes them.

### Updating

```sh
sc -U          # or --update, or `sc update`
```

`sc` replaces itself with the newest build **on its own channel**, and the
channel is not a choice: a tagged build follows stable, a snapshot follows dev,
and the version stamped into the binary says which it is. There is no flag and
no config file for it.

The download is verified against the platform's published sha256 before anything
on disk is touched, and every failure — a bad checksum, a lost connection, an
unpackable archive — leaves the working binary exactly where it was. An install
directory this user cannot write is reported with the command to run by hand;
`sc` never escalates, for the same reason `install.sh` never does.

An unreleased local build (`go build`, version `dev`) belongs to neither channel
and is refused rather than overwritten.

## Build & use

```sh
go build -o sc ./cmd/sc

# Store a token for the endpoint (prints the page to mint one at, and offers
# to open it):
./sc login

# In a git checkout, that's all the configuration there is — the application
# is named after the repository directory and the deployment after the branch:
./sc deploy --dry-run     # inspect what would be shipped, upload nothing
./sc deploy               # upload a Deployment

# Or name them explicitly:
./sc deploy --app my-app --deployment staging

# Who is this endpoint signed in as, and with which token:
./sc whoami

# See what's out there — organizations, applications, deployments and the
# state each one is in:
./sc apps                 # the whole tree
./sc apps staging         # only what matches, with its ancestors

# Switch a deployment off and back on. Nothing is uploaded and nothing is
# rebuilt — the same images go back up:
./sc stop                 # tear down what this branch has running
./sc start                # put it back, following it until it's running
./sc start staging -d     # another deployment, without waiting

# Follow a running service's output. Ctrl-C to stop:
./sc logs web
./sc logs web --tail 0    # from now, replaying nothing
```

## Following logs

`sc logs <service>` streams one service's stdout and stderr from the platform as
it happens. The service is the name it has in your compose file; the application
resolves exactly as `deploy` resolves it (`--app`, then `.sc.yml`, then the
directory), so the two always mean the same application.

**Log lines go to stdout and everything else to stderr**, so redirecting
collects the log and nothing else:

```sh
sc logs web > web.log     # only log lines land in the file
```

`--tail N` replays that much history before following (200 by default; `0`
starts from now). The platform caps how many streams it holds open at once — a
full pool answers immediately with how long to wait rather than queueing you
behind somebody else's terminal.

## Before it uploads

`deploy` checks the target first, so the two things a *detected* name can get
wrong become questions rather than a failed upload:

```
$ sc deploy
No application "example-app" on https://someones.computer.
Create application "example-app" in "acme"? [Y/n] y
Created example-app — https://someones.computer/applications/019f…

$ sc deploy
"example-app" has no deployment called "feature-ui" yet (it has: main).
Start a new deployment "feature-ui"? [Y/n]
```

The second one matters because the default shape is **one deployment per
branch**: without it, a branch you didn't mean to deploy from quietly starts a
second line of revisions. A first-ever deployment isn't asked about — there is
nothing for it to be a departure from.

Answers come from stdin, so `printf 'y\n' | sc deploy` works the same as typing
it. Nothing to read from (CI, a closed stdin) is not a hang: it's an error that
names the flag which would have answered — **`--yes`** accepts every prompt, and
**`--org`** picks the organization up front when you belong to several.

## Configuration

Every parameter can be set four ways, and they resolve in this order:

```
flag  >  environment  >  .sc.yml  >  detected default
```

### Which platform it talks to

`--endpoint` names the platform, and `--staging` is a shorthand for the hosted
staging one. It is a persistent flag, so it applies to every subcommand:

```sh
./sc --staging apps                          # list what's on staging
./sc deploy --staging                        # deploy there
./sc login --staging                         # and its own token — see below
```

The endpoint resolves highest-first:

| Precedence | Source | Value |
| --- | --- | --- |
| 1 (wins) | `--endpoint` | as given |
| 2 | `--staging` | `https://staging.someones.computer` |
| 3 | `$SC_ENDPOINT` | as given |
| 4 | `endpoint:` in `.sc.yml` | as given |
| 5 | built-in default | `https://someones.computer` |

`--staging` deliberately outranks `$SC_ENDPOINT` and the project file: a repo
pinned to some other endpoint must not quietly pull a `--staging` deploy back
off staging. Passing `--staging` **and** `--endpoint` together is an error
rather than a precedence puzzle — for a flag whose job is "not production",
silently picking a winner is the failure mode worth designing out.

> Tokens are stored **per endpoint**, so staging needs its own `sc login
> --staging`. Without one, a `deploy --staging` prompts for a token inline
> rather than reusing the production one.

The `deploy` summary names the resolved endpoint, so `--dry-run` answers
"where is this about to go?" before anything uploads.

### `.sc.yml`

The per-project file. `sc` reads the nearest one, searching upwards from the
working directory, so it works from a subdirectory of the repo; `--config
<path>` points at a specific file instead. `.sc.yaml` is accepted too (having
both in one directory is an error, not a coin toss). Every key is a flag name,
and unknown keys are rejected rather than ignored — a typo should fail loudly,
not deploy somewhere unexpected.

```yaml
app: example-app          # --app
deployment: staging       # --deployment
endpoint: http://127.0.0.1:7500
token: someonescomputer_… # see the warning below
files:                    # -f/--file; `file:` is accepted as an alias,
  - compose.yaml          # and a bare string works instead of a list
  - compose.override.yaml
dry-run: false            # --dry-run
no-browser: false         # --no-browser
```

Relative `files` entries resolve against the file's own directory, not the
working directory.

> **A `token:` in `.sc.yml` sits inside the build context**, so it ships with
> the upload unless `.dockerignore` excludes it. `sc` warns when it uses one.
> Prefer `$SC_TOKEN` or the `sc login` store, and keep the file out of the
> context if you do commit a token to it.

### Detected defaults

With nothing configured, `sc` infers from the checkout — this is what makes a
bare `sc deploy` work:

| Parameter | Detected from | Example |
| --- | --- | --- |
| `app` | the git repository's directory name, slugified | `~/code/My Cool App` → `my-cool-app` |
| `deployment` | the current branch, slugified | `feature/New UI` → `feature-new-ui` |

The repository root is used, so running from `services/api/` gives the same
answer as running from the root. Outside a checkout the working directory's name
is used and there is no deployment name; on a detached HEAD there is no
deployment name either (a commit id makes a poor one). Git is read directly —
`sc` is a static binary and doesn't need git on `PATH`.

| Environment variable | Effect |
| --- | --- |
| `SC_ENDPOINT` | default for `--endpoint`; `--staging` overrides it |
| `SC_TOKEN` | token for non-interactive runs (CI) |
| `SC_CONFIG_HOME` / `XDG_CONFIG_HOME` | where `credentials.json` lives |

## Authentication

Every upload is attributed to the user the token belongs to — nothing is
anonymous. `sc` looks for a token in this order:

| Source | Use |
| --- | --- |
| `--token` | one-off override |
| `$SC_TOKEN` | CI |
| `token:` in `.sc.yml` | a project pinned to one endpoint (see the warning above) |
| `sc login` store | interactive machines |

`sc login` prints the platform's **Settings → API tokens** page
(`<endpoint>/settings/api-tokens?label=<hostname>+(sc)`), offers to open it in a
browser, and reads the pasted token without echoing it. The `label` is a
suggestion the page prefills its form with — this machine's short hostname, which
is the one thing the browser cannot work out — so the token names the box it
lives on rather than being "token 3" when someone comes to revoke it. A hostname
of `localhost`, or none at all, suggests nothing.

Tokens are stored **per endpoint** in
`$SC_CONFIG_HOME`/`$XDG_CONFIG_HOME/sc`/`~/.config/sc/credentials.json`, mode
`0600`, so a local stack and the hosted platform can each have their own. A
`deploy` with no token configured runs the same prompt inline instead of failing.

```sh
./sc login --staging                          # the hosted staging platform
./sc login --endpoint http://127.0.0.1:7500   # a local stack
./sc login --no-browser                       # just print the URL
echo "$TOKEN" | ./sc login                    # non-interactive
```

`deploy` auto-discovers `compose.yaml`/`docker-compose.yml` (override with `-f`),
parses it with [compose-go](https://github.com/compose-spec/compose-go), and packs
each `build:` context into a deterministic, `.dockerignore`-aware `.tar.gz`.

While the upload is in flight, `sc` tumbles a die between a green `S` and a red
`C` — the pips are the six dots of a braille cell:

```
[S⣦C] Uploading 1.4 MiB — 52%          bytes moving: an 8-step gauge
[S⠙C] Waiting for someones.computer    nothing to measure: ⠙⠸⠴⠦⠇⠋ tumbling
[S·C] Uploading 1.4 MiB — 52%          stalled 2s: a resting mark, brackets breathing
[S⠿C] Uploaded 1.4 MiB in 3.2s         done: the die landed, and this line stays
```

It only animates on a terminal; piped or in CI the label prints once and only
the closing summary follows. `$NO_COLOR` drops the colour and keeps the
animation. The gauge and the resting state need per-byte progress from the
client, which isn't wired yet — see [agent-cli](https://git.grey.ooo/Grey.ooo/someones.computer/src/branch/main/docs/agent-cli.md).

## CI/CD

- **`.github/workflows/ci.yaml`** — on every push to `main` and every PR: `go vet`,
  `go test`, and a static cross-compile across `linux`, `darwin`, `windows` ×
  `amd64`, `arm64` (`CGO_ENABLED=0`).
- **`.github/workflows/release.yaml`** — on a `v*` tag: GoReleaser builds the same
  matrix as static, stripped binaries (`-s -w -trimpath`), archives them
  (`.tar.gz`, `.zip` on Windows) with a `checksums.txt`, and publishes a release to
  this repo via the Forgejo/Gitea API.

Cut a release:

```sh
git tag v0.1.0
git push origin v0.1.0
```

### Requirements for the pipeline to run

- A **Forgejo Actions runner** registered to this repo (or the `Grey.ooo` org)
  exposing the `ubuntu-latest` label.
- The **Actions** unit enabled on the repo (Settings → Units).
- The release job uses the auto-injected `GITHUB_TOKEN` (repo-scoped) as
  `GITEA_TOKEN`; no manual secret needed unless you publish cross-repo.

## Releasing

`.github/workflows/release.yaml` runs goreleaser on every push to `main` and on
every `v*` tag, and both ends up somewhere a user can reach:

| Trigger | goreleaser | Forgejo release | someones.computer channel |
|---|---|---|---|
| push to `main` | `--snapshot` | none | `dev` → `0.0.1-snapshot-<sha>` |
| `v*` tag | `release --clean` | yes, on this repo | `stable` → the tag |

There is deliberately **no rolling `dev` tag**: a snapshot version already names
the commit it was built from, so a moving tag would add nothing and force-push
something.

The mirror is [`scripts/publish-s3.sh`](scripts/publish-s3.sh) — six PUTs signed
with `curl --aws-sigv4`, no AWS CLI to install. It writes the archives and
goreleaser's `checksums.txt` under `releases/<version>/`, then moves
`channels/<channel>` **last**, so a half-finished upload leaves the channel on the
previous version instead of on a set of 404s. The platform reads exactly those
keys (`docs/agent-cli.md` in the platform repo).

The bucket, `sc-builder`, is this pipeline's alone, and the key below can reach
nothing else on that object store — the platform's own keys have **read** on it
and nothing more. Garage grants permissions per bucket and no finer, so sharing
the platform's data bucket would have handed this repo's CI write access to every
stored build context, which the build worker feeds to kaniko by object key.

Five settings on this repo, all configured; absent, the release still cuts and the
mirror step warns, so a fork can tag one without the platform's credentials:

| | | |
|---|---|---|
| Variables | `SC_S3_ENDPOINT` | `https://s3.grey.ooo` |
| | `SC_S3_REGION` | `us-east-1` — s3.grey.ooo reports this; signing with the wrong region is rejected outright |
| | `SC_S3_BUCKET` | `sc-builder` |
| Secrets | `SC_S3_ACCESS_KEY` / `SC_S3_SECRET_KEY` | the `sc-builder`-only Garage key |

## Layout

```
cmd/sc/           entry point
internal/cli/     cobra command tree (root, version, login, deploy, apps, logs,
                  update)
internal/config/  per-endpoint token store (0600 credentials.json) + .sc.yml
internal/detect/  app name from the repo directory, deployment from the branch
internal/bundle/  compose-go loading + deterministic context packing
internal/client/  multipart upload to /applications/{slug}/bundle, plus the
                  /cli/* JSON API (preflight, create application, list) and the
                  newline-delimited log stream
internal/ui/      transient terminal output (the [S⠙C] wait spinner)
internal/selfupdate/
                  sc -U: channel from the embedded version, /install/{channel}.json,
                  checksum verification, atomic replacement
scripts/          publish-s3.sh — mirrors a build to someones.computer
.github/          CI + release workflows
.goreleaser.yaml  static multi-arch build matrix
```

## Gates

Same shape as the platform repo's, in Go terms — one definition each, so a
laptop and CI cannot drift:

| Check | Command | Is |
|---|---|---|
| Static analysis | `hack/lint.sh` | golangci-lint from a pinned image: staticcheck, govet, errcheck, unused, plus `bodyclose`, `errorlint`, `gosec`, `gocritic`, `misspell`. Configured in `.golangci.yaml`, **no baseline** — the gate is "no findings" |
| Coverage | `hack/coverage.sh` | a **ratchet**: every package's figure is recorded in `coverage.floor`, and a drop fails. `--bump` raises them |
| Tests | `go test ./...` | run by the coverage gate, so it is not a separate step |

Those three files are duplicated verbatim in `someones.computer_scclient` and
`someones.computer_tray`, and are meant to stay identical.

**Why a ratchet and not 100%.** The platform repo gates PHP at 100% because
everything there is injectable. Go here is not — a `main()`, an `exec` path, a
terminal draw. A fixed floor is a number nobody raises, which lets a package
fall from 92% to it unnoticed; a ratchet only turns one way, and lowering a
figure has to be argued for in the commit that does it.
