@fabrika/installation-init 0.8.2 → 0.9.0-cloudflare.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.
@@ -0,0 +1,206 @@
1
+ // The environment fabrika's four platform services read, as one installation's values.
2
+ //
3
+ // The variable NAMES are the services' own (`@fabrika/iam`'s `node/runtime.ts`, Control's and Operations'
4
+ // configuration, the proxy's `readProxyEnv`), so they are the same on every cloud. What a provider supplies
5
+ // is where a service's data lives — a DSN or the cloud's reference to one — and where the proxy dials IAM
6
+ // privately; it adds the variables of its own cloud beside these and writes the result.
7
+ //
8
+ // IAM's Bun runtime reads `ISSUER` and `ENVIRONMENT` UNPREFIXED and everything else with the
9
+ // `FABRIKA_IAM_` prefix. Both spellings below are the ones it actually reads.
10
+
11
+ import { FABRIKA_SERVICE_TABLE_DIGEST, IAM_PROXY_SECRET_VARIABLE } from '@fabrika/auth-core'
12
+ import { createHmac } from 'node:crypto'
13
+ import type { InstallationEmail } from './email'
14
+ import type { IamOAuthConfig } from './mcp'
15
+ import { type PlatformHosts, platformOrigin } from './proxy-manifest'
16
+ import type { InstallationSecrets } from './secrets'
17
+
18
+ /** The four services an installation configures. Each is a service of the platform's own topology. */
19
+ export type PlatformService = 'iam' | 'operations' | 'control' | 'proxy'
20
+
21
+ /** Each service's variables, in the order they are written. */
22
+ export type PlatformServiceEnvironment = Readonly<Record<PlatformService, ReadonlyMap<string, string>>>
23
+
24
+ /** IAM's OAuth configuration for MCP clients (ADR-0049): written only when the installation enables MCP authoring. */
25
+ export const IAM_OAUTH_CONFIG_VARIABLE = 'FABRIKA_IAM_OAUTH_CONFIG'
26
+
27
+ /** An object store's coordinates, in the provider's own spelling (values or references to them). */
28
+ export interface PlatformStorage {
29
+ readonly bucket: string
30
+ readonly accessKeyId: string
31
+ readonly secretAccessKey: string
32
+ readonly endpoint: string
33
+ }
34
+
35
+ /** Where each service's data lives. */
36
+ export interface PlatformData {
37
+ readonly iamDatabase: string
38
+ readonly controlDatabase: string
39
+ readonly operationsDatabase: string
40
+ /** Control's run logs. */
41
+ readonly runLogs: PlatformStorage
42
+ /** Operations' artifacts and source maps. */
43
+ readonly operationsBlobs: PlatformStorage
44
+ }
45
+
46
+ /** Where the installation answers. Both the bring-up and every deploy derive their variables from these. */
47
+ export interface PlatformPlacementInput {
48
+ readonly environment: string
49
+ readonly scheme: 'http' | 'https'
50
+ readonly hosts: PlatformHosts
51
+ /** The private address the platform proxy dials IAM at, which owns the session (`host:port`). */
52
+ readonly iamUpstream: string
53
+ }
54
+
55
+ /** What `installationServiceEnvironment` needs. Everything in it is settled before the first write. */
56
+ export interface InstallationServiceInput extends PlatformPlacementInput {
57
+ readonly secrets: InstallationSecrets
58
+ readonly data: PlatformData
59
+ /** IAM sends its own mail with it, and Control hands it to applications whose job declares `mailer: 'installation'`. */
60
+ readonly email: InstallationEmail
61
+ }
62
+
63
+ /** Compared byte-for-byte against a token's `iss`, so it is the PUBLIC issuer, never the private upstream. */
64
+ const proxySessionOwner = (input: PlatformPlacementInput): string =>
65
+ JSON.stringify({ issuer: platformOrigin(input.scheme, input.hosts.iam), upstream: input.iamUpstream })
66
+
67
+ /**
68
+ * EVERY fabrika variable a bring-up places, per service.
69
+ *
70
+ * The proxy manifest is deliberately absent: the deploy owns it, in the same run as the code it
71
+ * describes (ADR-0027).
72
+ */
73
+ export const installationServiceEnvironment = (input: InstallationServiceInput): PlatformServiceEnvironment => {
74
+ const { secrets, data } = input
75
+ const iamOrigin = platformOrigin(input.scheme, input.hosts.iam)
76
+ const operationsOrigin = platformOrigin(input.scheme, input.hosts.operations)
77
+ const email = input.email.kind === 'none' ? [] : [...input.email.variables]
78
+ return {
79
+ iam: new Map([
80
+ ['FABRIKA_IAM_DATABASE_URL', data.iamDatabase],
81
+ ['ENVIRONMENT', input.environment],
82
+ ['ISSUER', iamOrigin],
83
+ // Password only, by decision: OIDC needs an identity provider the operator has not been
84
+ // asked for. Both are STATED because IAM's own defaults are the other way round — OIDC on,
85
+ // password off — and it refuses to boot with neither.
86
+ ['FABRIKA_IAM_OIDC_ENABLED', 'false'],
87
+ ['FABRIKA_IAM_PASSWORD_ENABLED', 'true'],
88
+ ['FABRIKA_IAM_SIGNING_KEYS', secrets.signingKeys],
89
+ ['FABRIKA_IAM_RPC_KEY', secrets.rpcKey],
90
+ ['FABRIKA_IAM_PROXY_KEY', secrets.proxyKey],
91
+ ['FABRIKA_IAM_PROVISIONING_KEY', secrets.provisioningKey],
92
+ ...email,
93
+ ]),
94
+ operations: new Map([
95
+ ['FABRIKA_OPERATIONS_DATABASE_URL', data.operationsDatabase],
96
+ ['FABRIKA_OPERATIONS_BLOB_BUCKET', data.operationsBlobs.bucket],
97
+ ['FABRIKA_OPERATIONS_BLOB_ACCESS_KEY_ID', data.operationsBlobs.accessKeyId],
98
+ ['FABRIKA_OPERATIONS_BLOB_SECRET_ACCESS_KEY', data.operationsBlobs.secretAccessKey],
99
+ ['FABRIKA_OPERATIONS_BLOB_ENDPOINT', data.operationsBlobs.endpoint],
100
+ ['FABRIKA_OPERATIONS_PUBLIC_HOST', input.hosts.operations],
101
+ ['ENVIRONMENT', input.environment],
102
+ ['FABRIKA_IAM_ISSUER', iamOrigin],
103
+ ['OPERATIONS_SYNC_KEY', secrets.operationsSyncKey],
104
+ ['FABRIKA_IAM_RPC_KEY', secrets.rpcKey],
105
+ ]),
106
+ control: new Map([
107
+ ['FABRIKA_CONTROL_DATABASE_URL', data.controlDatabase],
108
+ ['ENVIRONMENT', input.environment],
109
+ // A bare HOST, not an origin: `controlPublicOrigin` accepts either.
110
+ ['FABRIKA_CONTROL_DOMAIN', input.hosts.control],
111
+ ['FABRIKA_IAM_ISSUER', iamOrigin],
112
+ ['OPERATIONS_ARTIFACT_ORIGIN', operationsOrigin],
113
+ ['FABRIKA_CONTROL_RUN_LOGS_BUCKET', data.runLogs.bucket],
114
+ ['FABRIKA_CONTROL_RUN_LOGS_ACCESS_KEY_ID', data.runLogs.accessKeyId],
115
+ ['FABRIKA_CONTROL_RUN_LOGS_SECRET_ACCESS_KEY', data.runLogs.secretAccessKey],
116
+ ['FABRIKA_CONTROL_RUN_LOGS_ENDPOINT', data.runLogs.endpoint],
117
+ ['FABRIKA_IAM_RPC_KEY', secrets.rpcKey],
118
+ ['OPERATIONS_SYNC_KEY', secrets.operationsSyncKey],
119
+ ['FABRIKA_CONTROL_VAULT_KEY', secrets.vaultKey],
120
+ ['FABRIKA_IAM_PROVISIONING_KEY', secrets.provisioningKey],
121
+ ...email,
122
+ ]),
123
+ proxy: new Map([
124
+ ['ENVIRONMENT', input.environment],
125
+ ['FABRIKA_IAM_ISSUER', iamOrigin],
126
+ ['FABRIKA_IAM_KEY', secrets.proxyKey],
127
+ ['FABRIKA_PROXY_SESSION_OWNER_JSON', proxySessionOwner(input)],
128
+ ]),
129
+ }
130
+ }
131
+
132
+ /**
133
+ * The fabrika variables whose VALUE a bring-up generates, per service.
134
+ *
135
+ * Their presence on a live service is what "this project already holds an installation" means, and they
136
+ * are written as credentials. A provider adds the names of its own that carry a generated value.
137
+ */
138
+ export const INSTALLATION_SECRET_VARIABLES: Readonly<Record<PlatformService, readonly string[]>> = {
139
+ iam: ['FABRIKA_IAM_SIGNING_KEYS', 'FABRIKA_IAM_RPC_KEY', 'FABRIKA_IAM_PROXY_KEY', 'FABRIKA_IAM_PROVISIONING_KEY'],
140
+ operations: ['OPERATIONS_SYNC_KEY', 'FABRIKA_IAM_RPC_KEY'],
141
+ control: ['FABRIKA_IAM_RPC_KEY', 'OPERATIONS_SYNC_KEY', 'FABRIKA_CONTROL_VAULT_KEY', 'FABRIKA_IAM_PROVISIONING_KEY'],
142
+ proxy: ['FABRIKA_IAM_KEY'],
143
+ }
144
+
145
+ /**
146
+ * The secret the platform proxy sends IAM's route, so IAM honours the client address and request id only from the proxy
147
+ * (ADR-0022 rule 5). Derived from the provisioning key, which every deploy holds, so each deploy writes the same value
148
+ * and nothing stores or reads one back. Never from the proxy key: namespace proxies hold that one.
149
+ */
150
+ export const deriveIamProxySecret = (provisioningKey: string): string =>
151
+ createHmac('sha256', provisioningKey).update('fabrika-installation-proxy-secret:iam').digest('hex')
152
+
153
+ /** What a deploy derives the service environment from. */
154
+ export interface DeployedServiceInput extends PlatformPlacementInput {
155
+ /** `FABRIKA_IAM_PROVISIONING_KEY`, which the IAM proxy secret is derived from. */
156
+ readonly provisioningKey: string
157
+ /** The digest of the console table the deploy's manifest publishes (`platformServiceTableDigest`). */
158
+ readonly controlTableDigest: string
159
+ /** Present only when the installation enables MCP authoring. */
160
+ readonly oauthConfig?: IamOAuthConfig
161
+ }
162
+
163
+ /**
164
+ * The per-installation configuration a deploy writes, derived from where the installation answers.
165
+ *
166
+ * IAM's OAuth configuration is present only when MCP authoring is enabled; without it the variable is left
167
+ * as it is, and the manifest — whose `/mcp` row then admits no OAuth token — is what turns MCP off.
168
+ */
169
+ export const deployedServiceEnvironment = (input: DeployedServiceInput): PlatformServiceEnvironment => {
170
+ const iamOrigin = platformOrigin(input.scheme, input.hosts.iam)
171
+ const iamProxySecret = deriveIamProxySecret(input.provisioningKey)
172
+ const iam = new Map([
173
+ ['ENVIRONMENT', input.environment],
174
+ // The `iss` of every minted token AND the OIDC redirect base, so it must be the address
175
+ // routed to the proxy in front of this service.
176
+ ['ISSUER', iamOrigin],
177
+ [IAM_PROXY_SECRET_VARIABLE, iamProxySecret],
178
+ ])
179
+ if (input.oauthConfig !== undefined) {
180
+ iam.set(IAM_OAUTH_CONFIG_VARIABLE, JSON.stringify(input.oauthConfig))
181
+ }
182
+ return {
183
+ iam,
184
+ operations: new Map([
185
+ ['ENVIRONMENT', input.environment],
186
+ ['FABRIKA_IAM_ISSUER', iamOrigin],
187
+ ['FABRIKA_OPERATIONS_PUBLIC_HOST', input.hosts.operations],
188
+ ]),
189
+ proxy: new Map([
190
+ ['ENVIRONMENT', input.environment],
191
+ ['FABRIKA_IAM_ISSUER', iamOrigin],
192
+ ['FABRIKA_PROXY_SESSION_OWNER_JSON', proxySessionOwner(input)],
193
+ // Caddy sends it on the session owner's route.
194
+ [IAM_PROXY_SECRET_VARIABLE, iamProxySecret],
195
+ ]),
196
+ control: new Map([
197
+ ['ENVIRONMENT', input.environment],
198
+ ['FABRIKA_IAM_ISSUER', iamOrigin],
199
+ // A bare host: `controlPublicOrigin` accepts either spelling.
200
+ ['FABRIKA_CONTROL_DOMAIN', input.hosts.control],
201
+ // Control picks its own table, MCP or not, by this digest, and refuses to start under another.
202
+ [FABRIKA_SERVICE_TABLE_DIGEST, input.controlTableDigest],
203
+ ['OPERATIONS_ARTIFACT_ORIGIN', platformOrigin(input.scheme, input.hosts.operations)],
204
+ ]),
205
+ }
206
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * The first administrator of a fresh installation — the admin-RPC calls that admit them, as mechanics
3
+ * rather than a script.
4
+ *
5
+ * `platform install` ends with a provisioning key and nobody who can sign in. What it takes to fix
6
+ * that is provider-neutral: which procedures, in which order, and what makes a second run change
7
+ * nothing. WHERE an installation's IAM answers, and how an operator names it, is the provider's own
8
+ * question and stays in its installation package.
9
+ *
10
+ * A trap a live bring-up found is encoded here rather than left to each caller: **the grant is
11
+ * CROSS-APP (`app: null`).** Grants filter to the calling app, so an `admin` grant scoped to the
12
+ * console's own app id leaves Delivery and Operations working while the Access plane refuses. IAM's
13
+ * own app id is not a registered app, so naming it would dangle instead.
14
+ *
15
+ * The enrollment URL is a CREDENTIAL IN TRANSIT. It is RETURNED, never logged: nothing in this module
16
+ * prints, and `log.ts` deliberately has no helper that takes a secret value.
17
+ */
18
+ import { type InferRpcClient, type RpcFetch } from '@fabrika/app';
19
+ import type { IamAdminRpcContract } from '@fabrika/iam-contract';
20
+ /** The built-in cross-app role. It is the one role that carries `iam.admin`, which the Access plane checks. */
21
+ export declare const ADMIN_ROLE_KEY = "admin";
22
+ /** The typed admin surface, exactly as the console reaches it — one contract, so the two cannot drift. */
23
+ export type IamAdminClient = InferRpcClient<IamAdminRpcContract>;
24
+ /**
25
+ * The slice of that surface this flow calls, and nothing wider.
26
+ *
27
+ * `IamAdminClient` satisfies it, so the real client is passed straight in; a caller testing the flow
28
+ * stands up five procedures instead of the whole administration API.
29
+ */
30
+ export interface FirstAdministratorApi {
31
+ readonly principals: Pick<IamAdminClient['principals'], 'get' | 'invite' | 'list'>;
32
+ readonly grants: Pick<IamAdminClient['grants'], 'create'>;
33
+ readonly passwords: Pick<IamAdminClient['passwords'], 'issueEnrollment'>;
34
+ }
35
+ export interface IamAdminConnection {
36
+ /** IAM's PUBLIC origin — the address the proxy fronts, e.g. `https://iam.example.com`. */
37
+ readonly origin: string;
38
+ /** The installation's `px_` provisioning key, sent as `Authorization: Bearer`. */
39
+ readonly provisioningKey: string;
40
+ /** Override `fetch` (tests). Defaults to the global one. */
41
+ readonly fetch?: RpcFetch;
42
+ }
43
+ /**
44
+ * A client for `<origin>/admin/rpc` authenticated by the provisioning key.
45
+ *
46
+ * Bearer AND NOTHING ELSE, which is what makes it work at all: IAM's cross-origin guard exempts a
47
+ * request carrying only `Authorization`, and refuses a credential-less one whatever the admin-origin
48
+ * registry holds (`iam/src/admin/router.ts`).
49
+ */
50
+ export declare const createIamAdminClient: ({ origin, provisioningKey, fetch: fetcher }: IamAdminConnection) => IamAdminClient;
51
+ export interface FirstAdministratorRequest {
52
+ /** The mailbox that identifies the human. Any spelling; IAM stores the normalized form. */
53
+ readonly email: string;
54
+ /**
55
+ * Issue a second enrollment URL when one is already outstanding. Default false, so a re-run prints
56
+ * nothing new — but an enrollment that expired unopened would otherwise be unrecoverable, because
57
+ * the only other way to issue one is a console nobody can sign in to yet.
58
+ */
59
+ readonly reissueEnrollment?: boolean;
60
+ }
61
+ /** What happened to the password enrollment. Only `issued` carries a URL, and only once. */
62
+ export type EnrollmentOutcome = {
63
+ readonly state: 'issued';
64
+ readonly url: string;
65
+ readonly expiresAt: number;
66
+ } | {
67
+ readonly state: 'emailed';
68
+ readonly email: string;
69
+ readonly expiresAt: number;
70
+ } | {
71
+ readonly state: 'outstanding';
72
+ } | {
73
+ readonly state: 'already-set';
74
+ };
75
+ export interface FirstAdministratorResult {
76
+ readonly principalId: string;
77
+ /** The normalized mailbox, as IAM holds it. */
78
+ readonly email: string;
79
+ readonly principal: 'invited' | 'existing';
80
+ readonly grant: 'created' | 'present';
81
+ readonly enrollment: EnrollmentOutcome;
82
+ }
83
+ /**
84
+ * Admit the first administrator, idempotently.
85
+ *
86
+ * Four states are reused rather than rewritten: an existing principal for this mailbox, an existing
87
+ * cross-app `admin` grant, an outstanding enrollment, and a password that is already set. A re-run
88
+ * therefore invites nobody twice, grants nothing twice and issues no second URL — and a run that loses
89
+ * the invite race to a concurrent one converges on the principal that won.
90
+ */
91
+ export declare const ensureFirstAdministrator: (client: FirstAdministratorApi, request: FirstAdministratorRequest) => Promise<FirstAdministratorResult>;
@@ -0,0 +1,9 @@
1
+ /** `--checkout=<dir>`: where `platform init` keeps the sidecar repository's local checkout. */
2
+ export declare const CHECKOUT_FLAG = "--checkout";
3
+ /**
4
+ * The sidecar checkout directory: `--checkout`, or `defaultName` in the working directory.
5
+ *
6
+ * Refused inside another repository, where it would sit as an untracked nested repository that a
7
+ * blanket `git add` records as a gitlink.
8
+ */
9
+ export declare const sidecarCheckoutDir: (explicit: string | undefined, defaultName: string, cwd?: string) => string;
@@ -0,0 +1,21 @@
1
+ /** The one question `install` and `init` ask. Injected so a flow can be exercised without a TTY. */
2
+ export interface Confirmations {
3
+ confirm(question: string, defaultYes?: boolean): Promise<boolean>;
4
+ }
5
+ /**
6
+ * The one way a confirming command runs with nobody watching it.
7
+ *
8
+ * Every confirmation defaults to yes, so a pipe carrying blank lines would answer all of them with
9
+ * nobody reading what they agree to — which is why saying yes unattended is a FLAG and never an
10
+ * inference: it makes the choice explicit, and it puts it in the shell history of whoever ran it.
11
+ */
12
+ export declare const UNATTENDED_FLAG = "--yes";
13
+ /**
14
+ * How a command's confirmations are answered — and the refusal when nothing can answer them.
15
+ *
16
+ * Unattended, each question is still printed with the answer it was given, so the run leaves the same
17
+ * record of what was agreed to that an interactive one does.
18
+ */
19
+ export declare const confirmations: (unattended: boolean, stdin: {
20
+ readonly isTTY?: boolean;
21
+ }, announce: (line: string) => void, whatItDoes: string) => Confirmations;
@@ -0,0 +1,15 @@
1
+ import { type EmailProviderRegistry } from '@fabrika/email';
2
+ import { type CommandEnv } from './options';
3
+ export type InstallationEmail = {
4
+ readonly kind: 'none';
5
+ } | {
6
+ readonly kind: 'provider';
7
+ readonly variables: ReadonlyMap<string, string>;
8
+ readonly secretNames: ReadonlySet<string>;
9
+ };
10
+ export declare const EMAIL_FLAGS: readonly ["--email-provider", "--email-from", "--email-from-name"];
11
+ /** Throws naming flags and variables, never a value. */
12
+ export declare function readInstallationEmail(argv: readonly string[], env: CommandEnv, options: {
13
+ readonly registry: EmailProviderRegistry;
14
+ readonly required: boolean;
15
+ }): InstallationEmail;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Write the collected configuration into the base repo's GitHub **Environment** (not repo-level), so the
3
+ * platform pipeline reads it per-environment. Secret VALUES go to `gh secret set --env` over STDIN (gh does
4
+ * the libsodium encryption; the value never hits argv or a log line); non-secret values go to
5
+ * `gh variable set --env --body`. The environment itself is created (idempotent PUT) first, carrying the
6
+ * deployment-branch policy that is the only thing an Environment buys over a repository secret — a policy
7
+ * that restricts nothing until `main` itself carries a branch-protection rule.
8
+ */
9
+ import { type GhAttempt, type ProtectionOutcome } from './protection';
10
+ import { type ShellAttempt, type ShellStep } from './shell';
11
+ export interface EnvironmentConfig {
12
+ repo: string;
13
+ environment: string;
14
+ /** Secret name → value (written via `gh secret set`). */
15
+ secrets: Record<string, string>;
16
+ /** Variable name → value (written via `gh variable set`). */
17
+ vars: Record<string, string>;
18
+ /**
19
+ * Told once whether only a protected branch may use this Environment's secrets, so a provider's
20
+ * closing summary can restate it. A callback rather than a return value: both providers already
21
+ * bind this function through a `Promise<void>` collaborator.
22
+ */
23
+ onProtection?(outcome: ProtectionOutcome): void;
24
+ }
25
+ /** The child processes `configureEnvironment` drives. Replaced wholesale in tests. */
26
+ export interface EnvironmentEffects {
27
+ attempt: GhAttempt;
28
+ run(step: ShellStep): Promise<void>;
29
+ }
30
+ /** Create the GitHub Environment and populate its secrets + variables. */
31
+ export declare function configureEnvironment(config: EnvironmentConfig, effects?: EnvironmentEffects): Promise<void>;
32
+ /** One reviewer in a PUT body: GitHub's GET nests the id under `reviewer`, its PUT wants it flat. */
33
+ interface EnvironmentReviewer {
34
+ readonly type: string;
35
+ readonly id: number;
36
+ }
37
+ /** The protection rules a PUT must carry forward, read off `gh api repos/…/environments/<name>`. */
38
+ export interface ExistingEnvironmentProtection {
39
+ readonly reviewers?: readonly EnvironmentReviewer[];
40
+ readonly wait_timer?: number;
41
+ }
42
+ /**
43
+ * Translate a GET's `protection_rules` into the shape a PUT takes. Anything unrecognised is dropped:
44
+ * a body GitHub rejects would cost the deployment-branch policy, which is what this write is for.
45
+ */
46
+ export declare function existingEnvironmentProtection(result: ShellAttempt): ExistingEnvironmentProtection;
47
+ /** Trigger the platform workflow (the real deploy runs in GitHub Actions). */
48
+ export declare function triggerPlatformWorkflow(repo: string): Promise<void>;
49
+ export {};
@@ -0,0 +1,9 @@
1
+ /**
2
+ * `gh` CLI helpers. The CLI shells out to `gh` for everything GitHub-side (repo create, secrets, the
3
+ * deploy trigger) rather than re-implementing the REST API + libsodium secret encryption — `gh` already
4
+ * does the encryption for `secret set`. These are the small probes shared across scaffold + environment.
5
+ */
6
+ /** Is the `gh` CLI available + authenticated? `gh auth status` exits non-zero when not logged in. */
7
+ export declare function hasGhCli(): Promise<boolean>;
8
+ /** Does `<org>/<repo>` exist and is it visible to the current `gh` login? */
9
+ export declare function ghRepoExists(repo: string): Promise<boolean>;
@@ -0,0 +1,18 @@
1
+ export { ADMIN_ROLE_KEY, createIamAdminClient, type EnrollmentOutcome, ensureFirstAdministrator, type FirstAdministratorApi, type FirstAdministratorRequest, type FirstAdministratorResult, type IamAdminClient, type IamAdminConnection, } from './admin';
2
+ export { CHECKOUT_FLAG, sidecarCheckoutDir } from './checkout';
3
+ export { type Confirmations, confirmations, UNATTENDED_FLAG } from './confirmations';
4
+ export { EMAIL_FLAGS, type InstallationEmail, readInstallationEmail } from './email';
5
+ export { configureEnvironment, type EnvironmentConfig, type EnvironmentEffects, type ExistingEnvironmentProtection, existingEnvironmentProtection, triggerPlatformWorkflow, } from './environment';
6
+ export { ghRepoExists, hasGhCli } from './gh';
7
+ export { action, detail, fail, info, ok, step, url, warn } from './log';
8
+ export { type IamOAuthConfig, type McpClient, parseMcpClientsJson, platformIamOAuthConfig } from './mcp';
9
+ export { type CommandEnv, describeArgument, readEnv, readFlag, requiredSetting, setting, shellWord } from './options';
10
+ export { assertPinnedTag } from './pin';
11
+ export { confirm } from './prompt';
12
+ export { branchProtectionCommand, DEPLOYMENT_BRANCH_POLICY, environmentPolicyCommand, environmentReviewerCommand, type GhAttempt, isPlanLimited, PLAN_LIMITED, probeSidecarBranchProtection, type ProtectionOutcome, protectSidecarBranch, } from './protection';
13
+ export { type PlatformHosts, type PlatformOAuthResourceTemplate, type PlatformOAuthScopeGrant, platformOrigin, type PlatformProxyApp, platformProxyAppFor, type PlatformProxyComposition, type PlatformProxyManifest, type PlatformProxyManifestTemplate, type PlatformProxyPlacement, type PlatformProxyService, platformServiceTableDigest, resolvePlatformProxyManifest, } from './proxy-manifest';
14
+ export { INITIAL_SCAFFOLD_COMMIT_MESSAGE, REFRESH_SCAFFOLD_COMMIT_MESSAGE, scaffoldSidecarRepository, type SidecarScaffoldEffects, type SidecarScaffoldInput, type SidecarScaffoldResult, } from './scaffold';
15
+ export { generateInstallationSecrets, generateSigningKeys, generateVaultKey, type InstallationSecrets, randomPxKey, randomSecret } from './secrets';
16
+ export { checkedSecretsFilePath, renderSecretsFile, SECRETS_FILE_FLAG, SECRETS_FILE_VARIABLE, writeSecretsFile } from './secrets-file';
17
+ export { deployedServiceEnvironment, type DeployedServiceInput, deriveIamProxySecret, IAM_OAUTH_CONFIG_VARIABLE, INSTALLATION_SECRET_VARIABLES, installationServiceEnvironment, type InstallationServiceInput, type PlatformData, type PlatformPlacementInput, type PlatformService, type PlatformServiceEnvironment, type PlatformStorage, } from './service-environment';
18
+ export { attempt, capture, probe, run, type ShellAttempt, type ShellStep } from './shell';
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Console formatting for the fabrika CLI — a thin, consistent presentation layer over the orchestration
3
+ * in the other modules. Every line the operator sees during `init` flows through here.
4
+ *
5
+ * HARD RULE (see the worker CLAUDE.md): this module NEVER prints a secret VALUE. Callers may pass
6
+ * secret NAMES, counts, or "✓ set" confirmations — never the bytes of a token / key / PEM / webhook
7
+ * secret. There is deliberately no helper here that takes a secret value, so a careless caller can't
8
+ * accidentally route one through formatting.
9
+ */
10
+ /** A numbered, bold section heading — the spine of the CLI's progress. Resets nothing else. */
11
+ export declare function step(title: string): void;
12
+ /** A plain informational line, slightly indented under the current step. */
13
+ export declare function info(message: string): void;
14
+ /** A dim, secondary detail line (context the operator usually skims). */
15
+ export declare function detail(message: string): void;
16
+ /** A success line (green ✓). */
17
+ export declare function ok(message: string): void;
18
+ /** A non-fatal warning (yellow ⚠) — the CLI keeps going. */
19
+ export declare function warn(message: string): void;
20
+ /** A failure line (red ✗). Does NOT exit — the caller decides whether to throw. */
21
+ export declare function fail(message: string): void;
22
+ /**
23
+ * A boxed OPERATOR ACTION — something the human must do out-of-band (open a URL, log in, click
24
+ * "create", paste a value back). These are the hand-off points the whole bring-up is built around, so
25
+ * they are visually loud and never scroll past unnoticed.
26
+ */
27
+ export declare function action(title: string, lines: string[]): void;
28
+ /** Render a URL prominently (cyan, underlined) — operators copy these by eye. */
29
+ export declare function url(value: string): string;
@@ -0,0 +1,18 @@
1
+ import { type OAuthManagedClient, type OAuthResourceDto } from '@fabrika/iam-contract';
2
+ import { type PlatformProxyComposition, type PlatformProxyManifestTemplate } from './proxy-manifest';
3
+ /** One operator-registered MCP client: its CIMD identity, plus where IAM fetches it when that differs. */
4
+ export interface McpClient extends OAuthManagedClient {
5
+ readonly metadataUrl?: string;
6
+ }
7
+ /** The document IAM parses from `FABRIKA_IAM_OAUTH_CONFIG`. */
8
+ export interface IamOAuthConfig {
9
+ readonly clients: readonly McpClient[];
10
+ readonly resources: readonly OAuthResourceDto[];
11
+ }
12
+ /**
13
+ * Parse the operator's MCP client list — a JSON array. Anything malformed, an empty list or a repeated
14
+ * client id is refused here, before a deploy writes a value IAM would refuse to boot with.
15
+ */
16
+ export declare const parseMcpClientsJson: (raw: string) => readonly McpClient[];
17
+ /** IAM's OAuth configuration for one placed installation: the clients, and one resource per app that serves one. */
18
+ export declare const platformIamOAuthConfig: (template: PlatformProxyManifestTemplate, composition: PlatformProxyComposition, clients: readonly McpClient[]) => IamOAuthConfig;
@@ -0,0 +1,9 @@
1
+ export type CommandEnv = Record<string, string | undefined>;
2
+ export declare const readFlag: (argv: readonly string[], name: string) => string | undefined;
3
+ export declare const readEnv: (env: CommandEnv, name: string) => string | undefined;
4
+ export declare const setting: (argv: readonly string[], env: CommandEnv, flag: string, variable: string) => string | undefined;
5
+ export declare const requiredSetting: (value: string | undefined, flag: string, variable: string) => string;
6
+ /** How a refusal names an argument: a flag by its name only, never its value, which may be a pasted credential. */
7
+ export declare const describeArgument: (arg: string) => string;
8
+ /** One argument as a POSIX shell reads it back unchanged, for the rerun command a declined step prints. */
9
+ export declare const shellWord: (arg: string) => string;
@@ -0,0 +1,2 @@
1
+ /** The pinned ref, or an error naming what was found. A branch is refused, deliberately and loudly. */
2
+ export declare const assertPinnedTag: (ref: string) => string;
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The one interactive question the fabrika CLI asks: a yes/no confirmation before a step that leaves
3
+ * the operator's machine. Every other input is a flag or an environment variable, and a run with no
4
+ * terminal answers confirmations with `--yes` (`confirmations.ts`), so this reads a TTY only.
5
+ *
6
+ * A fresh interface per question, closed afterwards: a persistent one holds the terminal in raw mode
7
+ * (`-icanon -echo`, measured) between questions, and init spawns `git` and `gh` between them — a child
8
+ * inheriting a raw terminal echoes nothing and stair-steps its output.
9
+ */
10
+ /** A yes/no confirmation. Defaults to `defaultYes` on an empty answer. */
11
+ export declare function confirm(question: string, defaultYes?: boolean): Promise<boolean>;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Harden the sidecar repository the operator owns. It holds the installation's entire credential set,
3
+ * so a push to `main` must not be able to rewrite the pipeline that reads them. ADR-0025 leaves the
4
+ * repository operator-owned; this only stops it from being weaker than the account it controls.
5
+ *
6
+ * GitHub Free offers neither rule on a PRIVATE repository, so a refusal is REPORTED, never fatal: the
7
+ * operator is told what is unprotected and the exact command that applies it later. Every body travels
8
+ * over stdin (`gh api --input -`), never on argv.
9
+ *
10
+ * An Environment's `protected_branches` policy is only as strong as the branch rule it points at: with no
11
+ * protection rule on `main`, the policy admits `main` and restricts nothing. The two are reported together
12
+ * for that reason.
13
+ */
14
+ import { type ShellAttempt, type ShellStep } from './shell';
15
+ /** No force pushes, no deletions, admins included. No required review: `platform upgrade` pushes to `main`. */
16
+ export declare const BRANCH_PROTECTION_BODY: string;
17
+ /** Only a protected branch may use the Environment's secrets — and only where such a rule exists. */
18
+ export declare const DEPLOYMENT_BRANCH_POLICY: {
19
+ readonly protected_branches: true;
20
+ readonly custom_branch_policies: false;
21
+ };
22
+ /** The same policy as a standalone PUT body, for the command an operator runs by hand. */
23
+ export declare const DEPLOYMENT_BRANCH_POLICY_BODY: string;
24
+ /** Whether one hardening rule is in place, plus the command that applies it when it is not. */
25
+ export interface ProtectionOutcome {
26
+ readonly applied: boolean;
27
+ /** One short, printable line saying why it is not applied. Absent when it is. */
28
+ readonly reason?: string;
29
+ /** The `gh api` invocation that applies this rule later. */
30
+ readonly command: string;
31
+ }
32
+ /** Run a `gh` step whose failure is an ANSWER rather than an error. The seam tests drive. */
33
+ export type GhAttempt = (step: ShellStep) => Promise<ShellAttempt>;
34
+ export declare const branchProtectionCommand: (repo: string) => string;
35
+ export declare const environmentPolicyCommand: (repo: string, environment: string) => string;
36
+ /** Recommended, never applied by us: an approval click per deploy is the operator's call. */
37
+ export declare const environmentReviewerCommand: (repo: string, environment: string) => string;
38
+ /** The HTTP status `gh` names in its error line (`gh: … (HTTP 403)`), when it names one. */
39
+ export declare const ghHttpStatus: (result: ShellAttempt) => number | undefined;
40
+ /**
41
+ * The three statuses GitHub answers when a plan does not offer a protection rule. A 422 can also mean a
42
+ * malformed request, so this is a heuristic — it decides only whether we DEGRADE or give up, never
43
+ * whether something is safe.
44
+ */
45
+ export declare const PLAN_LIMITED: ReadonlySet<number>;
46
+ /** True when GitHub's answer is one this code is willing to continue without the rule on. */
47
+ export declare const isPlanLimited: (result: ShellAttempt) => boolean;
48
+ /** One printable line for a refusal — the plan limit when GitHub named one, the exit code otherwise. */
49
+ export declare const unavailableReason: (result: ShellAttempt) => string;
50
+ /** Protect `main` on the sidecar. Reports what is unprotected and never throws. */
51
+ export declare function protectSidecarBranch(repo: string, gh?: GhAttempt): Promise<ProtectionOutcome>;
52
+ /**
53
+ * Read-only: does `main` already carry a protection rule? For a repository this run did NOT create — a
54
+ * PUT there would replace whatever the operator configured, so the existing rules are reported, never
55
+ * overwritten. A 404 is GitHub's answer for "no protection rule", which is exactly what we warn about.
56
+ */
57
+ export declare function probeSidecarBranchProtection(repo: string, gh?: GhAttempt): Promise<ProtectionOutcome>;