Skip to content

Commit a4eaa2a

Browse files
committed
feat: clone, adopt, status, tunnels and ssh config
1 parent aedc863 commit a4eaa2a

9 files changed

Lines changed: 696 additions & 19 deletions

File tree

book/src/machines.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,3 +118,42 @@ what scp and rsync already taught your fingers.
118118

119119
`pull` takes exactly one machine: two would write to the same local path, and the second would
120120
silently win.
121+
122+
## Port forwards you can name
123+
124+
```sh
125+
wing machine tunnel add db lab --local 5433:localhost:5432
126+
wing machine tunnel add www lab --remote 8080:localhost:80
127+
wing machine tunnel add proxy lab --dynamic 1080
128+
wing machine tunnel start db
129+
wing machine tunnel list
130+
wing machine tunnel stop db
131+
```
132+
133+
A forward is three numbers and a host, typed correctly, every time you want it. Naming one is the
134+
difference between remembering `-L 5433:localhost:5432` and typing `tunnel start db`.
135+
136+
A tunnel gets its **own** ssh connection rather than the shared multiplexed one. With
137+
`ControlMaster` on, the forward is handed to the persistent master and the process wing started
138+
exits — leaving a working tunnel that `list` reports as down and `stop` cannot stop.
139+
140+
`--foreground` runs it in this terminal instead of the background, for when you want to watch it or
141+
kill it with ctrl-c.
142+
143+
## An ssh config for everything else
144+
145+
```sh
146+
wing machine ssh-config # print it
147+
wing machine ssh-config lab
148+
wing machine ssh-config --write # install it
149+
```
150+
151+
`--write` puts every machine into `~/.ssh/wing.config` and adds one `Include wing.config` line at
152+
the top of `~/.ssh/config`. After that, `ssh lab`, `scp file lab:/tmp`, `rsync`, and
153+
`git clone lab:/srv/repo.git` all work with no wing in the way.
154+
155+
Each machine gets a block for its bare name, pointing at the route wing would take, and one per
156+
interface for reaching a specific route on purpose. The file is wing's — it is rewritten whole, so
157+
change the machine with `wing machine set` rather than editing it. The `Include` goes first because
158+
ssh takes the first value it sees for each option, so one added at the bottom loses to anything
159+
above it.

book/src/projects.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,3 +120,28 @@ Silence is the good outcome: a repository with nothing to say gets no line, so w
120120
screen is the list of things to deal with. `--all` adds the clean ones, and the "no upstream" note —
121121
kept out of the default because wing generates projects with a repository and no remote, so it would
122122
be true of every new project forever.
123+
124+
## Getting a project in
125+
126+
A repository URL already says where a project belongs, so being asked for a path as well is being
127+
asked to repeat what you just typed.
128+
129+
```sh
130+
wing project clone termworks/wing # -> ~/code/github.com/termworks/wing
131+
wing project clone git@gitlab.com:group/app.git
132+
wing project clone owner/repo --machine lab # clone it on the server instead
133+
wing project clone owner/repo --root /srv --branch develop
134+
wing project adopt ~/old/checkout # move it into the layout and register it
135+
wing project adopt . --in-place # register where it already is
136+
```
137+
138+
The layout is `<root>/<host>/<owner>/<name>`, and `WING_CODE_ROOT` sets the root (default
139+
`~/code`). The point of a layout is that two machines end up agreeing about where a project is
140+
without either being told — which is what makes a registry of projects across machines readable.
141+
142+
Cloning registers the project, with its language read off what arrived. `--machine` does the whole
143+
thing on that machine instead: clone there, register it as living there.
144+
145+
`adopt` reads the origin remote to work out where a checkout belongs, moves it, and registers it.
146+
A directory with no origin has nowhere to be filed, so it says so and `--in-place` is how you
147+
register it where it is.

src/wing/cmd/machine.nim

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import std/[os, osproc, sequtils, strutils]
55
import ../cliargs
66
import ../jsonfmt
77
import ./machine_remote
8+
import ./tunnel
89
import ../ssh
910
import ../machines/facts
1011
import ../projects/locate
@@ -30,7 +31,8 @@ Commands:
3031
push SOURCE... NAME:DEST [--all] [--tag TAG] [--dry-run]
3132
pull NAME:SOURCE DEST [--dry-run]
3233
tag NAME TAG... | untag NAME TAG...
33-
ssh-config [NAME]
34+
tunnel add|list|start|stop|remove …
35+
ssh-config [NAME] [--write]
3436
check NAME [--ssh] [--timeout MS]
3537
check --all [--ssh] [--timeout MS]
3638
pick
@@ -308,10 +310,36 @@ proc handleMachine*(argsIn: seq[string]) =
308310
writeMachines(path, machines)
309311
echo name & ": " & (if machines[found].tags.len >
310312
0: machines[found].tags.join(", ") else: "no tags")
313+
of "tunnel", "tunnels", "forward":
314+
handleTunnel(args)
311315
of "ssh-config", "config":
316+
let write = popFlag(args, ["-w", "--write"])
312317
rejectUnknownOptions(args)
313318
if args.len > 1:
314-
die("Usage: wing machine ssh-config [NAME]", 2)
319+
die("Usage: wing machine ssh-config [NAME] [--write]", 2)
320+
if write:
321+
# Written where ssh already looks, as its own file that wing owns entirely, and pulled in by
322+
# one Include line. Editing the user's config in place would mean parsing and rewriting
323+
# something they wrote, and getting that wrong costs them every connection they have.
324+
var text = "# Written by wing — `wing machine ssh-config --write` regenerates it.\n" &
325+
"# Do not edit: change the machine with `wing machine set` instead.\n\n"
326+
for machine in machines:
327+
if args.len == 0 or machine.name == args[0]:
328+
text.add(sshConfigFor(machine))
329+
let sshDir = getHomeDir() / ".ssh"
330+
createDir(sshDir)
331+
let target = sshDir / "wing.config"
332+
writeFile(target, text)
333+
let mainConfig = sshDir / "config"
334+
let includeLine = "Include wing.config"
335+
let existing = if fileExists(mainConfig): readFile(mainConfig) else: ""
336+
if not existing.contains(includeLine):
337+
# First line: ssh takes the first value it sees for each option, so an Include added at the
338+
# bottom loses to anything above it that already matched.
339+
writeFile(mainConfig, includeLine & "\n\n" & existing)
340+
echo "Added '" & includeLine & "' to " & mainConfig
341+
echo "Wrote " & target & " (" & $machines.len & " machines)"
342+
return
315343
if args.len == 0:
316344
for machine in machines:
317345
writeSshConfig(machine)

src/wing/cmd/project.nim

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import std/[os, sequtils, strutils]
44

55
import ../cliargs
66
import ../projects/locate
7+
import ./project_clone
78
import ./project_remote
89
import ../discovery
910
import ../jsonfmt
@@ -77,6 +78,10 @@ proc handleProject*(argsIn: seq[string]) =
7778
writeProjects(path, projects)
7879
echo "Project '" & name & "' added successfully to namespace '" &
7980
namespace & "'"
81+
of "clone", "get":
82+
handleClone(args)
83+
of "adopt", "migrate":
84+
handleAdopt(args)
8085
of "discover", "scan":
8186
let depthValue = popValue(args, ["--depth"], "3")
8287
let asJson = popFlag(args, ["--json"])

src/wing/cmd/project_clone.nim

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
## `wing project clone` and `wing project adopt` — getting a project into the registry.
2+
##
3+
## Cloning already tells you everything the registry wants to know, and doing it by hand is three
4+
## commands that each repeat part of the last one: clone somewhere, remember where, register it.
5+
## This is those three, once, and it works the same whether the machine you want it on is this one.
6+
7+
import std/[os, osproc, strutils]
8+
9+
import ../cliargs
10+
import ../discovery
11+
import ../projects/layout
12+
import ../projects/locate
13+
import ../remote
14+
import ../store/machines
15+
import ../store/projects
16+
import ../types
17+
import ../util
18+
19+
proc registerProject(name, path, machine, language: string): bool =
20+
## True when it was added rather than already known. The same path on the same machine is the same
21+
## project, which is what makes running this twice harmless.
22+
let file = ensureProjectsFile()
23+
var projects = parseProjects(file)
24+
for project in projects:
25+
if project.path == path and project.machine == machine:
26+
return false
27+
let stamp = nowStamp()
28+
projects.add(Project(name: name, path: path, machine: machine,
29+
namespace: "default", language: language, tags: @[], createdAt: stamp,
30+
updatedAt: stamp))
31+
writeProjects(file, projects)
32+
true
33+
34+
proc detectLanguage(path: string): string =
35+
let detected = detectProject(path)
36+
if detected.found: detected.project.language else: ""
37+
38+
proc handleClone*(argsIn: seq[string]) =
39+
var args = argsIn
40+
let onMachine = popValue(args, ["-m", "--machine"])
41+
let intoRoot = popValue(args, ["--root"], codeRoot())
42+
let atPath = popValue(args, ["--path"])
43+
let branch = popValue(args, ["-b", "--branch"])
44+
let dryRun = popFlag(args, ["--dry-run", "-n"])
45+
rejectUnknownOptions(args)
46+
requireArgs(args, 1,
47+
"wing project clone URL|owner/name [--machine NAME] [--root DIR] [--branch B]")
48+
49+
let repo = parseRepoRef(args[0])
50+
if repo.name.len == 0:
51+
die("Could not read a repository out of '" & args[0] & "'", 2)
52+
let target = if atPath.len > 0: atPath else: layoutPath(intoRoot, repo)
53+
54+
var command = "git clone"
55+
if branch.len > 0:
56+
command.add(" --branch " & quoteShell(branch))
57+
command.add(" " & quoteShell(repo.url) & " " & quoteShell(target))
58+
59+
if dryRun:
60+
echo (if onMachine.len > 0: onMachine & ": " else: "") & command
61+
return
62+
63+
if onMachine.len > 0:
64+
let machines = parseMachines(ensureMachinesFile())
65+
var found = false
66+
var machine: Machine
67+
for candidate in machines:
68+
if candidate.name == onMachine:
69+
machine = candidate
70+
found = true
71+
break
72+
if not found:
73+
die("Machine '" & onMachine & "' not found", 2)
74+
# mkdir first: git will not create the parents, and the layout is several levels deep.
75+
let script = "mkdir -p " & quoteShell(parentDir(target)) & " && " & command
76+
let results = runOn(@[RemoteTarget(machine: machine, host: firstHost(
77+
machine))], script, 0)
78+
for r in results:
79+
for line in r.output.strip().splitLines():
80+
if line.strip().len > 0:
81+
echo " " & line
82+
if r.exitCode != 0:
83+
die("clone failed on " & onMachine, 1)
84+
# The language is read on the machine that has the files, since this one does not.
85+
let asked = runOn(@[RemoteTarget(machine: machine, host: firstHost(
86+
machine))], "cd " & quoteShell(target) & " && ls", 0)
87+
var language = ""
88+
if asked.len > 0 and asked[0].exitCode == 0:
89+
for line in asked[0].output.splitLines():
90+
case line.strip()
91+
of "Cargo.toml": language = "rust"
92+
of "go.mod": language = "go"
93+
of "pyproject.toml": language = "python"
94+
of "package.json": language = "node"
95+
of "build.zig": language = "zig"
96+
of "dub.json": language = "d"
97+
else:
98+
if line.strip().endsWith(".nimble"): language = "nim"
99+
discard registerProject(repo.name, target, onMachine, language)
100+
echo repo.name & " -> " & onMachine & ":" & target
101+
return
102+
103+
createDir(parentDir(target))
104+
if execCmd(command) != 0:
105+
die("clone failed", 1)
106+
discard registerProject(repo.name, target, "", detectLanguage(target))
107+
echo repo.name & " -> " & target
108+
109+
proc handleAdopt*(argsIn: seq[string]) =
110+
## An existing checkout, moved into the layout and registered. `--in-place` keeps it where it is
111+
## and only registers, which is what you want for a directory that other things point at.
112+
var args = argsIn
113+
let inPlace = popFlag(args, ["--in-place", "--here"])
114+
let intoRoot = popValue(args, ["--root"], codeRoot())
115+
let dryRun = popFlag(args, ["--dry-run", "-n"])
116+
rejectUnknownOptions(args)
117+
let source = expandFilename(if args.len > 0: args[0] else: getCurrentDir())
118+
if not dirExists(source):
119+
die("'" & source & "' is not a directory", 2)
120+
121+
if inPlace:
122+
let name = source.lastPathPart
123+
if dryRun:
124+
echo "register " & name & " -> " & source
125+
return
126+
if registerProject(name, source, "", detectLanguage(source)):
127+
echo "registered " & name & " -> " & source
128+
else:
129+
echo name & " was already registered"
130+
return
131+
132+
# Where it belongs is read from the remote it was cloned from, which is the only thing that knows.
133+
# The exit code decides, not the output: execCmdEx merges stderr, so a directory that is not a
134+
# repository answers with git's error text -- which has slashes and a colon in it and parses as a
135+
# URL if you only check that something came back.
136+
let asked = execCmdEx("git -C " & quoteShell(source) & " remote get-url origin")
137+
let remote = if asked.exitCode == 0: asked.output.strip() else: ""
138+
if remote.len == 0:
139+
die("'" & source & "' has no origin remote, so there is no layout to move it into — " &
140+
"--in-place registers it where it is", 2)
141+
let repo = parseRepoRef(remote)
142+
if repo.name.len == 0:
143+
die("Could not read a repository out of '" & remote & "'", 2)
144+
let target = layoutPath(intoRoot, repo)
145+
if target == source:
146+
if registerProject(repo.name, source, "", detectLanguage(source)):
147+
echo "registered " & repo.name & " -> " & source
148+
else:
149+
echo repo.name & " is already where it belongs"
150+
return
151+
if dirExists(target):
152+
die("'" & target & "' already exists", 2)
153+
154+
if dryRun:
155+
echo source & " -> " & target
156+
return
157+
createDir(parentDir(target))
158+
moveDir(source, target)
159+
discard registerProject(repo.name, target, "", detectLanguage(target))
160+
echo repo.name & ": " & source & " -> " & target

0 commit comments

Comments
 (0)