unoverse 0.1.76 → 0.1.78
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.
- package/operator/infra/aws/main.tf +33 -0
- package/operator/infra/aws/variables.tf +4 -3
- package/operator/infra/digitalocean/variables.tf +6 -4
- package/operator/lib/deploy.sh +68 -26
- package/operator/lib/destroy.sh +8 -0
- package/operator/lib/ground.sh +52 -3
- package/operator/lib/tfsummary.mjs +13 -1
- package/package.json +1 -1
|
@@ -14,8 +14,41 @@ terraform {
|
|
|
14
14
|
}
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
# ── One universe, one group ───────────────────────────────────────────────────
|
|
18
|
+
#
|
|
19
|
+
# AWS has no projects. Its equivalent is TAGS, and `default_tags` is the only honest way
|
|
20
|
+
# to apply them: it stamps every resource this configuration creates, so nothing is
|
|
21
|
+
# forgotten the day someone adds a resource and does not think about tagging. The console
|
|
22
|
+
# view comes from the Resource Group below, and the same tag drives cost allocation, which
|
|
23
|
+
# a DigitalOcean project cannot do.
|
|
24
|
+
#
|
|
25
|
+
# It touches ONLY what this stack creates. An adopted database keeps its own tags, in
|
|
26
|
+
# keeping with the rule that a universe never takes ownership of what it borrowed
|
|
27
|
+
# (INFRASTRUCTURE.md, "Borrowed resources are never owned").
|
|
17
28
|
provider "aws" {
|
|
18
29
|
region = var.region
|
|
30
|
+
|
|
31
|
+
default_tags {
|
|
32
|
+
tags = {
|
|
33
|
+
Universe = var.name
|
|
34
|
+
ManagedBy = "unoverse"
|
|
35
|
+
Size = var.size
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
# The console's answer to "what does this universe own". A tag query rather than a
|
|
41
|
+
# membership list, so it stays correct as the ground grows without anyone maintaining it.
|
|
42
|
+
resource "aws_resourcegroups_group" "universe" {
|
|
43
|
+
name = var.name
|
|
44
|
+
description = "unoverse universe: ${var.name}"
|
|
45
|
+
|
|
46
|
+
resource_query {
|
|
47
|
+
query = jsonencode({
|
|
48
|
+
ResourceTypeFilters = ["AWS::AllSupported"]
|
|
49
|
+
TagFilters = [{ Key = "Universe", Values = [var.name] }]
|
|
50
|
+
})
|
|
51
|
+
}
|
|
19
52
|
}
|
|
20
53
|
|
|
21
54
|
# ── Network: default VPC + two security groups ─────────────────────────────────
|
|
@@ -1,13 +1,14 @@
|
|
|
1
|
+
# NO DEFAULTS ON EITHER OF THESE — see the same note in infra/digitalocean/variables.tf.
|
|
2
|
+
# Where it runs and what it is called are the developer's answers, and a default quietly
|
|
3
|
+
# supplies mine instead.
|
|
1
4
|
variable "region" {
|
|
2
5
|
description = "AWS region. Must have Bedrock model access enabled for the models you use."
|
|
3
6
|
type = string
|
|
4
|
-
default = "us-east-1"
|
|
5
7
|
}
|
|
6
8
|
|
|
7
9
|
variable "name" {
|
|
8
|
-
description = "Prefix for every resource (also the Cognito domain prefix, so keep it dns-safe)."
|
|
10
|
+
description = "Prefix for every resource (also the Cognito domain prefix, so keep it dns-safe), and the resource group they are tagged into."
|
|
9
11
|
type = string
|
|
10
|
-
default = "universe-poc"
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
variable "admin_cidr" {
|
|
@@ -5,16 +5,18 @@ variable "do_token" {
|
|
|
5
5
|
sensitive = true
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
+
# NO DEFAULTS ON EITHER OF THESE. They used to default to "lon1" and "universe-poc", which
|
|
9
|
+
# meant a tfvars missing them built somebody's universe in London under a placeholder name
|
|
10
|
+
# and reported success. Where it runs and what it is called are the developer's answers, so
|
|
11
|
+
# terraform should refuse to plan without them rather than quietly supply mine.
|
|
8
12
|
variable "region" {
|
|
9
|
-
description = "DigitalOcean region for everything."
|
|
13
|
+
description = "DigitalOcean region for everything (doctl compute region list)."
|
|
10
14
|
type = string
|
|
11
|
-
default = "lon1"
|
|
12
15
|
}
|
|
13
16
|
|
|
14
17
|
variable "name" {
|
|
15
|
-
description = "Prefix for every resource."
|
|
18
|
+
description = "Prefix for every resource, and the name of the DigitalOcean project they are grouped under."
|
|
16
19
|
type = string
|
|
17
|
-
default = "universe-poc"
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
variable "size" {
|
package/operator/lib/deploy.sh
CHANGED
|
@@ -20,16 +20,23 @@
|
|
|
20
20
|
# trusted-sources list, so pointing it at a borrowed cluster deletes the operator's own
|
|
21
21
|
# IP, their other droplets and their apps. Read the rules, add ours if missing, write
|
|
22
22
|
# them back. Nothing else moves, and running it twice changes nothing.
|
|
23
|
-
|
|
23
|
+
# Add or remove THIS universe's droplet in an adopted cluster's trusted sources.
|
|
24
|
+
# _adopted_db_access <cloud> grant|revoke [droplet_id]
|
|
25
|
+
# Symmetric on purpose. The grant existed and nothing revoked it, so a teardown left a
|
|
26
|
+
# stale rule behind — and worse, terraform's own destroy of an authoritative firewall
|
|
27
|
+
# resource PUT an EMPTY list and locked the operator out of a database it had borrowed.
|
|
28
|
+
# Both directions now touch exactly one rule and never the list.
|
|
29
|
+
_adopted_db_access() {
|
|
24
30
|
local cloud="$1" dir="$ROOT/infra/$cloud" cluster droplet_id
|
|
31
|
+
local mode="$2"
|
|
25
32
|
cluster=$(grep -E '^existing_pg_cluster_name[[:space:]]*=' "$dir/terraform.tfvars" 2>/dev/null | sed -E 's/.*"([^"]+)".*/\1/')
|
|
26
33
|
[ -n "$cluster" ] || return 0
|
|
27
34
|
[ -n "${DIGITALOCEAN_TOKEN:-}" ] || return 0
|
|
28
|
-
droplet_id
|
|
35
|
+
droplet_id="${3:-$(terraform -chdir="$dir" state show digitalocean_droplet.app 2>/dev/null | awk -F'"' '/^ *id *=/{print $2; exit}')}"
|
|
29
36
|
[ -n "$droplet_id" ] || return 0
|
|
30
37
|
|
|
31
|
-
node - "$cluster" "$droplet_id" <<'NODE'
|
|
32
|
-
const [cluster, droplet] = process.argv.slice(2);
|
|
38
|
+
node - "$cluster" "$droplet_id" "$mode" <<'NODE'
|
|
39
|
+
const [cluster, droplet, mode] = process.argv.slice(2);
|
|
33
40
|
const T = process.env.DIGITALOCEAN_TOKEN;
|
|
34
41
|
const H = { Authorization: `Bearer ${T}`, "Content-Type": "application/json" };
|
|
35
42
|
const api = (p, o = {}) => fetch(`https://api.digitalocean.com/v2${p}`, { headers: H, ...o });
|
|
@@ -38,13 +45,21 @@ const api = (p, o = {}) => fetch(`https://api.digitalocean.com/v2${p}`, { header
|
|
|
38
45
|
const db = (list.databases || []).find((d) => d.name === cluster);
|
|
39
46
|
if (!db) return;
|
|
40
47
|
const fw = await (await api(`/databases/${db.id}/firewall`)).json();
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
48
|
+
let rules = (fw.rules || []).map((r) => ({ type: r.type, value: r.value }));
|
|
49
|
+
const mine = (r) => r.type === "droplet" && String(r.value) === String(droplet);
|
|
50
|
+
const has = rules.some(mine);
|
|
51
|
+
if (mode === "revoke") {
|
|
52
|
+
if (!has) return;
|
|
53
|
+
rules = rules.filter((r) => !mine(r)); // ours only; every other rule survives
|
|
54
|
+
} else {
|
|
55
|
+
if (has) return;
|
|
56
|
+
rules.push({ type: "droplet", value: String(droplet) });
|
|
57
|
+
}
|
|
44
58
|
const res = await api(`/databases/${db.id}/firewall`, { method: "PUT", body: JSON.stringify({ rules }) });
|
|
59
|
+
const what = mode === "revoke" ? "no longer reaches" : "may reach";
|
|
45
60
|
console.log(res.ok
|
|
46
|
-
? ` \x1b[32m✓\x1b[0m This universe
|
|
47
|
-
: ` \x1b[33m!\x1b[0m Could not update ${cluster}'s trusted sources —
|
|
61
|
+
? ` \x1b[32m✓\x1b[0m This universe ${what} ${cluster} \x1b[2m(one rule changed, ${rules.length} left in place)\x1b[0m`
|
|
62
|
+
: ` \x1b[33m!\x1b[0m Could not update ${cluster}'s trusted sources — adjust droplet ${droplet} by hand`);
|
|
48
63
|
})().catch(() => {});
|
|
49
64
|
NODE
|
|
50
65
|
}
|
|
@@ -110,6 +125,24 @@ _ensure_ground_config() {
|
|
|
110
125
|
fi
|
|
111
126
|
}
|
|
112
127
|
echo ""
|
|
128
|
+
|
|
129
|
+
# YOUR IP MOVED. admin_cidr is captured once, when the ground is generated, and it is
|
|
130
|
+
# the ONLY address the firewall lets near port 22. Home broadband, a phone hotspot or a
|
|
131
|
+
# different office and the address is stale — at which point terraform applies happily,
|
|
132
|
+
# every resource is correct, and the ship step dies on "connect to host ... port 22:
|
|
133
|
+
# Operation timed out" with nothing on screen connecting the two. Check it every deploy
|
|
134
|
+
# and just fix it: the firewall is ours, and a one-rule update is not a decision.
|
|
135
|
+
local cur_ip cur_cidr have_cidr
|
|
136
|
+
cur_ip=$(curl -s --max-time 5 https://api.ipify.org 2>/dev/null || curl -s --max-time 5 https://ifconfig.me 2>/dev/null)
|
|
137
|
+
have_cidr=$(grep -E '^admin_cidr[[:space:]]*=' "$tfv" 2>/dev/null | sed -E 's/.*"([^"]+)".*/\1/')
|
|
138
|
+
if [ -n "$cur_ip" ] && [ -n "$have_cidr" ] && [ "$have_cidr" != "FILL_ME" ]; then
|
|
139
|
+
cur_cidr="$cur_ip/32"
|
|
140
|
+
if [ "$have_cidr" != "$cur_cidr" ]; then
|
|
141
|
+
node -e 'const fs=require("fs");const[f,v]=process.argv.slice(1);let s=fs.readFileSync(f,"utf8");s=s.replace(/^admin_cidr(\s*)=\s*"[^"]*"/m,"admin_cidr$1= "+JSON.stringify(v));fs.writeFileSync(f,s)' "$tfv" "$cur_cidr"
|
|
142
|
+
ok "Your address changed ${DIM}($have_cidr → $cur_cidr). Admin access follows you${NC}"
|
|
143
|
+
fi
|
|
144
|
+
fi
|
|
145
|
+
|
|
113
146
|
# POSTGRES: ASK, DO NOT ASSUME. The ground discovers an existing cluster and writes
|
|
114
147
|
# it as a COMMENTED line, so the default silently provisions a SECOND database next
|
|
115
148
|
# to one the account already pays for. A choice with a monthly bill attached is a
|
|
@@ -229,25 +262,34 @@ _ground_apply() {
|
|
|
229
262
|
*) fail "Terraform could not plan the change:"; tail -20 "$tmp/plan.log" | sed 's/^/ /'; rm -rf "$tmp"; return 1 ;;
|
|
230
263
|
esac
|
|
231
264
|
|
|
232
|
-
|
|
265
|
+
# STOPPING IS FOR LOSING SOMETHING, NOT FOR BUILDING IT. Typing `unoverse deploy` is
|
|
266
|
+
# already the decision; a y/N gate straight afterwards asks the same question twice and
|
|
267
|
+
# makes the developer's own instruction feel like a risk. The summary above still shows
|
|
268
|
+
# exactly what appears and what it costs — it is a briefing, not a checkpoint. tfsummary
|
|
269
|
+
# exits 3 when the plan destroys or replaces something, and THAT is worth interrupting.
|
|
270
|
+
local summary_rc=0
|
|
271
|
+
terraform -chdir="$dir" show -json "$planfile" 2>/dev/null | node "$GRAVITY_LIB/tfsummary.mjs"
|
|
272
|
+
summary_rc=${PIPESTATUS[1]}
|
|
273
|
+
if [ "$summary_rc" = "2" ]; then
|
|
274
|
+
# The plan could not be read, so nothing above is trustworthy. Show the real thing and
|
|
275
|
+
# fall back to asking: silence plus an unreadable plan is not consent.
|
|
233
276
|
terraform -chdir="$dir" show "$planfile"
|
|
277
|
+
summary_rc=3
|
|
234
278
|
fi
|
|
235
279
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
info "Nothing was created. Run ${BOLD}unoverse deploy${NC} when you are ready"
|
|
250
|
-
rm -rf "$tmp"; return 1
|
|
280
|
+
local docpage
|
|
281
|
+
[ "$cloud" = "aws" ] && docpage="aws" || docpage="digitalocean"
|
|
282
|
+
echo -e " ${DIM}How it fits together: https://github.com/unoverse-platform/docs/blob/main/architecture/$docpage.md${NC}"
|
|
283
|
+
echo ""
|
|
284
|
+
|
|
285
|
+
if [ "$summary_rc" = "3" ]; then
|
|
286
|
+
local REPLY
|
|
287
|
+
echo -e " ${RED}This removes or rebuilds things that already exist.${NC}"
|
|
288
|
+
read -r -p " Type y to go ahead, or Enter to stop: " REPLY
|
|
289
|
+
if [[ ! "$REPLY" =~ ^[Yy]$ ]]; then
|
|
290
|
+
info "Nothing was changed. Run ${BOLD}unoverse deploy${NC} when you are ready"
|
|
291
|
+
rm -rf "$tmp"; return 1
|
|
292
|
+
fi
|
|
251
293
|
fi
|
|
252
294
|
|
|
253
295
|
echo ""
|
|
@@ -311,7 +353,7 @@ cmd_deploy() {
|
|
|
311
353
|
exit 1
|
|
312
354
|
fi
|
|
313
355
|
|
|
314
|
-
|
|
356
|
+
_adopted_db_access "$cloud" grant
|
|
315
357
|
|
|
316
358
|
|
|
317
359
|
# Read deploy target from .env.production
|
package/operator/lib/destroy.sh
CHANGED
|
@@ -91,6 +91,14 @@ cmd_destroy() {
|
|
|
91
91
|
return 1
|
|
92
92
|
fi
|
|
93
93
|
|
|
94
|
+
# Take our rule out of the borrowed cluster BEFORE the state goes, while the droplet id
|
|
95
|
+
# is still readable. One rule, ours, never the list.
|
|
96
|
+
if [ -n "$kept" ]; then
|
|
97
|
+
local dropped
|
|
98
|
+
dropped=$(terraform -chdir="$dir" state show digitalocean_droplet.app 2>/dev/null | awk -F'"' '/^ *id *=/{print $2; exit}')
|
|
99
|
+
[ -n "$dropped" ] && _adopted_db_access "$cloud" revoke "$dropped"
|
|
100
|
+
fi
|
|
101
|
+
|
|
94
102
|
echo ""
|
|
95
103
|
info "Taking it down..."
|
|
96
104
|
echo ""
|
package/operator/lib/ground.sh
CHANGED
|
@@ -13,6 +13,44 @@ _ground_my_ip() {
|
|
|
13
13
|
curl -s --max-time 5 https://api.ipify.org 2>/dev/null || curl -s --max-time 5 https://ifconfig.me 2>/dev/null
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
# THE UNIVERSE HAS A NAME, so ask for one. This file used to write name = "universe-poc"
|
|
17
|
+
# as a literal, so every universe on every account carried the same placeholder — it names
|
|
18
|
+
# the droplet, the cache, the load balancer and the cloud project, and the developer first
|
|
19
|
+
# meets it in their provider dashboard, after the bill has started. A generated file is not
|
|
20
|
+
# a question: nobody edits it before running deploy.
|
|
21
|
+
#
|
|
22
|
+
# Sets GROUND_NAME (and GROUND_REGION when a default is passed). Both are sanitised to what
|
|
23
|
+
# cloud resource names allow, so a typed "My POC" cannot fail an apply five minutes in.
|
|
24
|
+
_ground_identity() {
|
|
25
|
+
local valid="$1" suggest
|
|
26
|
+
suggest=$(basename "$ROOT" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9-]+/-/g; s/^-+//; s/-+$//')
|
|
27
|
+
suggest="${suggest:-universe}"
|
|
28
|
+
echo ""
|
|
29
|
+
read -r -p " Name this universe [$suggest]: " GROUND_NAME
|
|
30
|
+
GROUND_NAME=$(echo "${GROUND_NAME:-$suggest}" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9-]+/-/g; s/^-+//; s/-+$//')
|
|
31
|
+
GROUND_NAME="${GROUND_NAME:-$suggest}"
|
|
32
|
+
|
|
33
|
+
# NO DEFAULT REGION. A default is a choice made on the developer's behalf, and the
|
|
34
|
+
# previous one was mine: `lon1`, written straight into the file, which is how a universe
|
|
35
|
+
# meant for Singapore was built in London. There is no neutral region to fall back to, so
|
|
36
|
+
# this asks until it gets a real answer and checks it against the provider's own list —
|
|
37
|
+
# a typo here surfaces as a five-minute apply that fails on the last resource.
|
|
38
|
+
# "-" means the region is not this ground's to ask: AWS reads it from the developer's own
|
|
39
|
+
# CLI configuration, which is already their answer.
|
|
40
|
+
GROUND_REGION=""
|
|
41
|
+
[ "$valid" = "-" ] && return 0
|
|
42
|
+
while [ -z "$GROUND_REGION" ]; do
|
|
43
|
+
read -r -p " Which region? " GROUND_REGION
|
|
44
|
+
GROUND_REGION=$(echo "$GROUND_REGION" | tr -d '[:space:]')
|
|
45
|
+
# Validate only against a list we actually retrieved. An empty list means the lookup
|
|
46
|
+
# failed, not that every region is wrong.
|
|
47
|
+
if [ -n "$GROUND_REGION" ] && [ -n "$valid" ] && ! echo "$valid" | tr ' ' '\n' | grep -qx "$GROUND_REGION"; then
|
|
48
|
+
warn "$GROUND_REGION is not a region in your account. Pick one from the list above"
|
|
49
|
+
GROUND_REGION=""
|
|
50
|
+
fi
|
|
51
|
+
done
|
|
52
|
+
}
|
|
53
|
+
|
|
16
54
|
_ground_do() {
|
|
17
55
|
local dir="$ROOT/infra/digitalocean"
|
|
18
56
|
local out="$dir/terraform.tfvars"
|
|
@@ -88,13 +126,21 @@ _ground_do() {
|
|
|
88
126
|
info "no existing Postgres cluster. Terraform will provision one"
|
|
89
127
|
fi
|
|
90
128
|
|
|
129
|
+
local regions
|
|
130
|
+
regions=$(doctl compute region list --format Slug --no-header 2>/dev/null | tr -d '\r' | xargs)
|
|
131
|
+
if [ -n "$regions" ]; then
|
|
132
|
+
echo ""
|
|
133
|
+
echo -e " ${DIM}Regions available to you: $regions${NC}"
|
|
134
|
+
fi
|
|
135
|
+
_ground_identity "$regions"
|
|
136
|
+
|
|
91
137
|
cat > "$out" <<EOF
|
|
92
138
|
# Generated by \`unoverse ground do\` on $(date +%Y-%m-%d). Discovered values are
|
|
93
139
|
# prefilled; every FILL_ME needs YOUR value before \`terraform apply\`.
|
|
94
140
|
# Token: set DIGITALOCEAN_TOKEN in the environment (preferred) or do_token here.
|
|
95
141
|
|
|
96
|
-
region = "
|
|
97
|
-
name = "
|
|
142
|
+
region = "$GROUND_REGION" # doctl compute region list
|
|
143
|
+
name = "$GROUND_NAME"
|
|
98
144
|
size = "small" # small (POC) | medium | large
|
|
99
145
|
admin_cidr = "${ip:-FILL_ME}${ip:+/32}" # YOUR IP — SSH + Dozzle only
|
|
100
146
|
ssh_key_name = "${first_key:-FILL_ME}" # must already exist in the DO account
|
|
@@ -185,12 +231,15 @@ _ground_aws() {
|
|
|
185
231
|
info "multiple Route53 zones. Pick one in the file (listed there)"
|
|
186
232
|
fi
|
|
187
233
|
|
|
234
|
+
# Region already came from the AWS CLI's own configuration, so only the name is asked.
|
|
235
|
+
_ground_identity "-"
|
|
236
|
+
|
|
188
237
|
cat > "$out" <<EOF
|
|
189
238
|
# Generated by \`unoverse ground aws\` on $(date +%Y-%m-%d). Discovered values are
|
|
190
239
|
# prefilled; every FILL_ME needs YOUR value before \`terraform apply\`.
|
|
191
240
|
|
|
192
241
|
region = "$region"
|
|
193
|
-
name = "
|
|
242
|
+
name = "$GROUND_NAME"
|
|
194
243
|
admin_cidr = "${ip:-FILL_ME}${ip:+/32}" # YOUR IP — the only SSH source
|
|
195
244
|
ssh_key_name = "${first_key:-FILL_ME}" # must already exist in the region
|
|
196
245
|
admin_email = "FILL_ME" # initial admin (Cognito user, all roles; invite emailed)
|
|
@@ -11,6 +11,11 @@
|
|
|
11
11
|
* their size, counts the plumbing (database users, firewall rules, generated passwords)
|
|
12
12
|
* rather than listing it, and shouts about anything being destroyed.
|
|
13
13
|
*
|
|
14
|
+
* EXIT CODE IS THE ANSWER TO "should the caller stop and ask?". 0 when the plan only adds
|
|
15
|
+
* or adjusts — deploy just builds, because typing `unoverse deploy` already said to. 3
|
|
16
|
+
* when something is destroyed or replaced, which is the only case worth interrupting a
|
|
17
|
+
* developer for. 2 when the JSON could not be read, so the caller shows raw terraform.
|
|
18
|
+
*
|
|
14
19
|
* NOT A COST ORACLE. The developer sees an order of magnitude rather than a surprise. Rates
|
|
15
20
|
* move, so the total is labelled approximate and the real bill is the provider's.
|
|
16
21
|
*
|
|
@@ -138,7 +143,7 @@ process.stdin.on("end", () => {
|
|
|
138
143
|
const out = [];
|
|
139
144
|
if (total === 0) {
|
|
140
145
|
console.log(`\n ${green("●")} Your infrastructure already matches. Nothing to change.\n`);
|
|
141
|
-
|
|
146
|
+
return;
|
|
142
147
|
}
|
|
143
148
|
|
|
144
149
|
const width = Math.max(...Object.values(groups).flat().map((d) => d.what.length), 0) + 2;
|
|
@@ -169,4 +174,11 @@ process.stdin.on("end", () => {
|
|
|
169
174
|
}
|
|
170
175
|
|
|
171
176
|
console.log("\n" + out.join("\n"));
|
|
177
|
+
|
|
178
|
+
// Tell the caller whether this is worth stopping for. Losing something is; building
|
|
179
|
+
// something is not. `process.exitCode` rather than `process.exit`, which can cut off
|
|
180
|
+
// the output above while it is still draining down a pipe.
|
|
181
|
+
const destructive =
|
|
182
|
+
groups.delete.length + groups.replace.length + supporting.delete + supporting.replace;
|
|
183
|
+
if (destructive) process.exitCode = 3;
|
|
172
184
|
});
|
package/package.json
CHANGED