ghostdock 0.0.10 → 0.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -228,7 +228,7 @@ MCP tool: `images_update_dockerfile`.
228
228
 
229
229
  ## `ghostdock login`
230
230
 
231
- Sign in and store a device access token for this CLI.
231
+ Sign in and store a device access token for this machine (~/.config/ghostdock/credentials.json).
232
232
 
233
233
  ```
234
234
  ghostdock login [flags]
@@ -255,14 +255,15 @@ The private network: reach your sandboxes by address and name from this machine.
255
255
 
256
256
  ### `ghostdock network connect`
257
257
 
258
- Put this machine into the private network: WireGuard and split DNS. Asks for sudo once.
258
+ Put this machine into the private network through one provider: WireGuard and split DNS. Asks for sudo once.
259
259
 
260
260
  ```
261
- ghostdock network connect [flags]
261
+ ghostdock network connect [provider] [flags]
262
262
  ```
263
263
 
264
264
  | Argument | Description |
265
265
  | --- | --- |
266
+ | `<provider>` | Sandbox provider name or id. Omit for the organization's default provider (else the platform default). Only its sandboxes are reachable; connecting through another replaces the connection. |
266
267
  | `--org <value>` | Organization id or slug, from `ghostdock organizations list` (default: GHOSTDOCK_ORG, then `ghostdock organizations use`). |
267
268
 
268
269
  ### `ghostdock network devices`
@@ -295,7 +296,7 @@ ghostdock network disconnect
295
296
 
296
297
  ### `ghostdock network ls`
297
298
 
298
- Sandboxes of an organization with their private addresses and names.
299
+ Sandboxes of an organization with their private addresses and names, and which are reachable through the current connection.
299
300
 
300
301
  ```
301
302
  ghostdock network ls [flags]
@@ -307,25 +308,12 @@ ghostdock network ls [flags]
307
308
 
308
309
  ### `ghostdock network status`
309
310
 
310
- Whether this machine is connected, and as which device.
311
+ Whether this machine is connected, through which provider, and as which device.
311
312
 
312
313
  ```
313
314
  ghostdock network status
314
315
  ```
315
316
 
316
- ### `ghostdock network use`
317
-
318
- Choose the organization <sandbox>.gdock.internal names resolve in on this machine.
319
-
320
- ```
321
- ghostdock network use [org] [flags]
322
- ```
323
-
324
- | Argument | Description |
325
- | --- | --- |
326
- | `<org>` | Organization id or slug. |
327
- | `--clear` | Turn short names off. |
328
-
329
317
  ## `ghostdock organizations`
330
318
 
331
319
  Organizations you belong to.
@@ -354,7 +342,7 @@ MCP tool: `organizations_list`.
354
342
 
355
343
  ### `ghostdock organizations use`
356
344
 
357
- Set the default organization for commands run in this directory.
345
+ Set the default organization for commands run without --org (in every directory).
358
346
 
359
347
  ```
360
348
  ghostdock organizations use [org] [flags]
package/dist/cli.md CHANGED
@@ -99,7 +99,7 @@ Commands are grouped by resource, like `gh` or `kubectl`: `ghostdock <resource>
99
99
  ghostdock apps Published apps of an organization.
100
100
  ghostdock env Manage organization environment variables and secrets.
101
101
  ghostdock images Organization image catalog: OCI bookmarks, Dockerfiles and Compose files.
102
- ghostdock login Sign in and store a device access token for this CLI.
102
+ ghostdock login Sign in and store a device access token for this machine…
103
103
  ghostdock logout Revoke the device session and remove stored credentials.
104
104
  ghostdock network The private network: reach your sandboxes by address and name from this machine.
105
105
  ghostdock organizations Organizations you belong to.
@@ -128,21 +128,25 @@ Every organization-scoped command acts on one organization, taken from the first
128
128
 
129
129
  1. `--org <id-or-slug>` on the command
130
130
  2. the `GHOSTDOCK_ORG` environment variable
131
- 3. the default set with `ghostdock organizations use <id-or-slug>`
131
+ 3. the default set with `ghostdock organizations use <id-or-slug>`, stored in
132
+ `~/.config/ghostdock/settings.json`
133
+
134
+ With one organization there is nothing to do: `login` makes it the default, and every command,
135
+ in any directory, uses it.
132
136
 
133
137
  ```bash
134
- ghostdock organizations use acme # default for this directory (login sets one when it can)
135
- ghostdock sandboxes list # stderr: Using organization acme (default, …)
138
+ ghostdock organizations use acme # the default (login sets one when it can)
139
+ ghostdock sandboxes list # stderr: Using organization acme (default from ~/.config/ghostdock/settings.json)
136
140
  ghostdock sandboxes list --org beta # --org always wins
137
141
  ghostdock organizations current # which organization, and where it comes from
138
142
  ghostdock organizations use --clear
139
143
  ```
140
144
 
141
145
  When the organization does not come from `--org`, the command says which one it uses on stderr,
142
- so a default never acts silently. The default is stored next to the credentials
143
- (`./.ghostdock/settings.json`), so it belongs to that directory's login. **Scripts and CI should
144
- pass `--org` or set `GHOSTDOCK_ORG`** rather than depend on (or change) a stored default, and use
145
- the organization id: renaming an organization changes its slug, not its id.
146
+ so a default never acts silently. The default is stored next to the credentials, so it belongs to
147
+ the same login. **Scripts and CI should pass `--org` or set `GHOSTDOCK_ORG`** rather than depend on
148
+ (or change) a stored default, and use the organization id: renaming an organization changes its
149
+ slug, not its id.
146
150
 
147
151
  ### `ghostdock login`
148
152
 
@@ -187,6 +191,9 @@ ghostdock login --code <paste-code>
187
191
  On success, credentials are written and the command exits `0`. If the code hasn't been approved
188
192
  yet, it exits `1` — approve it in the browser first.
189
193
 
194
+ Credentials are stored per machine, in `~/.config/ghostdock/credentials.json` (see
195
+ [Credentials file](#credentials-file)), so one login works in every directory.
196
+
190
197
  **Default organization after login.** Once logged in (with `--wait` or `--code`), `login` sets the
191
198
  default organization when that is unambiguous:
192
199
 
@@ -207,7 +214,8 @@ ghostdock logout
207
214
  ```
208
215
 
209
216
  Revokes the device session on the server (best-effort — still clears local credentials even if the
210
- server call fails, e.g. when offline) and deletes the local credentials file.
217
+ server call fails, e.g. when offline) and deletes the credentials file. The default organization
218
+ stays.
211
219
 
212
220
  ### `ghostdock whoami`
213
221
 
@@ -478,31 +486,75 @@ secret is public.
478
486
 
479
487
  ### `ghostdock network`
480
488
 
481
- Reach your organization's sandboxes from this machine by private address and name, over
489
+ Reach your organizations' sandboxes from this machine by private address and name, over
482
490
  WireGuard ([ADR 0027](../../docs/adr/0027-private-sandbox-network.md)). Everything on a
483
491
  sandbox's address is reachable: a container that publishes port 80 answers on
484
- `http://quiet-harbor.gdock.internal/`, as it would on `localhost` inside the sandbox.
492
+ `http://quiet-harbor.acme.ghostdock.internal/`, as it would on `localhost` inside the sandbox.
485
493
  Nothing reaches your machine from a sandbox.
486
494
 
487
495
  ```bash
488
- ghostdock network connect --org acme # asks for sudo once: wg-quick up and split DNS
489
- ghostdock network ls --org acme # sandboxes with their addresses and names
496
+ ghostdock network connect # your organization's default provider; sudo once
497
+ ghostdock network connect helsinki # or name a provider (name or id)
498
+ ghostdock network ls --org acme # sandboxes, addresses, names, and what is reachable
490
499
  curl http://quiet-harbor.acme.ghostdock.internal:8080/
491
- curl http://quiet-harbor.gdock.internal:8080/ # short name: the organization connect used
492
- ghostdock network use beta # short names in another organization, no sudo
500
+ open https://quiet-harbor--acme.ghostdock.net:8080/ # in a browser: HTTPS, any port
493
501
  ghostdock network status
494
502
  ghostdock network disconnect
495
503
  ```
496
504
 
505
+ - **One provider at a time.** Each sandbox provider is its own gateway, and a device reaches
506
+ only the sandboxes on the provider it connected through. Without an argument, `connect` takes
507
+ the organization (`--org`, `GHOSTDOCK_ORG`, the default from `organizations use`) and its
508
+ default sandbox provider, else the platform default. Connecting through another provider
509
+ replaces the connection: same interface and address, new gateway. `network status` says which
510
+ provider you are connected through; `network ls` marks sandboxes elsewhere as
511
+ `not reachable: on <provider>` with the `connect` command that reaches them. A provider
512
+ without a private network (Incus, for now) is refused by the server; one your organization
513
+ may not use answers 403.
497
514
  - **Needs `wg-quick`** (`brew install wireguard-tools`, `apt install wireguard-tools`). macOS
498
515
  and Linux. On Windows, run the CLI in WSL: then only tools inside WSL reach sandboxes.
499
- - **One key per machine**, kept in `~/.config/ghostdock/network/` (or `GHOSTDOCK_NETWORK_DIR`).
516
+ - **One key per machine**, kept in `~/.config/ghostdock/network/`.
500
517
  Only the public key is sent. The device belongs to the login it was connected with:
501
518
  `ghostdock logout` takes it out of the network, and so does not connecting for 90 days.
502
- - **Names**: `<sandbox>.<org>.ghostdock.internal` always, and `<sandbox>.gdock.internal` in the
503
- organization this machine is pinned to (the one `connect` resolved, changed with
504
- `network use`). The pin is per machine, not per directory. Only AAAA records exist: the
505
- network is IPv6, and the sandbox sees IPv4.
519
+ - **Names**: `<sandbox>.<org>.ghostdock.internal`, for every organization you are a member
520
+ of. A name always says its organization, so a browser origin never changes hands between
521
+ organizations. Only AAAA records exist: the network is IPv6, and the sandbox sees IPv4.
522
+ - **In a browser**, use the HTTPS name `https://<sandbox>--<org>.ghostdock.net:<port>`
523
+ (`network ls` shows it): any port, a real certificate, so the page is a secure context
524
+ (clipboard, `Secure` cookies, service workers). The host's `gdo-ingress` terminates TLS and
525
+ forwards to the sandbox's port; it serves only devices of the sandbox's organization, and
526
+ refuses background requests (fetch, forms, iframes, websockets) from pages outside that
527
+ organization; pages on another HTTPS name of the same organization, any port, may call it. The
528
+ sandbox sees the request from `198.18.0.0`, and your device's address in
529
+ `X-Forwarded-For`. The desktop image's Desktop opens only this way, never on the raw
530
+ address.
531
+ - **Browsers use the HTTPS name; tools keep the raw address.** A browser that opens
532
+ `http://quiet-harbor.acme.ghostdock.internal:3000/app` (or the raw IPv6 address) gets a
533
+ `308` to `https://quiet-harbor--acme.ghostdock.net:3000/app`. Any other browser request on
534
+ the raw address (`fetch`, images, scripts, forms, websockets) gets `403` with
535
+ `X-Ghostdock-Blocked: browser-https-only` and the HTTPS address in the body. This keeps
536
+ websites you visit while connected from using your connection against your sandboxes,
537
+ including through DNS rebinding.
538
+ - **What counts as a browser:** a `User-Agent` that starts with `Mozilla/`, which every
539
+ browser sends on its own requests. Browsers send no `Sec-Fetch-*` headers over plain
540
+ `http://` to an address like this, so those cannot be the signal. (Firefox lets a page
541
+ set its own `User-Agent` on a `fetch`; the `Host` check below stops that one.) Tools that send a
542
+ browser's `User-Agent` (Playwright, Puppeteer, their request APIs) count as browsers too:
543
+ point them at the HTTPS name. A frontend outside the sandbox (a dev server on your laptop)
544
+ calls the sandbox's API by its HTTPS name as well.
545
+ - **Tools** (`curl`, SDKs, test runners, Prometheus) pass untouched, and every other
546
+ protocol does too, as long as they address the sandbox by its IP address, its
547
+ `.internal` name (or a container's, `web.quiet-harbor.acme.ghostdock.internal`), or
548
+ `localhost`: any other `Host` gets `421` (`X-Ghostdock-Blocked: host-not-allowed`). That
549
+ check is what stops DNS rebinding for a page that sets its own `User-Agent`. A name of
550
+ your own in `/etc/hosts` gets 421 too.
551
+ - **One difference for tools:** a port where nothing listens accepts the connection and
552
+ then resets it, so port checks such as `nc -z` report it open; check with a real request
553
+ instead.
554
+ - **In the sandbox,** firewall rules for these clients belong in the input path
555
+ (`iptables -A INPUT`, an nft `hook input` chain): `FORWARD` and `DOCKER-USER` rules no
556
+ longer see them, because the guard connects to a published container port from inside
557
+ the VM.
506
558
  - `ghostdock network devices` lists your devices, `ghostdock network devices remove <id>`
507
559
  removes one.
508
560
 
@@ -544,7 +596,7 @@ or read [`cli-reference.md`](cli-reference.md) for every command's full descript
544
596
 
545
597
  | Command | What it does |
546
598
  | --- | --- |
547
- | `ghostdock login [flags]` | Sign in and store a device access token for this CLI. |
599
+ | `ghostdock login [flags]` | Sign in and store a device access token for this machine… |
548
600
 
549
601
  #### logout
550
602
 
@@ -556,13 +608,12 @@ or read [`cli-reference.md`](cli-reference.md) for every command's full descript
556
608
 
557
609
  | Command | What it does |
558
610
  | --- | --- |
559
- | `ghostdock network connect [flags]` | Put this machine into the private network: WireGuard and split DNS. |
611
+ | `ghostdock network connect [provider] [flags]` | Put this machine into the private network through one provider: WireGuard and… |
560
612
  | `ghostdock network devices <command>` | Your devices in the private network. |
561
613
  | `ghostdock network devices remove <id>` | Remove one of your devices from the private network. |
562
614
  | `ghostdock network disconnect` | Take this machine out of the private network. |
563
- | `ghostdock network ls [flags]` | Sandboxes of an organization with their private addresses and names. |
564
- | `ghostdock network status` | Whether this machine is connected, and as which device. |
565
- | `ghostdock network use [org] [flags]` | Choose the organization <sandbox>.gdock.internal names resolve in on this… |
615
+ | `ghostdock network ls [flags]` | Sandboxes of an organization with their private addresses and names, and which… |
616
+ | `ghostdock network status` | Whether this machine is connected, through which provider, and as which device. |
566
617
 
567
618
  #### organizations
568
619
 
@@ -570,7 +621,7 @@ or read [`cli-reference.md`](cli-reference.md) for every command's full descript
570
621
  | --- | --- |
571
622
  | `ghostdock organizations current [flags]` | Show which organization commands use, and where it comes from. |
572
623
  | `ghostdock organizations list` | List the organizations you belong to, with their ids and slugs. |
573
- | `ghostdock organizations use [org] [flags]` | Set the default organization for commands run in this directory. |
624
+ | `ghostdock organizations use [org] [flags]` | Set the default organization for commands run without --org (in every… |
574
625
 
575
626
  #### projects
576
627
 
@@ -694,7 +745,8 @@ and secrets, see [`ghostdock env`](#ghostdock-env).
694
745
  | ------------------------------- | ------------------------------------------------------------- | --------------------------------- |
695
746
  | `GHOSTDOCK_BASE_URL` | API origin the CLI talks to. Override for local dev / self-hosted instances. | Local/dev builds default to `http://localhost:3000`. npm-published builds default to the production API origin baked at publish time. Override with `--base-url` or this env var. |
696
747
  | `GHOSTDOCK_ORG` | Organization (id or slug) for commands run without `--org`. Takes precedence over the default from `organizations use`. | unset |
697
- | `GHOSTDOCK_CREDENTIALS_PATH` | Absolute path to the credentials file, overriding the default. `settings.json` (the default organization) sits in the same directory. | `./.ghostdock/credentials.json` (relative to the current working directory) |
748
+ | `XDG_CONFIG_HOME` | Base directory for the CLI's per-machine state (`ghostdock/credentials.json`, `ghostdock/settings.json`, `ghostdock/network/`). | `~/.config` (native Windows: `%APPDATA%`) |
749
+ | `GHOSTDOCK_CREDENTIALS_PATH` | Absolute path to the credentials file, overriding the default. `settings.json` (the default organization) sits in the same directory, so each credentials file keeps its own default — handy for a second account. | `~/.config/ghostdock/credentials.json` |
698
750
 
699
751
  Example for local development against a Nuxt dev server:
700
752
 
@@ -706,8 +758,10 @@ GHOSTDOCK_BASE_URL=http://localhost:3000 ghostdock login --wait
706
758
 
707
759
  ## Credentials file
708
760
 
709
- Credentials are stored as JSON at `./.ghostdock/credentials.json` (relative to the directory the
710
- CLI is run from), with file permissions restricted to the owner (`0600`):
761
+ Credentials are stored per machine as JSON in `~/.config/ghostdock/credentials.json`
762
+ (`$XDG_CONFIG_HOME/ghostdock/` when that is set, `%APPDATA%\ghostdock\` on native Windows; WSL
763
+ counts as Linux), with file permissions restricted to the owner (`0600`). `GHOSTDOCK_CREDENTIALS_PATH`
764
+ points the CLI at another file, e.g. for a second account. The file looks like this:
711
765
 
712
766
  ```json
713
767
  {
@@ -717,10 +771,6 @@ CLI is run from), with file permissions restricted to the owner (`0600`):
717
771
  }
718
772
  ```
719
773
 
720
- **Add `.ghostdock/` to your project's `.gitignore`.** These are live tokens — never commit them.
721
- This repository's own `.gitignore` already ignores `.ghostdock/` for local development/testing of
722
- the CLI itself.
723
-
724
774
  Access tokens are short-lived (1 hour); the CLI refreshes them automatically using the refresh token
725
775
  (30-day rolling expiry) whenever a request gets a `401`. If both tokens are invalid or revoked (e.g.
726
776
  after `ghostdock logout` from another session), re-run `ghostdock login`.
package/dist/index.js CHANGED
@@ -1121,6 +1121,9 @@ var GENERATED_TOOL_CATALOG = [
1121
1121
  },
1122
1122
  mcp: {
1123
1123
  type: "boolean"
1124
+ },
1125
+ private_network: {
1126
+ type: "boolean"
1124
1127
  }
1125
1128
  },
1126
1129
  required: [
@@ -1135,7 +1138,8 @@ var GENERATED_TOOL_CATALOG = [
1135
1138
  "compose",
1136
1139
  "kubernetes",
1137
1140
  "env",
1138
- "mcp"
1141
+ "mcp",
1142
+ "private_network"
1139
1143
  ],
1140
1144
  additionalProperties: false
1141
1145
  },
@@ -1563,6 +1567,9 @@ var GENERATED_TOOL_CATALOG = [
1563
1567
  },
1564
1568
  mcp: {
1565
1569
  type: "boolean"
1570
+ },
1571
+ private_network: {
1572
+ type: "boolean"
1566
1573
  }
1567
1574
  },
1568
1575
  required: [
@@ -1577,7 +1584,8 @@ var GENERATED_TOOL_CATALOG = [
1577
1584
  "compose",
1578
1585
  "kubernetes",
1579
1586
  "env",
1580
- "mcp"
1587
+ "mcp",
1588
+ "private_network"
1581
1589
  ],
1582
1590
  additionalProperties: false
1583
1591
  },
@@ -2102,6 +2110,9 @@ var GENERATED_TOOL_CATALOG = [
2102
2110
  },
2103
2111
  mcp: {
2104
2112
  type: "boolean"
2113
+ },
2114
+ private_network: {
2115
+ type: "boolean"
2105
2116
  }
2106
2117
  },
2107
2118
  required: [
@@ -2116,7 +2127,8 @@ var GENERATED_TOOL_CATALOG = [
2116
2127
  "compose",
2117
2128
  "kubernetes",
2118
2129
  "env",
2119
- "mcp"
2130
+ "mcp",
2131
+ "private_network"
2120
2132
  ],
2121
2133
  additionalProperties: false
2122
2134
  },
@@ -2495,6 +2507,9 @@ var GENERATED_TOOL_CATALOG = [
2495
2507
  },
2496
2508
  mcp: {
2497
2509
  type: "boolean"
2510
+ },
2511
+ private_network: {
2512
+ type: "boolean"
2498
2513
  }
2499
2514
  },
2500
2515
  required: [
@@ -2509,7 +2524,8 @@ var GENERATED_TOOL_CATALOG = [
2509
2524
  "compose",
2510
2525
  "kubernetes",
2511
2526
  "env",
2512
- "mcp"
2527
+ "mcp",
2528
+ "private_network"
2513
2529
  ],
2514
2530
  additionalProperties: false
2515
2531
  },
@@ -2949,6 +2965,9 @@ var GENERATED_TOOL_CATALOG = [
2949
2965
  },
2950
2966
  mcp: {
2951
2967
  type: "boolean"
2968
+ },
2969
+ private_network: {
2970
+ type: "boolean"
2952
2971
  }
2953
2972
  },
2954
2973
  required: [
@@ -2963,7 +2982,8 @@ var GENERATED_TOOL_CATALOG = [
2963
2982
  "compose",
2964
2983
  "kubernetes",
2965
2984
  "env",
2966
- "mcp"
2985
+ "mcp",
2986
+ "private_network"
2967
2987
  ],
2968
2988
  additionalProperties: false
2969
2989
  },
@@ -3339,6 +3359,9 @@ var GENERATED_TOOL_CATALOG = [
3339
3359
  },
3340
3360
  mcp: {
3341
3361
  type: "boolean"
3362
+ },
3363
+ private_network: {
3364
+ type: "boolean"
3342
3365
  }
3343
3366
  },
3344
3367
  required: [
@@ -3353,7 +3376,8 @@ var GENERATED_TOOL_CATALOG = [
3353
3376
  "compose",
3354
3377
  "kubernetes",
3355
3378
  "env",
3356
- "mcp"
3379
+ "mcp",
3380
+ "private_network"
3357
3381
  ],
3358
3382
  additionalProperties: false
3359
3383
  },
@@ -8564,7 +8588,7 @@ function getCliName(argv = process.argv) {
8564
8588
  return CLI_NAMES.find((name) => name === invoked) ?? "ghostdock";
8565
8589
  }
8566
8590
  // package.json
8567
- var version = "0.0.10";
8591
+ var version = "0.0.11";
8568
8592
  var package_default = {
8569
8593
  name: "ghostdock",
8570
8594
  description: "Ghostdock CLI — device login and agent tool invocation from the command line",
@@ -8601,6 +8625,7 @@ var package_default = {
8601
8625
  };
8602
8626
 
8603
8627
  // src/config.ts
8628
+ import { homedir } from "node:os";
8604
8629
  import { join } from "node:path";
8605
8630
  var FALLBACK_DEFAULT_BASE_URL = "http://localhost:3000";
8606
8631
  var cliBaseUrlOverride;
@@ -8622,9 +8647,22 @@ function getBaseUrl() {
8622
8647
  return fromEnv.replace(/\/+$/, "");
8623
8648
  return resolveDefaultBaseUrl();
8624
8649
  }
8650
+ function getConfigDir() {
8651
+ const xdg = process.env.XDG_CONFIG_HOME?.trim();
8652
+ if (xdg)
8653
+ return join(xdg, "ghostdock");
8654
+ const appData = process.env.APPDATA?.trim();
8655
+ if (process.platform === "win32" && appData)
8656
+ return join(appData, "ghostdock");
8657
+ return join(homedir(), ".config", "ghostdock");
8658
+ }
8625
8659
  function getCredentialsPath() {
8626
8660
  const override = process.env.GHOSTDOCK_CREDENTIALS_PATH?.trim();
8627
- return override || join(process.cwd(), ".ghostdock", "credentials.json");
8661
+ return override || join(getConfigDir(), "credentials.json");
8662
+ }
8663
+ function displayPath(path) {
8664
+ const home = homedir();
8665
+ return home && (path === home || path.startsWith(`${home}/`)) ? `~${path.slice(home.length)}` : path;
8628
8666
  }
8629
8667
 
8630
8668
  // src/credentials.ts
@@ -8886,7 +8924,8 @@ async function findOrganizationRef(flag) {
8886
8924
  return {
8887
8925
  ref: defaultOrganization.id,
8888
8926
  source: "default",
8889
- label: defaultOrganization.slug ?? defaultOrganization.id
8927
+ label: defaultOrganization.slug ?? defaultOrganization.id,
8928
+ path: getSettingsPath()
8890
8929
  };
8891
8930
  }
8892
8931
  return null;
@@ -8897,15 +8936,15 @@ async function requireOrganizationRef(flag, notice = printNotice) {
8897
8936
  throw new Error(`No organization. Pass --org <id-or-slug>, set GHOSTDOCK_ORG, or run \`${getCliName()} organizations use <org>\`.`);
8898
8937
  }
8899
8938
  if (found.source !== "flag")
8900
- notice(`Using organization ${found.label} (${describeSource(found.source)}).`);
8939
+ notice(`Using organization ${found.label} (${describeSource(found)}).`);
8901
8940
  return found.ref;
8902
8941
  }
8903
- function describeSource(source) {
8942
+ function describeSource({ source, path }) {
8904
8943
  if (source === "flag")
8905
8944
  return "--org";
8906
8945
  if (source === "env")
8907
8946
  return "GHOSTDOCK_ORG";
8908
- return `default, set with \`${getCliName()} organizations use\``;
8947
+ return `default from ${displayPath(path ?? getSettingsPath())}`;
8909
8948
  }
8910
8949
  async function withOrganization(flag, run) {
8911
8950
  let organization;
@@ -9750,7 +9789,7 @@ async function runLogin(args, log = console.log, choose = defaultChooser(), erro
9750
9789
  var loginCommand = defineCommand({
9751
9790
  meta: {
9752
9791
  name: "login",
9753
- description: "Sign in and store a device access token for this CLI."
9792
+ description: "Sign in and store a device access token for this machine (~/.config/ghostdock/credentials.json)."
9754
9793
  },
9755
9794
  args: {
9756
9795
  wait: {
@@ -9809,15 +9848,10 @@ import { hostname as hostname2 } from "node:os";
9809
9848
  import { createPrivateKey, createPublicKey, generateKeyPairSync } from "node:crypto";
9810
9849
  import { existsSync } from "node:fs";
9811
9850
  import { chmod as chmod2, mkdir as mkdir3, readFile as readFile3, writeFile as writeFile3 } from "node:fs/promises";
9812
- import { homedir } from "node:os";
9813
9851
  import { dirname as dirname3, join as join3 } from "node:path";
9814
9852
  var INTERFACE = "gdock";
9815
9853
  function networkDir() {
9816
- const override = process.env.GHOSTDOCK_NETWORK_DIR?.trim();
9817
- if (override)
9818
- return override;
9819
- const base = process.env.XDG_CONFIG_HOME?.trim() || join3(homedir(), ".config");
9820
- return join3(base, "ghostdock", "network");
9854
+ return join3(getConfigDir(), "network");
9821
9855
  }
9822
9856
  function configPath(dir = networkDir()) {
9823
9857
  return join3(dir, `${INTERFACE}.conf`);
@@ -9828,6 +9862,10 @@ function keyPath(dir) {
9828
9862
  function statePath(dir) {
9829
9863
  return join3(dir, "device.json");
9830
9864
  }
9865
+ function isProvider(value) {
9866
+ const provider = value;
9867
+ return typeof provider?.id === "string" && typeof provider.name === "string";
9868
+ }
9831
9869
  var X25519_PKCS8_PREFIX = Buffer.from("302e020100300506032b656e04220420", "hex");
9832
9870
  function keyPairFromPrivate(privateKey) {
9833
9871
  const raw = Buffer.from(privateKey, "base64");
@@ -9863,7 +9901,8 @@ async function readDeviceState(dir = networkDir()) {
9863
9901
  return {
9864
9902
  baseUrl: parsed.baseUrl,
9865
9903
  deviceId: parsed.deviceId,
9866
- address: typeof parsed.address === "string" ? parsed.address : ""
9904
+ address: typeof parsed.address === "string" ? parsed.address : "",
9905
+ gatewayProvider: isProvider(parsed.gatewayProvider) ? { id: parsed.gatewayProvider.id, name: parsed.gatewayProvider.name } : null
9867
9906
  };
9868
9907
  }
9869
9908
  } catch {}
@@ -9883,7 +9922,14 @@ function detectDnsMode(platform = process.platform, exists = existsSync) {
9883
9922
  }
9884
9923
  function renderConfig(input) {
9885
9924
  const { device, network, gateway } = input.registered;
9886
- const zones = [network.zones.canonical, network.zones.short];
9925
+ const zones = [network.zone, ...network.netZone ? [network.netZone] : []];
9926
+ for (const zone of zones) {
9927
+ if (!isDnsZone(zone))
9928
+ throw new Error(`The server sent a zone that is not a DNS name: ${zone}`);
9929
+ }
9930
+ if (!isIPv6Address(network.gatewayAddress)) {
9931
+ throw new Error(`The server sent a gateway that is not an IPv6 address: ${network.gatewayAddress}`);
9932
+ }
9887
9933
  const lines = [
9888
9934
  "# Written by `ghostdock network connect`. Run it again rather than editing this file.",
9889
9935
  "[Interface]",
@@ -9892,15 +9938,26 @@ function renderConfig(input) {
9892
9938
  `MTU = ${network.mtu}`
9893
9939
  ];
9894
9940
  if (input.dns === "macos-resolver") {
9895
- const files = zones.map((zone) => `/etc/resolver/${zone}`);
9896
- lines.push(`PostUp = mkdir -p /etc/resolver && ${files.map((file) => `printf 'nameserver ${network.gatewayAddress}\\n' > ${file}`).join(" && ")}`, `PostDown = rm -f ${files.join(" ")}`);
9941
+ for (const zone of zones) {
9942
+ const file = `/etc/resolver/${zone}`;
9943
+ lines.push(`PostUp = mkdir -p /etc/resolver && printf 'nameserver ${network.gatewayAddress}\\n' > ${file}`, `PostDown = rm -f ${file}`);
9944
+ }
9897
9945
  } else if (input.dns === "resolved") {
9898
- lines.push(`PostUp = resolvectl dns %i ${network.gatewayAddress} && resolvectl domain %i ${zones.map((zone) => `'~${zone}'`).join(" ")}`);
9946
+ const domains = zones.map((zone) => `'~${zone}'`).join(" ");
9947
+ lines.push(`PostUp = resolvectl dns %i ${network.gatewayAddress} && resolvectl domain %i ${domains}`);
9899
9948
  }
9900
9949
  lines.push("", "[Peer]", `PublicKey = ${gateway.publicKey}`, `Endpoint = ${gateway.endpoint}`, `AllowedIPs = ${network.platformPrefix}`, "PersistentKeepalive = 25", "");
9901
9950
  return lines.join(`
9902
9951
  `);
9903
9952
  }
9953
+ var DNS_LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
9954
+ function isDnsZone(zone) {
9955
+ const labels = zone.split(".");
9956
+ return zone.length <= 253 && labels.length >= 2 && labels.every((label) => DNS_LABEL.test(label));
9957
+ }
9958
+ function isIPv6Address(text) {
9959
+ return /^[0-9a-f:]{2,39}$/.test(text) && text.includes(":");
9960
+ }
9904
9961
  async function writeConfig(contents, path = configPath()) {
9905
9962
  await mkdir3(dirname3(path), { recursive: true, mode: 448 });
9906
9963
  await writeFile3(path, contents, { mode: 384 });
@@ -9939,28 +9996,36 @@ function defaultDeps() {
9939
9996
  function message(err) {
9940
9997
  return err instanceof Error ? err.message : String(err);
9941
9998
  }
9999
+ function connectCommandFor(provider) {
10000
+ const ref = provider.connectRef;
10001
+ const arg = /^[A-Za-z0-9._-]+$/.test(ref) ? ref : JSON.stringify(ref);
10002
+ return `${getCliName()} network connect ${arg}`;
10003
+ }
9942
10004
  async function wgQuick(deps, verb) {
9943
10005
  const args = ["wg-quick", verb, configPath(deps.dir)];
9944
10006
  deps.notice(`Running: sudo ${args.join(" ")}`);
9945
10007
  return deps.run("sudo", args);
9946
10008
  }
9947
- async function runNetworkConnect(args, deps = defaultDeps()) {
10009
+ async function runNetworkConnect(args = {}, deps = defaultDeps()) {
9948
10010
  if (!deps.has("wg-quick")) {
9949
10011
  deps.error(`wg-quick is not installed. ${installHint()}`);
9950
10012
  return 1;
9951
10013
  }
9952
10014
  let registered;
9953
10015
  let privateKey;
10016
+ const previous = await readDeviceState(deps.dir);
9954
10017
  try {
10018
+ const organization = await requireOrganizationRef(args.org, deps.notice);
10019
+ const provider = args.provider?.trim();
9955
10020
  const key = await loadOrCreateKey(deps.dir);
9956
10021
  privateKey = key.privateKey;
9957
- const found = await findOrganizationRef(args.org);
9958
10022
  registered = await deps.request("/api/v1/network/devices", {
9959
10023
  method: "POST",
9960
10024
  body: JSON.stringify({
9961
10025
  publicKey: key.publicKey,
9962
10026
  name: hostname2().slice(0, 64) || "device",
9963
- ...found ? { shortNameOrganization: found.ref } : {}
10027
+ organization,
10028
+ ...provider ? { provider } : {}
9964
10029
  })
9965
10030
  });
9966
10031
  } catch (err) {
@@ -9968,9 +10033,22 @@ async function runNetworkConnect(args, deps = defaultDeps()) {
9968
10033
  return 1;
9969
10034
  }
9970
10035
  const dns = detectDnsMode();
9971
- await writeConfig(renderConfig({ privateKey, registered, dns }), configPath(deps.dir));
9972
- await writeDeviceState({ baseUrl: getBaseUrl(), deviceId: registered.device.id, address: registered.device.address }, deps.dir);
9973
- if (deps.up()) {
10036
+ let config;
10037
+ try {
10038
+ config = renderConfig({ privateKey, registered, dns });
10039
+ } catch (err) {
10040
+ deps.error(message(err));
10041
+ return 1;
10042
+ }
10043
+ await writeConfig(config, configPath(deps.dir));
10044
+ await writeDeviceState({
10045
+ baseUrl: getBaseUrl(),
10046
+ deviceId: registered.device.id,
10047
+ address: registered.device.address,
10048
+ gatewayProvider: registered.provider
10049
+ }, deps.dir);
10050
+ const wasUp = deps.up();
10051
+ if (wasUp) {
9974
10052
  const down = await wgQuick(deps, "down");
9975
10053
  if (down !== 0) {
9976
10054
  deps.error(`Could not take the old tunnel down (wg-quick exited ${down}).`);
@@ -9982,10 +10060,16 @@ async function runNetworkConnect(args, deps = defaultDeps()) {
9982
10060
  deps.error(`wg-quick exited ${code}. The config is in ${configPath(deps.dir)}.`);
9983
10061
  return 1;
9984
10062
  }
9985
- const { device, network } = registered;
9986
- printResult({ device, network, interface: INTERFACE, dns }, () => [
9987
- eventLine("done", `Connected. This machine is ${device.address} in the private network.`),
9988
- ` Names: <sandbox>.<org>.${network.zones.canonical}` + (device.shortNameOrganizationId ? `, and <sandbox>.${network.zones.short}` : ""),
10063
+ const { device, network, provider } = registered;
10064
+ const switched = wasUp && previous?.gatewayProvider && previous.gatewayProvider.id !== provider.id ? previous.gatewayProvider : null;
10065
+ printResult({ device, network, provider, interface: INTERFACE, dns }, () => [
10066
+ eventLine("done", `Connected through ${provider.name}. This machine is ${device.address} in the private network.`),
10067
+ ...switched ? [
10068
+ ` Replaced the connection through ${switched.name}: its sandboxes are out of reach now.`
10069
+ ] : [],
10070
+ ` Reachable: the sandboxes on ${provider.name}. One provider at a time.`,
10071
+ ` Names: <sandbox>.<org>.${network.zone}`,
10072
+ ...network.netZone ? [` In a browser: https://<sandbox>--<org>.${network.netZone}:<port>`] : [],
9989
10073
  ...dns === "none" ? [
9990
10074
  " No split DNS on this machine (no systemd-resolved): use the addresses from",
9991
10075
  ` \`${getCliName()} network ls\`.`
@@ -10007,44 +10091,89 @@ async function runNetworkDisconnect(deps = defaultDeps()) {
10007
10091
  printResult({ connected: false }, () => [eventLine("done", "Disconnected.")], deps.log);
10008
10092
  return 0;
10009
10093
  }
10010
- async function runNetworkStatus(deps = defaultDeps()) {
10094
+ async function readConnection(deps) {
10011
10095
  const state = await readDeviceState(deps.dir);
10012
- const connected = deps.up();
10013
- let device = null;
10014
- if (state) {
10015
- try {
10016
- const { devices } = await deps.request("/api/v1/network/devices");
10017
- device = devices.find((d) => d.id === state.deviceId) ?? null;
10018
- } catch (err) {
10019
- deps.notice(`Could not ask the app about this device: ${message(err)}`);
10020
- }
10096
+ const up = deps.up();
10097
+ if (!state)
10098
+ return { state, up, device: null, asked: true, provider: null };
10099
+ try {
10100
+ const { devices } = await deps.request("/api/v1/network/devices");
10101
+ const device = devices.find((d) => d.id === state.deviceId) ?? null;
10102
+ return {
10103
+ state,
10104
+ up,
10105
+ device,
10106
+ asked: true,
10107
+ provider: up ? device?.gatewayProvider ?? null : null
10108
+ };
10109
+ } catch (err) {
10110
+ deps.notice(`Could not ask the app about this device: ${message(err)}`);
10111
+ return { state, up, device: null, asked: false, provider: up ? state.gatewayProvider : null };
10021
10112
  }
10022
- printResult({ connected, device }, () => {
10113
+ }
10114
+ async function runNetworkStatus(deps = defaultDeps()) {
10115
+ const { state, up, device, asked, provider } = await readConnection(deps);
10116
+ printResult({ connected: up, device, provider }, () => {
10023
10117
  if (!state) {
10024
10118
  return [eventLine("item", `Not set up. Run \`${getCliName()} network connect\`.`)];
10025
10119
  }
10120
+ const through = provider ? ` through ${provider.name}` : "";
10026
10121
  const lines = [
10027
- eventLine("item", `${connected ? "Connected" : "Not connected"}: ${state.address}`)
10122
+ eventLine("item", `${up ? `Connected${through}` : "Not connected"}: ${state.address}`)
10028
10123
  ];
10029
- if (!device) {
10030
- lines.push(` The app no longer knows this device (removed, expired or logged out). Run \`${getCliName()} network connect\`.`);
10031
- } else {
10124
+ if (device) {
10032
10125
  lines.push(` Device ${device.name}, valid until ${device.expiresAt.slice(0, 10)}.`);
10126
+ if (!device.gatewayProvider) {
10127
+ lines.push(` Its provider is gone, so no gateway serves it. Run \`${getCliName()} network connect\`.`);
10128
+ }
10129
+ } else if (asked) {
10130
+ lines.push(` The app no longer knows this device (removed, expired or logged out). Run \`${getCliName()} network connect\`.`);
10033
10131
  }
10132
+ if (provider)
10133
+ lines.push(` Reachable: the sandboxes on ${provider.name}.`);
10034
10134
  return lines;
10035
10135
  }, deps.log);
10036
10136
  return 0;
10037
10137
  }
10138
+ function unreachableNote(sandbox, connected) {
10139
+ if (!sandbox.provider.privateNetwork) {
10140
+ return `not reachable: ${sandbox.provider.name} has no private network yet`;
10141
+ }
10142
+ if (connected?.id === sandbox.provider.id)
10143
+ return null;
10144
+ return `not reachable: on ${sandbox.provider.name} → ${connectCommandFor(sandbox.provider)}`;
10145
+ }
10038
10146
  async function runNetworkLs(args, deps = defaultDeps()) {
10039
10147
  try {
10040
10148
  const organization = await requireOrganizationRef(args.org, deps.notice);
10041
10149
  const listed = await deps.request(`/api/v1/network/organizations/${encodeURIComponent(organization)}/sandboxes`);
10042
- printResult(listed, () => {
10150
+ const connected = (await readConnection(deps)).provider;
10151
+ const sandboxes = listed.sandboxes.map((sandbox) => ({
10152
+ ...sandbox,
10153
+ reachable: unreachableNote(sandbox, connected) === null
10154
+ }));
10155
+ printResult({ ...listed, connectedProvider: connected, sandboxes }, () => {
10043
10156
  if (listed.sandboxes.length === 0) {
10044
10157
  return [eventLine("item", `No sandboxes in ${listed.organization.slug}.`)];
10045
10158
  }
10046
10159
  const width = Math.max(...listed.sandboxes.map((s) => s.name.length));
10047
- return listed.sandboxes.map((s) => ` ${s.name.padEnd(width)} ${s.address.padEnd(28)} ${s.canonicalName} ${s.status}`);
10160
+ const canonicalWidth = Math.max(...listed.sandboxes.map((s) => s.canonicalName.length));
10161
+ const httpsWidth = Math.max(...listed.sandboxes.map((s) => (s.httpsName?.length ?? 0) + 8));
10162
+ const lines = [
10163
+ connected ? eventLine("item", `Connected through ${connected.name}.`) : eventLine("item", `Not connected: nothing is reachable. Run \`${getCliName()} network connect [provider]\`.`)
10164
+ ];
10165
+ for (const s of listed.sandboxes) {
10166
+ const note = unreachableNote(s, connected);
10167
+ lines.push([
10168
+ ` ${s.name.padEnd(width)}`,
10169
+ s.address.padEnd(28),
10170
+ s.canonicalName.padEnd(canonicalWidth),
10171
+ ...s.httpsName ? [`https://${s.httpsName}`.padEnd(httpsWidth)] : [],
10172
+ s.status,
10173
+ ...note ? [`(${note})`] : []
10174
+ ].join(" "));
10175
+ }
10176
+ return lines;
10048
10177
  }, deps.log);
10049
10178
  return 0;
10050
10179
  } catch (err) {
@@ -10052,28 +10181,6 @@ async function runNetworkLs(args, deps = defaultDeps()) {
10052
10181
  return 1;
10053
10182
  }
10054
10183
  }
10055
- async function runNetworkUse(args, deps = defaultDeps()) {
10056
- const state = await readDeviceState(deps.dir);
10057
- if (!state) {
10058
- deps.error(`Not set up. Run \`${getCliName()} network connect\` first.`);
10059
- return 1;
10060
- }
10061
- const organization = args.clear ? null : args.org?.trim();
10062
- if (organization === undefined || organization === "") {
10063
- deps.error("Pass an organization id or slug, or --clear.");
10064
- return 1;
10065
- }
10066
- try {
10067
- const device = await deps.request(`/api/v1/network/devices/${encodeURIComponent(state.deviceId)}/short-name-organization`, { method: "PUT", body: JSON.stringify({ organization }) });
10068
- printResult(device, () => [
10069
- eventLine("done", organization === null ? "Short names are off for this machine." : `Short names (<sandbox>.gdock.internal) now resolve in ${organization}.`)
10070
- ], deps.log);
10071
- return 0;
10072
- } catch (err) {
10073
- deps.error(message(err));
10074
- return 1;
10075
- }
10076
- }
10077
10184
  async function runNetworkDevices(deps = defaultDeps()) {
10078
10185
  try {
10079
10186
  const { devices } = await deps.request("/api/v1/network/devices");
@@ -10100,11 +10207,18 @@ async function runNetworkDevicesRemove(args, deps = defaultDeps()) {
10100
10207
  var connectCommand = defineCommand({
10101
10208
  meta: {
10102
10209
  name: "connect",
10103
- description: "Put this machine into the private network: WireGuard and split DNS. Asks for sudo once."
10210
+ description: "Put this machine into the private network through one provider: WireGuard and split DNS. Asks for sudo once."
10211
+ },
10212
+ args: {
10213
+ provider: {
10214
+ type: "positional",
10215
+ required: false,
10216
+ description: "Sandbox provider name or id. Omit for the organization's default provider (else the platform default). Only its sandboxes are reachable; connecting through another replaces the connection."
10217
+ },
10218
+ org: orgArg
10104
10219
  },
10105
- args: { org: orgArg },
10106
10220
  async run({ args }) {
10107
- process.exitCode = await runNetworkConnect({ org: args.org });
10221
+ process.exitCode = await runNetworkConnect({ provider: args.provider, org: args.org });
10108
10222
  }
10109
10223
  });
10110
10224
  var disconnectCommand = defineCommand({
@@ -10114,7 +10228,10 @@ var disconnectCommand = defineCommand({
10114
10228
  }
10115
10229
  });
10116
10230
  var statusCommand = defineCommand({
10117
- meta: { name: "status", description: "Whether this machine is connected, and as which device." },
10231
+ meta: {
10232
+ name: "status",
10233
+ description: "Whether this machine is connected, through which provider, and as which device."
10234
+ },
10118
10235
  async run() {
10119
10236
  process.exitCode = await runNetworkStatus();
10120
10237
  }
@@ -10122,26 +10239,13 @@ var statusCommand = defineCommand({
10122
10239
  var lsCommand = defineCommand({
10123
10240
  meta: {
10124
10241
  name: "ls",
10125
- description: "Sandboxes of an organization with their private addresses and names."
10242
+ description: "Sandboxes of an organization with their private addresses and names, and which are reachable through the current connection."
10126
10243
  },
10127
10244
  args: { org: orgArg },
10128
10245
  async run({ args }) {
10129
10246
  process.exitCode = await runNetworkLs({ org: args.org });
10130
10247
  }
10131
10248
  });
10132
- var useCommand = defineCommand({
10133
- meta: {
10134
- name: "use",
10135
- description: "Choose the organization <sandbox>.gdock.internal names resolve in on this machine."
10136
- },
10137
- args: {
10138
- org: { type: "positional", description: "Organization id or slug.", required: false },
10139
- clear: { type: "boolean", description: "Turn short names off.", default: false }
10140
- },
10141
- async run({ args }) {
10142
- process.exitCode = await runNetworkUse({ org: args.org, clear: args.clear });
10143
- }
10144
- });
10145
10249
  var devicesRemoveCommand = defineCommand({
10146
10250
  meta: { name: "remove", description: "Remove one of your devices from the private network." },
10147
10251
  args: {
@@ -10170,7 +10274,6 @@ var networkCommand = defineCommand({
10170
10274
  disconnect: disconnectCommand,
10171
10275
  status: statusCommand,
10172
10276
  ls: lsCommand,
10173
- use: useCommand,
10174
10277
  devices: devicesCommand
10175
10278
  }
10176
10279
  });
@@ -10199,26 +10302,30 @@ async function runOrganizationsUse(args, log = console.log, error = printError)
10199
10302
  settings.defaultOrganization = { id: organization.id, slug: organization.slug };
10200
10303
  await writeSettings(settings);
10201
10304
  log(eventLine("done", `Default organization: ${organization.name} (${organization.slug ?? organization.id}).`));
10202
- log(` Stored in ${getSettingsPath()}. --org and GHOSTDOCK_ORG still take precedence.`);
10305
+ log(` Stored in ${displayPath(getSettingsPath())}. --org and GHOSTDOCK_ORG still take precedence.`);
10203
10306
  return 0;
10204
10307
  }
10205
10308
  async function runOrganizationsCurrent(args, log = console.log) {
10206
10309
  const found = await findOrganizationRef(args.org);
10207
10310
  if (args.json) {
10208
- log(JSON.stringify(found ? { organization: found.ref, source: found.source } : null, null, 2));
10311
+ log(JSON.stringify(found ? {
10312
+ organization: found.ref,
10313
+ source: found.source,
10314
+ ...found.path && { path: found.path }
10315
+ } : null, null, 2));
10209
10316
  return 0;
10210
10317
  }
10211
10318
  if (!found) {
10212
10319
  log(eventLine("item", `No organization set. Pass --org, set GHOSTDOCK_ORG, or run \`${getCliName()} organizations use <org>\`.`));
10213
10320
  return 0;
10214
10321
  }
10215
- log(eventLine("item", `${found.label} (${describeSource(found.source)})`));
10322
+ log(eventLine("item", `${found.label} (${describeSource(found)})`));
10216
10323
  return 0;
10217
10324
  }
10218
10325
  var organizationsUseCommand = defineCommand({
10219
10326
  meta: {
10220
10327
  name: "use",
10221
- description: "Set the default organization for commands run in this directory."
10328
+ description: "Set the default organization for commands run without --org (in every directory)."
10222
10329
  },
10223
10330
  args: {
10224
10331
  org: {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ghostdock",
3
3
  "description": "Ghostdock CLI — device login and agent tool invocation from the command line",
4
- "version": "0.0.10",
4
+ "version": "0.0.11",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "ghostdock": "dist/index.js",