@fabrika/provider-zerops 0.3.1 → 0.3.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fabrika/provider-zerops",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -43,11 +43,11 @@
43
43
  },
44
44
  "dependencies": {
45
45
  "zod": "^3.25.76",
46
- "@fabrika/auth": "^0.3.1",
47
- "@fabrika/auth-core": "^0.3.1",
48
- "@fabrika/installation-init": "^0.3.1",
49
- "@fabrika/provider-contract": "^0.3.1",
50
- "@fabrika/proxy-core": "^0.3.1",
46
+ "@fabrika/auth": "^0.3.2",
47
+ "@fabrika/auth-core": "^0.3.2",
48
+ "@fabrika/installation-init": "^0.3.2",
49
+ "@fabrika/provider-contract": "^0.3.2",
50
+ "@fabrika/proxy-core": "^0.3.2",
51
51
  "yaml": "^2.9.1"
52
52
  }
53
53
  }
package/src/cli-args.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { describeArgument } from '@fabrika/installation-init'
1
2
  export interface ZeropsCliArgs {
2
3
  command: string | undefined
3
4
  subcommand: string | undefined
@@ -71,7 +72,7 @@ export const parseZeropsCliArgs = (argv: readonly string[]): ZeropsCliArgs => {
71
72
  } else if (subcommand === undefined && command === 'namespace') {
72
73
  subcommand = arg
73
74
  } else {
74
- throw new Error(`Unexpected argument: ${arg}`)
75
+ throw new Error(`Unexpected argument: ${describeArgument(arg)}`)
75
76
  }
76
77
  }
77
78
 
package/src/descriptor.ts CHANGED
@@ -128,8 +128,52 @@ export const selectZeropsDescriptorSetup = (parsed: ZeropsDescriptor, setup?: st
128
128
  return selected
129
129
  }
130
130
 
131
+ const inheritanceSchema = z.object({
132
+ zerops: z.array(z.intersection(
133
+ descriptorMappingSchema,
134
+ z.object({ setup: z.string(), extends: z.union([z.string(), z.array(z.string())]).optional() }),
135
+ )),
136
+ })
137
+
138
+ /**
139
+ * The setup entries a deploy of `selected` applies: the selected one and, transitively, every setup it
140
+ * `extends`. Only these reach the running service; the other setups in the file never do.
141
+ */
142
+ export const appliedZeropsSetups = (parsed: ZeropsDescriptor, selected: string): JsonValue[] => {
143
+ const document = inheritanceSchema.safeParse(parsed.document)
144
+ if (!document.success) return invalid('requires extends to name setups')
145
+ const applied: JsonValue[] = []
146
+ const visit = (name: string): void => {
147
+ const entry = document.data.zerops.find((candidate) => candidate.setup === name)
148
+ if (entry === undefined) return invalid(`extends unknown setup \`${name}\``)
149
+ if (applied.includes(entry)) return
150
+ applied.push(entry)
151
+ for (const parent of typeof entry.extends === 'string' ? [entry.extends] : entry.extends ?? []) visit(parent)
152
+ }
153
+ visit(selected)
154
+ return applied
155
+ }
156
+
157
+ /**
158
+ * Refuse a selected setup that `extends` another without its own `run`: Zerops merges `extends` per
159
+ * whole section, and the deploy writes `FABRIKA_RELEASE` into `run`, which would replace the inherited one.
160
+ */
161
+ export const assertOwnRunWhenExtending = (parsed: ZeropsDescriptor, selected: string): void => {
162
+ const [entry] = appliedZeropsSetups(parsed, selected)
163
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) return
164
+ if (entry['extends'] !== undefined && entry['run'] === undefined) {
165
+ invalid(`setup \`${selected}\` extends another setup, so it must declare its own run section — the deploy writes FABRIKA_RELEASE into it`)
166
+ }
167
+ }
168
+
169
+ /** The document Zerops executes: the selected setup and what it extends, which is exactly what the ownership check read. */
170
+ export const executionZeropsDocument = (parsed: ZeropsDescriptor, selected: string): { readonly [key: string]: JsonValue } => ({
171
+ ...parsed.document,
172
+ zerops: appliedZeropsSetups(parsed, selected),
173
+ })
174
+
131
175
  export const prepareZeropsDescriptor = (contents: string, setup?: string): PreparedZeropsDescriptor => {
132
176
  const parsed = parseZeropsDescriptor(contents)
133
177
  const selected = selectZeropsDescriptorSetup(parsed, setup)
134
- return { contents: renderYaml(parsed.document), setup: selected }
178
+ return { contents: renderYaml(executionZeropsDocument(parsed, selected)), setup: selected }
135
179
  }
@@ -8,6 +8,7 @@
8
8
  // and prompts for nothing — and a missing required value is an error naming both the flag and the
9
9
  // variable that would supply it.
10
10
 
11
+ import { describeArgument, readEnv, requiredSetting, setting } from '@fabrika/installation-init'
11
12
  import { parsePlatformHostname, parsePlatformScheme } from './placement-options'
12
13
 
13
14
  /** Every input `runPlatformAdmin` takes from the operator. */
@@ -36,35 +37,6 @@ const FLAGS = ['--email', '--iam-host', '--scheme'] as const
36
37
  */
37
38
  export const REISSUE_FLAG = '--reissue'
38
39
 
39
- const readFlag = (argv: readonly string[], name: string): string | undefined => {
40
- const prefix = `${name}=`
41
- const matches = argv.filter((arg) => arg.startsWith(prefix))
42
- if (matches.length > 1) {
43
- throw new Error(`${name} was given more than once`)
44
- }
45
- const value = matches[0]?.slice(prefix.length)
46
- return value === undefined || value.trim() === '' ? undefined : value.trim()
47
- }
48
-
49
- const readEnv = (env: Record<string, string | undefined>, name: string): string | undefined => {
50
- const value = env[name]
51
- return value === undefined || value.trim() === '' ? undefined : value.trim()
52
- }
53
-
54
- const setting = (
55
- argv: readonly string[],
56
- env: Record<string, string | undefined>,
57
- flag: string,
58
- variable: string,
59
- ): string | undefined => readFlag(argv, flag) ?? readEnv(env, variable)
60
-
61
- const required = (value: string | undefined, flag: string, variable: string): string => {
62
- if (value === undefined) {
63
- throw new Error(`${flag}=<value> or ${variable} is required`)
64
- }
65
- return value
66
- }
67
-
68
40
  const assertKnownArguments = (argv: readonly string[]): void => {
69
41
  for (const arg of argv) {
70
42
  if (arg === REISSUE_FLAG) {
@@ -73,7 +45,7 @@ const assertKnownArguments = (argv: readonly string[]): void => {
73
45
  const name = arg.split('=')[0] ?? arg
74
46
  if (!FLAGS.some((flag) => flag === name) || !arg.includes('=')) {
75
47
  throw new Error(
76
- `unexpected argument \`${arg}\`. Credentials are read from the environment and have no flag; run `
48
+ `unexpected argument ${describeArgument(arg)}. Credentials are read from the environment and have no flag; run `
77
49
  + '`fabrika platform admin --provider=zerops --help` for the surface',
78
50
  )
79
51
  }
@@ -92,7 +64,7 @@ const readScheme = (argv: readonly string[], env: Record<string, string | undefi
92
64
  * composed from it and `--scheme`, so a value carrying either would compose a second one.
93
65
  */
94
66
  const readIamHost = (argv: readonly string[], env: Record<string, string | undefined>): string => {
95
- const host = required(setting(argv, env, '--iam-host', 'FABRIKA_PLATFORM_IAM_HOST'), '--iam-host', 'FABRIKA_PLATFORM_IAM_HOST')
67
+ const host = requiredSetting(setting(argv, env, '--iam-host', 'FABRIKA_PLATFORM_IAM_HOST'), '--iam-host', 'FABRIKA_PLATFORM_IAM_HOST')
96
68
  return parsePlatformHostname(host)
97
69
  }
98
70
 
@@ -119,7 +91,7 @@ export const parsePlatformAdminArgs = (
119
91
  ): PlatformAdminInput => {
120
92
  assertKnownArguments(argv)
121
93
  return {
122
- email: required(setting(argv, env, '--email', 'FABRIKA_PLATFORM_ADMIN_EMAIL'), '--email', 'FABRIKA_PLATFORM_ADMIN_EMAIL'),
94
+ email: requiredSetting(setting(argv, env, '--email', 'FABRIKA_PLATFORM_ADMIN_EMAIL'), '--email', 'FABRIKA_PLATFORM_ADMIN_EMAIL'),
123
95
  iamHost: readIamHost(argv, env),
124
96
  scheme: readScheme(argv, env),
125
97
  provisioningKey: provisioningKey(env),
@@ -12,6 +12,7 @@
12
12
  // The command is unattended by construction: nothing here prompts, and a missing required value is an
13
13
  // error naming both the flag and the variable that would supply it.
14
14
 
15
+ import { describeArgument, readEnv, requiredSetting, setting } from '@fabrika/installation-init'
15
16
  import { parsePlatformHosts, parsePlatformScheme } from './placement-options'
16
17
  import { readInstallationSourceDir } from './source-options'
17
18
 
@@ -52,35 +53,6 @@ const FLAGS = [
52
53
  '--source-dir',
53
54
  ] as const
54
55
 
55
- const readFlag = (argv: readonly string[], name: string): string | undefined => {
56
- const prefix = `${name}=`
57
- const matches = argv.filter((arg) => arg.startsWith(prefix))
58
- if (matches.length > 1) {
59
- throw new Error(`${name} was given more than once`)
60
- }
61
- const value = matches[0]?.slice(prefix.length)
62
- return value === undefined || value.trim() === '' ? undefined : value.trim()
63
- }
64
-
65
- const readEnv = (env: Record<string, string | undefined>, name: string): string | undefined => {
66
- const value = env[name]
67
- return value === undefined || value.trim() === '' ? undefined : value.trim()
68
- }
69
-
70
- const setting = (
71
- argv: readonly string[],
72
- env: Record<string, string | undefined>,
73
- flag: string,
74
- variable: string,
75
- ): string | undefined => readFlag(argv, flag) ?? readEnv(env, variable)
76
-
77
- const required = (value: string | undefined, flag: string, variable: string): string => {
78
- if (value === undefined) {
79
- throw new Error(`${flag}=<value> or ${variable} is required`)
80
- }
81
- return value
82
- }
83
-
84
56
  const assertKnownArguments = (argv: readonly string[]): void => {
85
57
  for (const arg of argv) {
86
58
  if (arg === '--dry-run') {
@@ -89,7 +61,7 @@ const assertKnownArguments = (argv: readonly string[]): void => {
89
61
  const name = arg.split('=')[0] ?? arg
90
62
  if (!FLAGS.some((flag) => flag === name) || !arg.includes('=')) {
91
63
  throw new Error(
92
- `unexpected argument \`${arg}\`. Credentials are read from the environment and have no flag; run `
64
+ `unexpected argument ${describeArgument(arg)}. Credentials are read from the environment and have no flag; run `
93
65
  + '`fabrika platform deploy --provider=zerops --help` for the surface',
94
66
  )
95
67
  }
@@ -123,7 +95,7 @@ const readProject = (argv: readonly string[], env: Record<string, string | undef
123
95
  if (name === undefined) {
124
96
  throw new Error('--project-id=<id> or FABRIKA_ZEROPS_PROJECT_ID is required (or --project-name with --client-id)')
125
97
  }
126
- const clientId = required(setting(argv, env, '--client-id', 'FABRIKA_ZEROPS_CLIENT_ID'), '--client-id', 'FABRIKA_ZEROPS_CLIENT_ID')
98
+ const clientId = requiredSetting(setting(argv, env, '--client-id', 'FABRIKA_ZEROPS_CLIENT_ID'), '--client-id', 'FABRIKA_ZEROPS_CLIENT_ID')
127
99
  return { kind: 'name', clientId, name }
128
100
  }
129
101
 
@@ -140,12 +112,12 @@ export const parsePlatformDeployArgs = (
140
112
  return {
141
113
  project: readProject(argv, env),
142
114
  // A dry run reads, and reading still needs a token; it is the one credential it cannot do without.
143
- accessToken: required(readEnv(env, 'FABRIKA_ZEROPS_ACCESS_TOKEN'), '(no flag)', 'FABRIKA_ZEROPS_ACCESS_TOKEN'),
144
- environment: required(setting(argv, env, '--env', 'FABRIKA_PLATFORM_ENVIRONMENT'), '--env', 'FABRIKA_PLATFORM_ENVIRONMENT'),
115
+ accessToken: requiredSetting(readEnv(env, 'FABRIKA_ZEROPS_ACCESS_TOKEN'), '(no flag)', 'FABRIKA_ZEROPS_ACCESS_TOKEN'),
116
+ environment: requiredSetting(setting(argv, env, '--env', 'FABRIKA_PLATFORM_ENVIRONMENT'), '--env', 'FABRIKA_PLATFORM_ENVIRONMENT'),
145
117
  // Not required for a dry run: it authenticates a write this mode never makes.
146
118
  iamAdminKey: dryRun
147
119
  ? readEnv(env, 'FABRIKA_IAM_PROVISIONING_KEY') ?? ''
148
- : required(readEnv(env, 'FABRIKA_IAM_PROVISIONING_KEY'), '(no flag)', 'FABRIKA_IAM_PROVISIONING_KEY'),
120
+ : requiredSetting(readEnv(env, 'FABRIKA_IAM_PROVISIONING_KEY'), '(no flag)', 'FABRIKA_IAM_PROVISIONING_KEY'),
149
121
  scheme: readScheme(argv, env),
150
122
  dryRun,
151
123
  ...(hosts === undefined ? {} : { hosts }),
@@ -1,5 +1,12 @@
1
1
  import { reconcileSchema } from '@fabrika/auth'
2
- import { info as consoleInfo, ok as consoleOk, step as consoleStep, warn as consoleWarn } from '@fabrika/installation-init'
2
+ import {
3
+ describeArgument,
4
+ info as consoleInfo,
5
+ ok as consoleOk,
6
+ step as consoleStep,
7
+ UNATTENDED_FLAG,
8
+ warn as consoleWarn,
9
+ } from '@fabrika/installation-init'
3
10
  import type { SchemaReconciler } from '@fabrika/provider-contract'
4
11
  import type { InstallationCli, InstallationCommand } from '@fabrika/provider-contract/installation'
5
12
  import { createZeropsApi, defaultSleep } from '../index'
@@ -7,9 +14,10 @@ import { consoleAdminCollaborators, runPlatformAdmin } from './admin'
7
14
  import { parsePlatformAdminArgs, REISSUE_FLAG } from './admin-options'
8
15
  import { deployPlatform, PLATFORM_CONCURRENT_DEPLOY, PLATFORM_DEPLOY_ORDER, PLATFORM_SEQUENTIAL_DEPLOY } from './deploy'
9
16
  import { parsePlatformDeployArgs } from './deploy-options'
10
- import { consoleInitCollaborators, parseInitArgs, runInit } from './init'
17
+ import { consoleInitCollaborators, runInit } from './init'
18
+ import { parseInitArgs } from './init-options'
11
19
  import { consoleInstallCollaborators, runInstall } from './install'
12
- import { parsePlatformInstallArgs, UNATTENDED_FLAG } from './install-options'
20
+ import { parsePlatformInstallArgs } from './install-options'
13
21
  import { prepareInstallationSource } from './installation-source'
14
22
  import { consoleDeployLog } from './log'
15
23
  import { generatedArtifacts } from './topology/artifacts'
@@ -34,7 +42,7 @@ const USAGE = `Zerops installation
34
42
 
35
43
  Commands:
36
44
  fabrika platform install --provider=zerops [options]
37
- fabrika platform init --provider=zerops <installation> [--repo=<owner>/<name>]
45
+ fabrika platform init --provider=zerops <installation> --tag=<tag> --project-id=<id> [options]
38
46
  fabrika platform plan --provider=zerops
39
47
  fabrika platform deploy --provider=zerops [options]
40
48
  fabrika platform admin --provider=zerops --email=<address> --iam-host=<host> [--scheme=<http|https>] [${REISSUE_FLAG}]
@@ -109,11 +117,12 @@ Creates and maintains the operator's SIDECAR REPOSITORY: the GitHub pipeline tha
109
117
  credentials. ADR-0025 puts that pipeline in a repository the operator owns — fabrika ships the
110
118
  generator, never the pipeline, because one public repository cannot hold every account's credentials.
111
119
 
112
- Interactive and laptop-side. It does the whole job and CONFIRMS before every step that leaves the
113
- operator's disk: reading the project, reading its control and proxy services, creating or pushing the
114
- repository, writing the Environment, and triggering the run.
115
- Declining a write stops there and prints what to run instead; a re-run is safe. Declining the read of
116
- control and proxy only leaves the console and IAM origins out of what init prints.
120
+ Laptop-side and prompt-free: every value comes from a flag or the environment, so a pipeline or an
121
+ agent runs it end to end. It CONFIRMS before every step that leaves the operator's disk: reading the
122
+ project, reading its control and proxy services, creating or pushing the repository, writing the
123
+ Environment, and triggering the run. A stdin that is not a terminal is refused unless \`${UNATTENDED_FLAG}\` is given.
124
+ Declining a write stops there and prints the command that repeats the run; a re-run is safe. Declining
125
+ the read of control and proxy only leaves the console and IAM origins out of what init prints.
117
126
 
118
127
  Init writes nothing to Zerops. It reads control and proxy only to print the console and IAM origins
119
128
  the installation records, and prints none it cannot verify. Public repositories deploy without a
@@ -129,16 +138,30 @@ file afterwards.
129
138
  <installation> names this installation: the GitHub Environment, the default
130
139
  repository \`contember/fabrika-zerops-<installation>\`, and the
131
140
  local checkout directory
132
- --repo=<owner>/<name> a different sidecar repository
141
+
142
+ Options (a flag beats the environment variable beside it):
143
+
144
+ --repo=<owner>/<name> a different sidecar repository
145
+ --checkout=<dir> the sidecar's local checkout, default
146
+ ./fabrika-zerops-<installation>; refused
147
+ inside another git repository
148
+ --tag=<tag> FABRIKA_PLATFORM_TAG required; the published fabrika tag to pin
149
+ --project-id=<id> FABRIKA_ZEROPS_PROJECT_ID required; the project holding the platform
150
+ --env=<name> FABRIKA_PLATFORM_ENVIRONMENT default <installation>
151
+ --iam-host=<host> FABRIKA_PLATFORM_IAM_HOST custom domains: all three hosts or none;
152
+ --console-host=<host> FABRIKA_PLATFORM_CONSOLE_HOST none derives them from the proxy's Zerops
153
+ --operations-host=<host> FABRIKA_PLATFORM_OPERATIONS_HOST subdomains
154
+ ${UNATTENDED_FLAG} answer every confirmation yes —
155
+ required when stdin is not a terminal
133
156
 
134
157
  It writes TWO secrets into that Environment and GENERATES NEITHER — both already belong to the
135
- installation, so a value invented here would not be one it accepts:
158
+ installation, so a value invented here would not be one it accepts. Both are environment only:
136
159
 
137
- FABRIKA_ZEROPS_ACCESS_TOKEN a Zerops INTEGRATION token scoped to this installation's projects
138
- FABRIKA_IAM_PROVISIONING_KEY the px_ admin key this installation's IAM was seeded with
160
+ FABRIKA_ZEROPS_ACCESS_TOKEN required the INTEGRATION token install minted (the \`control\` service's env)
161
+ FABRIKA_IAM_PROVISIONING_KEY required the px_ key install printed
162
+ FABRIKA_ZEROPS_API_URL optional region API base, when not the default
139
163
 
140
- Each is read from a hidden prompt or from the environment variable of the same name, sent to GitHub
141
- over \`gh\` stdin, and never printed and never written to disk.
164
+ Each is sent to GitHub over \`gh\` stdin, and never printed and never written to disk.
142
165
 
143
166
  The generated pipeline pins a published TAG of contember/fabrika-platform and refuses a branch, at
144
167
  init and again in the workflow. It uploads that checkout's exact committed archive to every installation service.
@@ -273,7 +296,7 @@ const neverAborted = (): AbortSignal => new AbortController().signal
273
296
 
274
297
  const runPlan = (argv: readonly string[]): void => {
275
298
  if (argv.length > 0) {
276
- throw new Error(`Unexpected Zerops plan arguments: ${argv.join(' ')}`)
299
+ throw new Error(`Unexpected Zerops plan arguments: ${argv.map(describeArgument).join(' ')}`)
277
300
  }
278
301
  const artifacts = generatedArtifacts()
279
302
  for (const artifact of artifacts) {
@@ -342,7 +365,8 @@ export const installationCli: InstallationCli = {
342
365
  return
343
366
  }
344
367
  if (command === 'init') {
345
- await runInit(parseInitArgs(argv), consoleInitCollaborators())
368
+ const input = parseInitArgs(argv, process.env)
369
+ await runInit(input, consoleInitCollaborators(input))
346
370
  return
347
371
  }
348
372
  if (command === 'plan') {
@@ -0,0 +1,148 @@
1
+ // The CLI surface of `fabrika platform init --provider=zerops`, parsed once.
2
+ //
3
+ // The same two rules `deploy-options.ts` states: every CREDENTIAL comes from the environment and only
4
+ // from the environment, and everything else may come from a flag OR the environment, flag first.
5
+ // Nothing here prompts — a missing required value is an error naming both the flag and the variable —
6
+ // so a pipeline or an agent drives init end to end. Its confirmations stay, answered by `--yes`.
7
+
8
+ import {
9
+ CHECKOUT_FLAG,
10
+ type CommandEnv,
11
+ describeArgument,
12
+ readEnv,
13
+ readFlag,
14
+ requiredSetting,
15
+ setting,
16
+ sidecarCheckoutDir,
17
+ UNATTENDED_FLAG,
18
+ } from '@fabrika/installation-init'
19
+ import { parsePlatformHosts } from './placement-options'
20
+ import { assertPinnedTag, defaultCheckoutName, defaultSidecarRepo } from './sidecar'
21
+
22
+ /** The name every bypass in this platform is gated on, and therefore the one name an installation cannot take. */
23
+ const LOCAL_ENVIRONMENT = 'local'
24
+
25
+ /** Every input `runInit` takes from the operator. */
26
+ export interface InitInput {
27
+ /** Names the GitHub Environment, the default repository and the local checkout directory. */
28
+ readonly installation: string
29
+ readonly repo: string
30
+ /** The sidecar's local checkout: `--checkout`, or `fabrika-zerops-<installation>` in the working directory. */
31
+ readonly checkoutDir: string
32
+ /** The published fabrika-platform tag the sidecar pins in `fabrika.ref`. */
33
+ readonly fabrikaRef: string
34
+ readonly projectId: string
35
+ /** The name this installation calls itself — written to every service as `ENVIRONMENT`. */
36
+ readonly environmentName: string
37
+ /** All three or none: none derives them from the proxy's Zerops subdomains. */
38
+ readonly hosts?: { readonly iam: string; readonly control: string; readonly operations: string }
39
+ /** Region API base, when the installation is not on the default one. */
40
+ readonly apiBaseUrl?: string
41
+ /** The Zerops INTEGRATION token `platform install` minted. Environment only. */
42
+ readonly accessToken: string
43
+ /** The `px_` key this installation's IAM was seeded with. Environment only. */
44
+ readonly provisioningKey: string
45
+ /** `--yes`: answer every confirmation yes. */
46
+ readonly unattended: boolean
47
+ }
48
+
49
+ const FLAGS = ['--repo', CHECKOUT_FLAG, '--tag', '--project-id', '--env', '--iam-host', '--console-host', '--operations-host'] as const
50
+
51
+ const REPO_PATTERN = /^[^/\s]+\/[^/\s]+$/
52
+
53
+ /**
54
+ * The installation's environment name, refused when it claims to be local.
55
+ *
56
+ * The same refusal the services enforce at boot: `local` is the value `localDevLogin`, the ephemeral
57
+ * signing key and the credential-less caller path are all gated on, and a deployed installation is
58
+ * never a loopback address.
59
+ */
60
+ export const checkedEnvironmentName = (declared: string): string => {
61
+ const trimmed = declared.trim()
62
+ if (trimmed === '') {
63
+ throw new Error('an installation environment name is required — it is written to every service as ENVIRONMENT')
64
+ }
65
+ if (trimmed === LOCAL_ENVIRONMENT) {
66
+ throw new Error(
67
+ `\`${LOCAL_ENVIRONMENT}\` is refused: it is the name every development bypass is gated on, and a deployed `
68
+ + 'installation is not a loopback address. Name it after the installation instead',
69
+ )
70
+ }
71
+ return trimmed
72
+ }
73
+
74
+ const assertKnownArguments = (argv: readonly string[]): void => {
75
+ for (const arg of argv) {
76
+ if (arg === UNATTENDED_FLAG || !arg.startsWith('-')) {
77
+ continue
78
+ }
79
+ const name = arg.split('=')[0] ?? arg
80
+ if (!FLAGS.some((flag) => flag === name) || !arg.includes('=')) {
81
+ throw new Error(
82
+ `unexpected argument ${describeArgument(arg)}. Credentials are read from the environment and have no flag; run `
83
+ + '`fabrika platform init --provider=zerops --help` for the surface',
84
+ )
85
+ }
86
+ }
87
+ }
88
+
89
+ const readInstallation = (argv: readonly string[]): string => {
90
+ const positional = argv.filter((arg) => !arg.startsWith('-')).map((arg) => arg.trim())
91
+ if (positional.length > 1) {
92
+ throw new Error('unexpected second positional argument: name ONE installation')
93
+ }
94
+ const installation = positional[0]
95
+ if (installation === undefined || installation === '') {
96
+ throw new Error('Zerops installation init requires an installation name, for example `fabrika platform init --provider=zerops test`')
97
+ }
98
+ return installation
99
+ }
100
+
101
+ const readRepo = (argv: readonly string[], installation: string): string => {
102
+ const repo = readFlag(argv, '--repo')
103
+ if (repo === undefined) {
104
+ return defaultSidecarRepo(installation)
105
+ }
106
+ if (!REPO_PATTERN.test(repo)) {
107
+ throw new Error(`\`${repo}\` is not a <owner>/<name> repository`)
108
+ }
109
+ return repo
110
+ }
111
+
112
+ /** A credential, with the one message that says why there is no flag for it. */
113
+ const credential = (env: CommandEnv, variable: string, what: string): string => {
114
+ const value = readEnv(env, variable)
115
+ if (value === undefined) {
116
+ throw new Error(`${variable} is required: ${what}. It has no flag — a credential on a command line lands in logs and shell history`)
117
+ }
118
+ return value
119
+ }
120
+
121
+ /** Parse the command's arguments and environment into the one input `runInit` takes. */
122
+ export const parseInitArgs = (argv: readonly string[], env: CommandEnv, cwd: string = process.cwd()): InitInput => {
123
+ assertKnownArguments(argv)
124
+ const installation = readInstallation(argv)
125
+ const hosts = parsePlatformHosts({
126
+ iam: setting(argv, env, '--iam-host', 'FABRIKA_PLATFORM_IAM_HOST'),
127
+ control: setting(argv, env, '--console-host', 'FABRIKA_PLATFORM_CONSOLE_HOST'),
128
+ operations: setting(argv, env, '--operations-host', 'FABRIKA_PLATFORM_OPERATIONS_HOST'),
129
+ })
130
+ const apiBaseUrl = readEnv(env, 'FABRIKA_ZEROPS_API_URL')
131
+ return {
132
+ installation,
133
+ repo: readRepo(argv, installation),
134
+ checkoutDir: sidecarCheckoutDir(readFlag(argv, CHECKOUT_FLAG), defaultCheckoutName(installation), cwd),
135
+ fabrikaRef: assertPinnedTag(requiredSetting(setting(argv, env, '--tag', 'FABRIKA_PLATFORM_TAG'), '--tag', 'FABRIKA_PLATFORM_TAG')),
136
+ projectId: requiredSetting(setting(argv, env, '--project-id', 'FABRIKA_ZEROPS_PROJECT_ID'), '--project-id', 'FABRIKA_ZEROPS_PROJECT_ID'),
137
+ environmentName: checkedEnvironmentName(setting(argv, env, '--env', 'FABRIKA_PLATFORM_ENVIRONMENT') ?? installation),
138
+ accessToken: credential(
139
+ env,
140
+ 'FABRIKA_ZEROPS_ACCESS_TOKEN',
141
+ "the Zerops INTEGRATION token `platform install` minted, read from the `control` service's environment",
142
+ ),
143
+ provisioningKey: credential(env, 'FABRIKA_IAM_PROVISIONING_KEY', 'the px_ key `platform install` printed'),
144
+ unattended: argv.includes(UNATTENDED_FLAG),
145
+ ...(hosts === undefined ? {} : { hosts }),
146
+ ...(apiBaseUrl === undefined ? {} : { apiBaseUrl }),
147
+ }
148
+ }
@@ -23,18 +23,17 @@
23
23
  import {
24
24
  action as consoleAction,
25
25
  configureEnvironment,
26
- confirm as promptConfirm,
26
+ type Confirmations,
27
+ confirmations,
27
28
  type EnvironmentConfig,
28
29
  ghRepoExists,
29
30
  info as consoleInfo,
30
31
  ok as consoleOk,
31
32
  scaffoldSidecarRepository,
32
- secretOrEnv,
33
- select as promptSelect,
33
+ shellWord,
34
34
  type SidecarScaffoldInput,
35
35
  type SidecarScaffoldResult,
36
36
  step as consoleStep,
37
- text as promptText,
38
37
  triggerPlatformWorkflow,
39
38
  url,
40
39
  warn as consoleWarn,
@@ -42,21 +41,10 @@ import {
42
41
  import { createZeropsApi, type ZeropsApi, type ZeropsService } from '../index'
43
42
  import { PLATFORM_PROXY_MANIFEST_TEMPLATE } from './generated/platform-proxy-manifest'
44
43
  import { derivePlatformHosts, ZEROPS_SUBDOMAIN_VARIABLE } from './hosts'
44
+ import type { InitInput } from './init-options'
45
45
  import type { InitLog } from './log'
46
46
  import { MACHINE_KEY_NEXT_STEP_TITLE, machineKeyNextStepLines } from './machine-key'
47
- import { assertPinnedTag, defaultCheckoutDir, defaultSidecarRepo, materializeSidecarScaffold, readPinnedTag, SIDECAR_FILES } from './sidecar'
48
-
49
- /** The name every bypass in this platform is gated on, and therefore the one name an installation cannot take. */
50
- const LOCAL_ENVIRONMENT = 'local'
51
-
52
- /** Everything `runInit` asks the operator. Injected so the flow can be exercised without a TTY. */
53
- export interface InitPrompts {
54
- text(question: string, fallback?: string): Promise<string>
55
- confirm(question: string, defaultYes?: boolean): Promise<boolean>
56
- select<T>(question: string, options: { label: string; value: T }[]): Promise<T>
57
- /** A hidden prompt, or the named environment variable when it is already set. NEVER echoed. */
58
- secret(variable: string, question: string): Promise<string>
59
- }
47
+ import { materializeSidecarScaffold, readPinnedTag, SIDECAR_FILES } from './sidecar'
60
48
 
61
49
  /** Everything `runInit` does outside the operator's own disk. Every one of them is confirmed first. */
62
50
  export interface InitEffects {
@@ -72,80 +60,15 @@ export interface InitEffects {
72
60
 
73
61
  export interface InitCollaborators {
74
62
  readonly log: InitLog
75
- readonly prompts: InitPrompts
63
+ readonly prompts: Confirmations
76
64
  readonly effects: InitEffects
77
65
  }
78
66
 
79
- /** `<installation>` plus the one optional flag, so the repository can be named without a prompt. */
80
- export interface InitArguments {
81
- readonly installation: string
82
- readonly repo?: string
83
- }
84
-
85
- export const parseInitArgs = (argv: readonly string[]): InitArguments => {
86
- let installation: string | undefined
87
- let repo: string | undefined
88
- for (const arg of argv) {
89
- if (arg.startsWith('--repo=')) {
90
- repo = arg.slice('--repo='.length).trim()
91
- continue
92
- }
93
- if (arg.startsWith('-')) {
94
- throw new Error(`unexpected argument \`${arg}\`. Usage: fabrika platform init --provider=zerops <installation> [--repo=<owner>/<name>]`)
95
- }
96
- if (installation !== undefined) {
97
- throw new Error(`unexpected argument \`${arg}\`: name ONE installation`)
98
- }
99
- installation = arg.trim()
100
- }
101
- if (installation === undefined || installation === '') {
102
- throw new Error('Zerops installation init requires an installation name, for example `fabrika platform init --provider=zerops test`')
103
- }
104
- if (repo !== undefined && !/^[^/\s]+\/[^/\s]+$/.test(repo)) {
105
- throw new Error(`\`${repo}\` is not a <owner>/<name> repository`)
106
- }
107
- return { installation, ...(repo === undefined || repo === '' ? {} : { repo }) }
108
- }
109
-
110
- /**
111
- * The installation's environment name, refused when it claims to be local.
112
- *
113
- * The same refusal the services enforce at boot: `local` is the value `localDevLogin`, the ephemeral
114
- * signing key and the credential-less caller path are all gated on, and a deployed installation is
115
- * never a loopback address. Refusing it here means the operator finds out at the prompt rather than in
116
- * a failed readiness check.
117
- */
118
- export const checkedEnvironmentName = (declared: string): string => {
119
- const trimmed = declared.trim()
120
- if (trimmed === '') {
121
- throw new Error('an installation environment name is required — it is written to every service as ENVIRONMENT')
122
- }
123
- if (trimmed === LOCAL_ENVIRONMENT) {
124
- throw new Error(
125
- `\`${LOCAL_ENVIRONMENT}\` is refused: it is the name every development bypass is gated on, and a deployed `
126
- + 'installation is not a loopback address. Name it after the installation instead',
127
- )
128
- }
129
- return trimmed
130
- }
131
-
132
67
  /** Where this installation answers, as far as `init` needs to know it. */
133
68
  interface Placement {
134
69
  readonly hosts?: { readonly iam: string; readonly control: string; readonly operations: string }
135
70
  }
136
71
 
137
- /** Everything collected before anything leaves this disk. */
138
- interface Collected {
139
- readonly repo: string
140
- readonly fabrikaRef: string
141
- readonly projectId: string
142
- readonly environmentName: string
143
- readonly placement: Placement
144
- readonly apiBaseUrl?: string
145
- readonly accessToken: string
146
- readonly provisioningKey: string
147
- }
148
-
149
72
  export interface InspectPlatformInput {
150
73
  readonly projectId: string
151
74
  readonly accessToken: string
@@ -159,75 +82,6 @@ export interface PlatformInspection {
159
82
  readonly proxyPublished: boolean
160
83
  }
161
84
 
162
- const requiredAnswer = async (prompts: InitPrompts, question: string, fallback?: string): Promise<string> => {
163
- const answer = (await prompts.text(question, fallback)).trim()
164
- if (answer === '') {
165
- throw new Error(`${question} is required`)
166
- }
167
- return answer
168
- }
169
-
170
- const bareHost = (value: string, label: string): string => {
171
- const host = value.trim()
172
- if (host === '' || host.includes('/') || host.includes(':')) {
173
- throw new Error(`\`${value}\` is not a bare hostname — ${label} carries no scheme and no port`)
174
- }
175
- return host
176
- }
177
-
178
- const collect = async (args: InitArguments, { log, prompts }: InitCollaborators): Promise<Collected> => {
179
- log.step('The installation this repository will deploy')
180
- log.info('Nothing leaves this machine until every question below is answered.')
181
- const repo = args.repo ?? await requiredAnswer(prompts, 'Sidecar repository (<owner>/<name>)', defaultSidecarRepo(args.installation))
182
- // Refused HERE, before the credential prompts and long before anything is created: a branch pin is a
183
- // question for the operator, not a failure in their CI.
184
- const fabrikaRef = assertPinnedTag(await requiredAnswer(prompts, 'fabrika-platform tag to pin (a published tag, never a branch)'))
185
- const projectId = await requiredAnswer(prompts, 'Zerops project id (the project holding iam, operations, proxy and control)')
186
- const environmentName = checkedEnvironmentName(await requiredAnswer(prompts, 'Installation environment name', args.installation))
187
-
188
- const custom = await prompts.select('Where does this installation answer?', [
189
- { label: 'Zerops subdomains — derived from the proxy, and the deploy publishes them', value: false },
190
- { label: 'Custom domains — bound to the project balancer out of band', value: true },
191
- ])
192
- const placement: Placement = custom
193
- ? {
194
- hosts: {
195
- iam: bareHost(await requiredAnswer(prompts, 'IAM hostname'), 'a platform host'),
196
- control: bareHost(await requiredAnswer(prompts, 'Console hostname'), 'a platform host'),
197
- operations: bareHost(await requiredAnswer(prompts, 'Operations ingest hostname'), 'a platform host'),
198
- },
199
- }
200
- : {}
201
-
202
- const apiBaseUrl = (await prompts.text('Zerops region API base (blank = the default region)', '')).trim()
203
-
204
- log.step('Credentials')
205
- log.info('Both already belong to the installation. Neither is generated here, printed, or written to disk;')
206
- log.info('each is sent to GitHub over `gh` stdin and reaches the deploy as an Environment secret.')
207
- const accessToken = await prompts.secret(
208
- 'FABRIKA_ZEROPS_ACCESS_TOKEN',
209
- "Zerops access token (an INTEGRATION token scoped to this installation's projects, never a personal one)",
210
- )
211
- const provisioningKey = await prompts.secret(
212
- 'FABRIKA_IAM_PROVISIONING_KEY',
213
- "IAM provisioning key (the px_ admin key this installation's IAM already holds)",
214
- )
215
- if (accessToken.trim() === '' || provisioningKey.trim() === '') {
216
- throw new Error('both credentials are required: the deploy authenticates to Zerops and to IAM with them')
217
- }
218
-
219
- return {
220
- repo,
221
- fabrikaRef,
222
- projectId,
223
- environmentName,
224
- placement,
225
- accessToken: accessToken.trim(),
226
- provisioningKey: provisioningKey.trim(),
227
- ...(apiBaseUrl === '' ? {} : { apiBaseUrl }),
228
- }
229
- }
230
-
231
85
  const oneService = (services: readonly ZeropsService[], name: string): ZeropsService | undefined => {
232
86
  const matches = services.filter((service) => service.name === name)
233
87
  if (matches.length > 1) throw new Error(`Zerops project has ${matches.length} services named \`${name}\``)
@@ -318,47 +172,64 @@ export const sourceConnectionSettingsUrl = (placement: Placement, platform: Plat
318
172
  }
319
173
 
320
174
  /** The Environment the generated workflow reads. Secret VALUES appear here and nowhere else. */
321
- const environmentConfig = (installation: string, collected: Collected): EnvironmentConfig => ({
322
- repo: collected.repo,
175
+ const environmentConfig = (installation: string, input: InitInput): EnvironmentConfig => ({
176
+ repo: input.repo,
323
177
  environment: installation,
324
178
  secrets: {
325
- FABRIKA_ZEROPS_ACCESS_TOKEN: collected.accessToken,
326
- FABRIKA_IAM_PROVISIONING_KEY: collected.provisioningKey,
179
+ FABRIKA_ZEROPS_ACCESS_TOKEN: input.accessToken,
180
+ FABRIKA_IAM_PROVISIONING_KEY: input.provisioningKey,
327
181
  },
328
182
  vars: {
329
- FABRIKA_ZEROPS_PROJECT_ID: collected.projectId,
330
- FABRIKA_PLATFORM_ENVIRONMENT: collected.environmentName,
183
+ FABRIKA_ZEROPS_PROJECT_ID: input.projectId,
184
+ FABRIKA_PLATFORM_ENVIRONMENT: input.environmentName,
331
185
  // Stated rather than defaulted, so the Environment describes the whole deploy.
332
186
  FABRIKA_PLATFORM_SCHEME: 'https',
333
- ...(collected.placement.hosts === undefined ? {} : {
334
- FABRIKA_PLATFORM_IAM_HOST: collected.placement.hosts.iam,
335
- FABRIKA_PLATFORM_CONSOLE_HOST: collected.placement.hosts.control,
336
- FABRIKA_PLATFORM_OPERATIONS_HOST: collected.placement.hosts.operations,
187
+ ...(input.hosts === undefined ? {} : {
188
+ FABRIKA_PLATFORM_IAM_HOST: input.hosts.iam,
189
+ FABRIKA_PLATFORM_CONSOLE_HOST: input.hosts.control,
190
+ FABRIKA_PLATFORM_OPERATIONS_HOST: input.hosts.operations,
337
191
  }),
338
- ...(collected.apiBaseUrl === undefined ? {} : { FABRIKA_ZEROPS_API_URL: collected.apiBaseUrl }),
192
+ ...(input.apiBaseUrl === undefined ? {} : { FABRIKA_ZEROPS_API_URL: input.apiBaseUrl }),
339
193
  },
340
194
  })
341
195
 
196
+ /** The command that repeats this run, with every value but the two credentials, which stay in the environment. */
197
+ const rerunCommand = (input: InitInput): string =>
198
+ [
199
+ 'fabrika platform init --provider=zerops',
200
+ ...[
201
+ input.installation,
202
+ `--repo=${input.repo}`,
203
+ `--checkout=${input.checkoutDir}`,
204
+ `--tag=${input.fabrikaRef}`,
205
+ `--project-id=${input.projectId}`,
206
+ `--env=${input.environmentName}`,
207
+ ...(input.hosts === undefined
208
+ ? []
209
+ : [`--iam-host=${input.hosts.iam}`, `--console-host=${input.hosts.control}`, `--operations-host=${input.hosts.operations}`]),
210
+ ].map(shellWord),
211
+ ].join(' ')
212
+
342
213
  /** Read the project back, so a wrong token or a mistyped id fails here rather than in someone's CI. */
343
- const verifyProject = async (collected: Collected, { log, prompts, effects }: InitCollaborators): Promise<void> => {
214
+ const verifyProject = async (input: InitInput, { log, prompts, effects }: InitCollaborators): Promise<void> => {
344
215
  log.step('Check the token against the project')
345
- if (!(await prompts.confirm(`Read Zerops project ${collected.projectId} to check the access token and the id?`, true))) {
216
+ if (!(await prompts.confirm(`Read Zerops project ${input.projectId} to check the access token and the id?`, true))) {
346
217
  log.info('Skipped. A wrong token or project id will surface in the first pipeline run instead.')
347
218
  return
348
219
  }
349
220
  // A caught error is never re-thrown as itself: it may quote a request URL or a platform payload, and
350
221
  // this is the one step holding both credentials. A short sentence naming the two causes is enough.
351
222
  const name = await effects.describeProject({
352
- projectId: collected.projectId,
353
- accessToken: collected.accessToken,
354
- ...(collected.apiBaseUrl === undefined ? {} : { apiBaseUrl: collected.apiBaseUrl }),
223
+ projectId: input.projectId,
224
+ accessToken: input.accessToken,
225
+ ...(input.apiBaseUrl === undefined ? {} : { apiBaseUrl: input.apiBaseUrl }),
355
226
  }).catch(() => {
356
227
  throw new Error(
357
- `Zerops project ${collected.projectId} could not be read: the access token does not grant it, or the id is wrong. `
228
+ `Zerops project ${input.projectId} could not be read: the access token does not grant it, or the id is wrong. `
358
229
  + 'Nothing has been created — check both and run init again',
359
230
  )
360
231
  })
361
- log.ok(`Zerops project ${name} (${collected.projectId}) is readable with this token.`)
232
+ log.ok(`Zerops project ${name} (${input.projectId}) is readable with this token.`)
362
233
  }
363
234
 
364
235
  /**
@@ -366,16 +237,16 @@ const verifyProject = async (collected: Collected, { log, prompts, effects }: In
366
237
  *
367
238
  * Declining is not fatal: init then prints no origin rather than one it would have to guess.
368
239
  */
369
- const readPlatform = async (collected: Collected, { log, prompts, effects }: InitCollaborators): Promise<PlatformInspection | undefined> => {
240
+ const readPlatform = async (input: InitInput, { log, prompts, effects }: InitCollaborators): Promise<PlatformInspection | undefined> => {
370
241
  log.step('Read the installation')
371
- if (!(await prompts.confirm(`Read \`control\` and \`proxy\` in Zerops project ${collected.projectId} for the console and IAM origins?`, true))) {
242
+ if (!(await prompts.confirm(`Read \`control\` and \`proxy\` in Zerops project ${input.projectId} for the console and IAM origins?`, true))) {
372
243
  log.info('Skipped. No console or IAM origin will be printed.')
373
244
  return undefined
374
245
  }
375
246
  return effects.inspectPlatform({
376
- projectId: collected.projectId,
377
- accessToken: collected.accessToken,
378
- ...(collected.apiBaseUrl === undefined ? {} : { apiBaseUrl: collected.apiBaseUrl }),
247
+ projectId: input.projectId,
248
+ accessToken: input.accessToken,
249
+ ...(input.apiBaseUrl === undefined ? {} : { apiBaseUrl: input.apiBaseUrl }),
379
250
  }).catch(() => {
380
251
  throw new Error('the `control` and `proxy` services could not be read; no credential value is shown. Inspect them in Zerops, then run init again')
381
252
  })
@@ -388,52 +259,53 @@ const readPlatform = async (collected: Collected, { log, prompts, effects }: Ini
388
259
  * outward step: an Environment cannot be written to a repository that does not exist, and a workflow
389
260
  * cannot be triggered without one.
390
261
  */
391
- const scaffold = async (
392
- installation: string,
393
- collected: Collected,
394
- { log, prompts, effects }: InitCollaborators,
395
- ): Promise<string | undefined> => {
262
+ const scaffold = async (input: InitInput, { log, prompts, effects }: InitCollaborators): Promise<string | undefined> => {
396
263
  log.step('Scaffold the sidecar repository')
397
- const exists = await effects.repositoryExists(collected.repo)
264
+ const exists = await effects.repositoryExists(input.repo)
398
265
  const question = exists
399
- ? `Refresh the pipeline on ${collected.repo} and push?`
400
- : `Create ${collected.repo} (private) on GitHub and push the pipeline?`
266
+ ? `Refresh the pipeline on ${input.repo} and push?`
267
+ : `Create ${input.repo} (private) on GitHub and push the pipeline?`
401
268
  if (!(await prompts.confirm(question, true))) {
402
269
  log.action('OPERATOR ACTION — nothing was created; run init again when ready', [
403
- `fabrika platform init --provider=zerops ${installation} --repo=${collected.repo}`,
270
+ rerunCommand(input),
404
271
  ])
405
272
  return undefined
406
273
  }
407
- const dir = defaultCheckoutDir(installation)
408
274
  const result = await effects.scaffold({
409
- repo: collected.repo,
410
- dir,
275
+ repo: input.repo,
276
+ dir: input.checkoutDir,
411
277
  files: [...SIDECAR_FILES],
412
278
  materialize: (target) =>
413
279
  materializeSidecarScaffold(target, {
414
- installation,
415
- repo: collected.repo,
416
- fabrikaRef: collected.fabrikaRef,
280
+ installation: input.installation,
281
+ repo: input.repo,
282
+ fabrikaRef: input.fabrikaRef,
417
283
  }),
418
284
  })
419
285
  const pinned = await readPinnedTag(result.dir)
420
- log.ok(`${collected.repo} ${result.created ? 'created' : 'refreshed'} — pinned fabrika tag ${pinned}`)
421
- if (pinned !== collected.fabrikaRef) {
422
- log.warn(`the existing pin ${pinned} was kept; ${collected.fabrikaRef} was not written, because fabrika.ref is operator-owned`)
286
+ log.ok(`${input.repo} ${result.created ? 'created' : 'refreshed'} — pinned fabrika tag ${pinned}`)
287
+ if (pinned !== input.fabrikaRef) {
288
+ log.warn(`the existing pin ${pinned} was kept; ${input.fabrikaRef} was not written, because fabrika.ref is operator-owned`)
423
289
  }
424
290
  return result.dir
425
291
  }
426
292
 
427
293
  /** Run the whole thing for `<installation>`. */
428
- export const runInit = async (args: InitArguments, collaborators: InitCollaborators): Promise<void> => {
294
+ export const runInit = async (input: InitInput, collaborators: InitCollaborators): Promise<void> => {
429
295
  const { log, prompts, effects } = collaborators
430
- log.info(`fabrika platform init — the ${args.installation} Zerops installation's sidecar repository`)
296
+ log.info(`fabrika platform init — the ${input.installation} Zerops installation's sidecar repository`)
431
297
  log.info('This UPDATES an installation that already exists. It does not create one.')
432
298
 
433
- const collected = await collect(args, collaborators)
434
- await verifyProject(collected, collaborators)
435
- const platform = await readPlatform(collected, collaborators)
436
- const settingsUrl = platform === undefined ? undefined : sourceConnectionSettingsUrl(collected.placement, platform)
299
+ log.step('The installation this repository will deploy')
300
+ log.info(`repository ${input.repo}, pinned to ${input.fabrikaRef}; Zerops project ${input.projectId}, environment \`${input.environmentName}\``)
301
+ log.info(
302
+ input.hosts === undefined
303
+ ? "hosts: derived from the proxy's Zerops subdomains"
304
+ : `hosts: iam ${input.hosts.iam}, console ${input.hosts.control}, operations ${input.hosts.operations}`,
305
+ )
306
+ await verifyProject(input, collaborators)
307
+ const platform = await readPlatform(input, collaborators)
308
+ const settingsUrl = platform === undefined ? undefined : sourceConnectionSettingsUrl(input, platform)
437
309
  log.info('Public repositories deploy without a GitHub App. Connect private GitHub source in Control.')
438
310
  log.action(
439
311
  'OPERATOR ACTION — connect GitHub source in Control',
@@ -442,51 +314,51 @@ export const runInit = async (args: InitArguments, collaborators: InitCollaborat
442
314
  : [url(settingsUrl)],
443
315
  )
444
316
 
445
- const dir = await scaffold(args.installation, collected, collaborators)
317
+ const dir = await scaffold(input, collaborators)
446
318
  if (dir === undefined) {
447
319
  return
448
320
  }
449
321
 
450
- log.step(`Configure the ${args.installation} GitHub Environment`)
451
- const config = environmentConfig(args.installation, collected)
452
- log.info(`${Object.keys(config.secrets).length} secret(s) and ${Object.keys(config.vars).length} variable(s) on ${collected.repo}.`)
453
- if (!(await prompts.confirm(`Write them to the ${args.installation} Environment now (secret VALUES go to GitHub over \`gh\` stdin)?`, true))) {
322
+ log.step(`Configure the ${input.installation} GitHub Environment`)
323
+ const config = environmentConfig(input.installation, input)
324
+ log.info(`${Object.keys(config.secrets).length} secret(s) and ${Object.keys(config.vars).length} variable(s) on ${input.repo}.`)
325
+ if (!(await prompts.confirm(`Write them to the ${input.installation} Environment now (secret VALUES go to GitHub over \`gh\` stdin)?`, true))) {
454
326
  log.action('OPERATOR ACTION — the pipeline cannot run until the Environment holds these', [
455
- `fabrika platform init --provider=zerops ${args.installation} --repo=${collected.repo}`,
327
+ rerunCommand(input),
456
328
  ])
457
329
  return
458
330
  }
459
331
  await effects.configureEnvironment(config)
460
- log.ok(`Environment ${args.installation} written: ${Object.keys(config.secrets).join(', ')} and ${Object.keys(config.vars).join(', ')}.`)
332
+ log.ok(`Environment ${input.installation} written: ${Object.keys(config.secrets).join(', ')} and ${Object.keys(config.vars).join(', ')}.`)
461
333
 
462
334
  log.step('Trigger the deploy')
463
335
  log.info("The pipeline runs the whole ordered sequence in the operator's CI; this machine deploys nothing.")
464
- if (!(await prompts.confirm(`Run the platform workflow on ${collected.repo} now?`, true))) {
336
+ if (!(await prompts.confirm(`Run the platform workflow on ${input.repo} now?`, true))) {
465
337
  log.action('OPERATOR ACTION — run it when ready', [
466
- `gh workflow run platform.yml --repo ${collected.repo}`,
467
- `or: ${url(`https://github.com/${collected.repo}/actions`)} → platform → Run workflow`,
338
+ `gh workflow run platform.yml --repo ${input.repo}`,
339
+ `or: ${url(`https://github.com/${input.repo}/actions`)} → platform → Run workflow`,
468
340
  ])
469
341
  } else {
470
- await effects.triggerWorkflow(collected.repo)
342
+ await effects.triggerWorkflow(input.repo)
471
343
  log.ok('Platform workflow triggered.')
472
- log.info(`Watch: ${url(`https://github.com/${collected.repo}/actions`)} (or: gh run watch --repo ${collected.repo})`)
344
+ log.info(`Watch: ${url(`https://github.com/${input.repo}/actions`)} (or: gh run watch --repo ${input.repo})`)
473
345
  }
474
346
 
475
347
  log.step('Done')
476
- log.ok(`Sidecar repository: ${collected.repo} (local checkout: ${dir})`)
348
+ log.ok(`Sidecar repository: ${input.repo} (local checkout: ${dir})`)
477
349
  log.info('Roll this installation forward by bumping fabrika.ref to a newer published tag and pushing.')
478
350
  log.info("Who may administer the installation is IAM's to say: this repository holds no admission list to close later.")
479
351
  // The origin comes off the live control service, never from what was typed here: an operator who
480
352
  // pastes a guessed hostname into FABRIKA_IAM_RPC_URL gets "Unable to connect" and no explanation.
481
- const iamOrigin = platform === undefined ? undefined : platformIamOrigin(collected.placement, platform)
353
+ const iamOrigin = platform === undefined ? undefined : platformIamOrigin(input, platform)
482
354
  log.action(
483
355
  MACHINE_KEY_NEXT_STEP_TITLE,
484
- machineKeyNextStepLines({ sidecarEnvironment: args.installation, ...(iamOrigin === undefined ? {} : { iamOrigin }) }),
356
+ machineKeyNextStepLines({ sidecarEnvironment: input.installation, ...(iamOrigin === undefined ? {} : { iamOrigin }) }),
485
357
  )
486
358
  }
487
359
 
488
360
  /** The real collaborators: a TTY, `gh`, git, and one read of the Zerops API. */
489
- export const consoleInitCollaborators = (): InitCollaborators => ({
361
+ export const consoleInitCollaborators = (input: InitInput): InitCollaborators => ({
490
362
  log: {
491
363
  step: (title) => consoleStep(title),
492
364
  info: (message) => consoleInfo(message),
@@ -494,12 +366,13 @@ export const consoleInitCollaborators = (): InitCollaborators => ({
494
366
  ok: (message) => consoleOk(message),
495
367
  action: (title, lines) => consoleAction(title, [...lines]),
496
368
  },
497
- prompts: {
498
- text: (question, fallback) => promptText(question, fallback),
499
- confirm: (question, defaultYes) => promptConfirm(question, defaultYes),
500
- select: (question, options) => promptSelect(question, options),
501
- secret: (variable, question) => secretOrEnv(variable, question),
502
- },
369
+ // First, before anything is contacted: a run nobody can confirm does not start.
370
+ prompts: confirmations(
371
+ input.unattended,
372
+ process.stdin,
373
+ (line) => consoleInfo(line),
374
+ 'creates or pushes the sidecar repository, writes its GitHub Environment secrets and triggers the platform workflow',
375
+ ),
503
376
  effects: {
504
377
  repositoryExists: (repo) => ghRepoExists(repo),
505
378
  scaffold: (input) => scaffoldSidecarRepository(input),
@@ -1,3 +1,4 @@
1
+ import type { Confirmations } from '@fabrika/installation-init'
1
2
  import type { SchemaReconciler } from '@fabrika/provider-contract'
2
3
  import type { Sleeper, ZeropsApi } from '../index'
3
4
  import type { PlatformSourcePreparer } from './installation-source'
@@ -5,9 +6,7 @@ import type { InitLog } from './log'
5
6
  import type { PlatformUploadFetch } from './platform-upload'
6
7
 
7
8
  /** The one question this command asks. Injected so the flow can be exercised without a TTY. */
8
- export interface InstallPrompts {
9
- confirm(question: string, defaultYes?: boolean): Promise<boolean>
10
- }
9
+ export type InstallPrompts = Confirmations
11
10
 
12
11
  /** Everything `runInstall` reaches the outside world through. */
13
12
  export interface InstallCollaborators {
@@ -8,6 +8,7 @@
8
8
  // operator's disk — but nothing here prompts: a missing required value is an error naming both the flag
9
9
  // and the variable that would supply it, so a mistyped install fails before it contacts anything.
10
10
 
11
+ import { describeArgument, readEnv, readFlag, requiredSetting, setting, UNATTENDED_FLAG } from '@fabrika/installation-init'
11
12
  import { parsePlatformScheme } from './placement-options'
12
13
  import { readInstallationSourceDir } from './source-options'
13
14
 
@@ -52,50 +53,12 @@ export const FABRIKA_REPOSITORY_URL = 'https://github.com/contember/fabrika-plat
52
53
 
53
54
  const FLAGS = ['--project-id', '--project-name', '--client-id', '--env', '--scheme', '--source-dir', '--tier'] as const
54
55
 
55
- /**
56
- * The one way this command runs with nobody watching it.
57
- *
58
- * Every confirmation defaults to yes, so a pipe carrying blank lines answers all six of them with
59
- * nobody reading what they agree to — which is why saying yes unattended is a FLAG and never an
60
- * inference: it makes the choice explicit, and it puts it in the shell history of whoever ran it.
61
- */
62
- export const UNATTENDED_FLAG = '--yes'
63
-
64
56
  /** `--create-project`: bring the project up too, instead of naming one the operator made. */
65
57
  export const CREATE_PROJECT_FLAG = '--create-project'
66
58
 
67
59
  /** The two flags that carry no value. Written with one, they are `unexpected argument` like any other. */
68
60
  const BOOLEAN_FLAGS = [UNATTENDED_FLAG, CREATE_PROJECT_FLAG] as const
69
61
 
70
- const readFlag = (argv: readonly string[], name: string): string | undefined => {
71
- const prefix = `${name}=`
72
- const matches = argv.filter((arg) => arg.startsWith(prefix))
73
- if (matches.length > 1) {
74
- throw new Error(`${name} was given more than once`)
75
- }
76
- const value = matches[0]?.slice(prefix.length)
77
- return value === undefined || value.trim() === '' ? undefined : value.trim()
78
- }
79
-
80
- const readEnv = (env: Record<string, string | undefined>, name: string): string | undefined => {
81
- const value = env[name]
82
- return value === undefined || value.trim() === '' ? undefined : value.trim()
83
- }
84
-
85
- const setting = (
86
- argv: readonly string[],
87
- env: Record<string, string | undefined>,
88
- flag: string,
89
- variable: string,
90
- ): string | undefined => readFlag(argv, flag) ?? readEnv(env, variable)
91
-
92
- const required = (value: string | undefined, flag: string, variable: string): string => {
93
- if (value === undefined) {
94
- throw new Error(`${flag}=<value> or ${variable} is required`)
95
- }
96
- return value
97
- }
98
-
99
62
  const assertKnownArguments = (argv: readonly string[]): void => {
100
63
  for (const arg of argv) {
101
64
  if (BOOLEAN_FLAGS.some((flag) => flag === arg)) {
@@ -104,7 +67,7 @@ const assertKnownArguments = (argv: readonly string[]): void => {
104
67
  const name = arg.split('=')[0] ?? arg
105
68
  if (!FLAGS.some((flag) => flag === name) || !arg.includes('=')) {
106
69
  throw new Error(
107
- `unexpected argument \`${arg}\`. Credentials are read from the environment and have no flag; run `
70
+ `unexpected argument ${describeArgument(arg)}. Credentials are read from the environment and have no flag; run `
108
71
  + '`fabrika platform install --provider=zerops --help` for the surface',
109
72
  )
110
73
  }
@@ -135,7 +98,7 @@ const readScheme = (argv: readonly string[], env: Record<string, string | undefi
135
98
  }
136
99
 
137
100
  const readEnvironment = (argv: readonly string[], env: Record<string, string | undefined>): string =>
138
- required(setting(argv, env, '--env', 'FABRIKA_PLATFORM_ENVIRONMENT'), '--env', 'FABRIKA_PLATFORM_ENVIRONMENT')
101
+ requiredSetting(setting(argv, env, '--env', 'FABRIKA_PLATFORM_ENVIRONMENT'), '--env', 'FABRIKA_PLATFORM_ENVIRONMENT')
139
102
 
140
103
  /** The name a created project takes when the operator did not choose one: the installation's own. */
141
104
  const defaultProjectName = (environment: string): string => `fabrika-${environment}`
@@ -158,7 +121,7 @@ const readProjectSource = (argv: readonly string[], env: Record<string, string |
158
121
  + 'exists takes --project-id=<id>, which names it by id',
159
122
  )
160
123
  }
161
- return { kind: 'existing', projectId: required(projectId, '--project-id', 'FABRIKA_ZEROPS_PROJECT_ID') }
124
+ return { kind: 'existing', projectId: requiredSetting(projectId, '--project-id', 'FABRIKA_ZEROPS_PROJECT_ID') }
162
125
  }
163
126
  if (projectId !== undefined) {
164
127
  const source = readFlag(argv, '--project-id') === undefined ? 'FABRIKA_ZEROPS_PROJECT_ID' : '--project-id'
@@ -181,8 +144,8 @@ export const parsePlatformInstallArgs = (
181
144
  project: readProjectSource(argv, env),
182
145
  // Named even when the project id is known: `POST /client/{id}/integration-token` is addressed by
183
146
  // CLIENT, and the project read does not hand one back.
184
- clientId: required(setting(argv, env, '--client-id', 'FABRIKA_ZEROPS_CLIENT_ID'), '--client-id', 'FABRIKA_ZEROPS_CLIENT_ID'),
185
- accessToken: required(readEnv(env, 'FABRIKA_ZEROPS_ACCESS_TOKEN'), '(no flag)', 'FABRIKA_ZEROPS_ACCESS_TOKEN'),
147
+ clientId: requiredSetting(setting(argv, env, '--client-id', 'FABRIKA_ZEROPS_CLIENT_ID'), '--client-id', 'FABRIKA_ZEROPS_CLIENT_ID'),
148
+ accessToken: requiredSetting(readEnv(env, 'FABRIKA_ZEROPS_ACCESS_TOKEN'), '(no flag)', 'FABRIKA_ZEROPS_ACCESS_TOKEN'),
186
149
  environment: readEnvironment(argv, env),
187
150
  scheme: readScheme(argv, env),
188
151
  sourceDir,
@@ -4,7 +4,7 @@
4
4
 
5
5
  import {
6
6
  action as consoleAction,
7
- confirm as promptConfirm,
7
+ confirmations,
8
8
  info as consoleInfo,
9
9
  ok as consoleOk,
10
10
  step as consoleStep,
@@ -24,10 +24,10 @@ import {
24
24
  } from './deploy'
25
25
  import { PLATFORM_PROXY_MANIFEST_TEMPLATE } from './generated/platform-proxy-manifest'
26
26
  import { derivePlatformHosts, type PlatformHosts, platformOrigin, ZEROPS_SUBDOMAIN_VARIABLE } from './hosts'
27
- import { checkedEnvironmentName } from './init'
27
+ import { checkedEnvironmentName } from './init-options'
28
28
  import { assertRunning, type InstallCollaborators, type InstallPrompts } from './install-collaborators'
29
29
  import { installationServiceEnv, passOneProxyEnv, writeServiceEnv } from './install-environment'
30
- import { type PlatformInstallInput, UNATTENDED_FLAG } from './install-options'
30
+ import type { PlatformInstallInput } from './install-options'
31
31
  import { resolveInstallationProject } from './install-project'
32
32
  import {
33
33
  assertNoServiceIsBeingCreated,
@@ -226,39 +226,15 @@ const installPreparedPlatform = async (
226
226
  }
227
227
 
228
228
  /**
229
- * How this command's confirmations are answered — and the refusal when nothing can answer them.
230
- *
231
- * The confirmations are the only thing standing between a stray invocation and an installation: this
232
- * command generates six credentials, mints a Zerops token on the account and deploys. Every one of
233
- * them defaults to yes, so a pipe carrying blank lines answers all six of them with nobody reading
234
- * what they agree to. So a run with no terminal is refused unless the operator asked for it by name.
229
+ * The confirmations stand between a stray invocation and an installation: this command generates six
230
+ * credentials, mints a Zerops token on the account and deploys.
235
231
  */
236
232
  export const installPrompts = (
237
233
  input: PlatformInstallInput,
238
234
  stdin: { readonly isTTY?: boolean },
239
235
  announce: (line: string) => void,
240
- ): InstallPrompts => {
241
- if (input.unattended) {
242
- // The flag IS the yes, given once for the whole sequence; nothing reads stdin after it. Each
243
- // question is still printed with the answer it was given, so an unattended run leaves the same
244
- // record of what was agreed to that an interactive one does.
245
- return {
246
- confirm: (question) => {
247
- announce(`${question} yes (${UNATTENDED_FLAG})`)
248
- return Promise.resolve(true)
249
- },
250
- }
251
- }
252
- if (stdin.isTTY !== true) {
253
- throw new Error(
254
- "stdin is not a terminal, so nobody is here to confirm what this command does — it generates this installation's "
255
- + 'credentials, mints a Zerops integration token and deploys. Run it from a terminal, or pass `'
256
- + UNATTENDED_FLAG
257
- + '` to answer every confirmation yes unattended',
258
- )
259
- }
260
- return { confirm: (question, defaultYes) => promptConfirm(question, defaultYes) }
261
- }
236
+ ): InstallPrompts =>
237
+ confirmations(input.unattended, stdin, announce, "generates this installation's credentials, mints a Zerops integration token and deploys")
262
238
 
263
239
  /** The real collaborators: a TTY for the confirmations, the Zerops API, and IAM's own schema reconciler. */
264
240
  export const consoleInstallCollaborators = (input: PlatformInstallInput, reconcileSchema: SchemaReconciler): InstallCollaborators => {
@@ -119,4 +119,7 @@ export const readPinnedTag = async (dir: string): Promise<string> => {
119
119
  export const defaultSidecarRepo = (installation: string): string => `contember/fabrika-zerops-${installation}`
120
120
 
121
121
  /** Where the sidecar is checked out locally: `./fabrika-zerops-<installation>` under the cwd. */
122
- export const defaultCheckoutDir = (installation: string): string => resolve(process.cwd(), `fabrika-zerops-${installation}`)
122
+ /** The sidecar checkout's directory name when `--checkout` is not given; `init` creates it and `upgrade` reads it. */
123
+ export const defaultCheckoutName = (installation: string): string => `fabrika-zerops-${installation}`
124
+
125
+ export const defaultCheckoutDir = (installation: string): string => resolve(process.cwd(), defaultCheckoutName(installation))
@@ -8,6 +8,7 @@
8
8
  // The pin is checked BEFORE anything leaves this machine: `assertPinnedTag` refuses a branch or a SHA
9
9
  // (ADR-0025) without a network call, so `--to=main` fails on the spot rather than after a clone.
10
10
 
11
+ import { describeArgument, readFlag } from '@fabrika/installation-init'
11
12
  import { assertPinnedTag, defaultCheckoutDir } from './sidecar'
12
13
 
13
14
  /** Where the sidecar repository this command edits is found. */
@@ -43,16 +44,6 @@ export const UPGRADE_DRY_RUN_FLAG = '--dry-run'
43
44
  */
44
45
  const REPO_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._-]*$/
45
46
 
46
- const readFlag = (argv: readonly string[], name: string): string | undefined => {
47
- const prefix = `${name}=`
48
- const matches = argv.filter((arg) => arg.startsWith(prefix))
49
- if (matches.length > 1) {
50
- throw new Error(`${name} was given more than once`)
51
- }
52
- const value = matches[0]?.slice(prefix.length)
53
- return value === undefined || value.trim() === '' ? undefined : value.trim()
54
- }
55
-
56
47
  const assertKnownArguments = (argv: readonly string[]): void => {
57
48
  for (const arg of argv) {
58
49
  if (arg === UPGRADE_DRY_RUN_FLAG || !arg.startsWith('-')) {
@@ -61,7 +52,7 @@ const assertKnownArguments = (argv: readonly string[]): void => {
61
52
  const name = arg.split('=')[0] ?? arg
62
53
  if (!FLAGS.some((flag) => flag === name) || !arg.includes('=')) {
63
54
  throw new Error(
64
- `unexpected argument \`${arg}\`. Run \`fabrika platform upgrade --provider=zerops --help\` for the surface`,
55
+ `unexpected argument ${describeArgument(arg)}. Run \`fabrika platform upgrade --provider=zerops --help\` for the surface`,
65
56
  )
66
57
  }
67
58
  }
@@ -71,7 +62,7 @@ const assertKnownArguments = (argv: readonly string[]): void => {
71
62
  const readInstallation = (argv: readonly string[]): string | undefined => {
72
63
  const positional = argv.filter((arg) => !arg.startsWith('-'))
73
64
  if (positional.length > 1) {
74
- throw new Error(`unexpected argument \`${positional[1]}\`: name ONE installation`)
65
+ throw new Error('unexpected second positional argument: name ONE installation')
75
66
  }
76
67
  const installation = positional[0]?.trim()
77
68
  return installation === undefined || installation === '' ? undefined : installation
@@ -1,7 +1,13 @@
1
1
  import type { JsonValue } from '@fabrika/provider-contract'
2
2
  import { z } from 'zod'
3
3
  import { renderYaml } from './compile'
4
- import { parseZeropsDescriptor, type PreparedZeropsDescriptor, selectZeropsDescriptorSetup } from './descriptor'
4
+ import {
5
+ assertOwnRunWhenExtending,
6
+ executionZeropsDocument,
7
+ parseZeropsDescriptor,
8
+ type PreparedZeropsDescriptor,
9
+ selectZeropsDescriptorSetup,
10
+ } from './descriptor'
5
11
 
6
12
  export type ManagedKey =
7
13
  | 'FABRIKA_RELEASE'
@@ -93,7 +99,9 @@ export function overlayRelease(contents: string, setup: string | undefined, rele
93
99
  const parsed = parseZeropsDescriptor(contents)
94
100
  assertAuthoredManagedDocument(parsed.document)
95
101
  const selected = selectZeropsDescriptorSetup(parsed, setup)
96
- const entries = parsed.document['zerops']
102
+ assertOwnRunWhenExtending(parsed, selected)
103
+ const execution = executionZeropsDocument(parsed, selected)
104
+ const entries = execution['zerops']
97
105
  if (!Array.isArray(entries)) throw new Error('zerops: descriptor requires setup entries')
98
106
  const overlaid = release === null ? entries : entries.map((entry) => {
99
107
  if (!isMapping(entry) || entry['setup'] !== selected) return entry
@@ -101,5 +109,5 @@ export function overlayRelease(contents: string, setup: string | undefined, rele
101
109
  const env = isMapping(run['envVariables']) ? run['envVariables'] : {}
102
110
  return { ...entry, run: { ...run, envVariables: { ...env, FABRIKA_RELEASE: release } } }
103
111
  })
104
- return { contents: renderYaml({ ...parsed.document, zerops: overlaid }), setup: selected }
112
+ return { contents: renderYaml({ ...execution, zerops: overlaid }), setup: selected }
105
113
  }
package/src/manifest.ts CHANGED
@@ -4,7 +4,7 @@ import type { JsonValue, ProviderCodec } from '@fabrika/provider-contract'
4
4
  import { normalizeGateRule, proxyGateRuleFieldsSchema } from '@fabrika/proxy-core/manifest'
5
5
  import { z } from 'zod'
6
6
  import { compileImport, ENV_ISOLATION, OVERRIDE, renderYaml } from './compile'
7
- import { parseZeropsDescriptor, selectZeropsDescriptorSetup } from './descriptor'
7
+ import { assertOwnRunWhenExtending, executionZeropsDocument, parseZeropsDescriptor, selectZeropsDescriptorSetup } from './descriptor'
8
8
  import { assertAuthoredManagedDocument, assertAuthoredManagedInputs } from './managed-env'
9
9
  import { allowedReferenceHosts, foreignReferenceHost } from './service-references'
10
10
  import type { ZeropsAppConfig, ZeropsNamespaceResourceRequirement, ZeropsProxySpec, ZeropsSharedPostgresBinding } from './types'
@@ -288,14 +288,17 @@ export function parseFabrikaManifest(value: unknown, expected: ManifestExpectati
288
288
  if (!serviceHostnames.includes(deployService)) {
289
289
  throw new Error('invalid fabrika manifest Zerops deploy service')
290
290
  }
291
+ // Only the selected setup and what it extends reach this app's service, so a sibling setup for another
292
+ // placement may reference services this app does not own.
293
+ const selectedSetup = selectZeropsDescriptorSetup(parsedDescriptor, zeropsSetup)
294
+ assertOwnRunWhenExtending(parsedDescriptor, selectedSetup)
291
295
  const foreignInDescriptor = foreignReferenceInValue(
292
- parsedDescriptor.document,
296
+ executionZeropsDocument(parsedDescriptor, selectedSetup),
293
297
  allowedReferenceHosts(serviceHostnames),
294
298
  )
295
299
  if (foreignInDescriptor !== null) {
296
300
  throw new Error(`invalid fabrika manifest: zerops.yaml references service \`${foreignInDescriptor}\`, which this app does not own`)
297
301
  }
298
- selectZeropsDescriptorSetup(parsedDescriptor, zeropsSetup)
299
302
  if (proxy !== undefined) {
300
303
  const separator = proxy.upstream.indexOf(':')
301
304
  const upstreamHostname = separator === -1 ? proxy.upstream : proxy.upstream.slice(0, separator)