@fabrika/installation-init 0.9.0-dev.1 → 0.9.0-dev.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 +7 -6
- package/types/src/admin.d.ts +91 -0
- package/types/src/checkout.d.ts +9 -0
- package/types/src/confirmations.d.ts +21 -0
- package/types/src/email.d.ts +15 -0
- package/types/src/environment.d.ts +49 -0
- package/types/src/gh.d.ts +9 -0
- package/types/src/index.d.ts +12 -0
- package/types/src/log.d.ts +29 -0
- package/types/src/options.d.ts +9 -0
- package/types/src/prompt.d.ts +11 -0
- package/types/src/protection.d.ts +57 -0
- package/types/src/scaffold.d.ts +49 -0
- package/types/src/shell.d.ts +50 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fabrika/installation-init",
|
|
3
|
-
"version": "0.9.0-dev.
|
|
3
|
+
"version": "0.9.0-dev.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -12,12 +12,13 @@
|
|
|
12
12
|
},
|
|
13
13
|
"files": [
|
|
14
14
|
"src",
|
|
15
|
-
"!src/**/__tests__/**"
|
|
15
|
+
"!src/**/__tests__/**",
|
|
16
|
+
"types"
|
|
16
17
|
],
|
|
17
18
|
"exports": {
|
|
18
19
|
".": {
|
|
19
20
|
"bun": "./src/index.ts",
|
|
20
|
-
"types": "./src/index.ts",
|
|
21
|
+
"types": "./types/src/index.d.ts",
|
|
21
22
|
"default": "./src/index.ts"
|
|
22
23
|
}
|
|
23
24
|
},
|
|
@@ -27,8 +28,8 @@
|
|
|
27
28
|
},
|
|
28
29
|
"dependencies": {
|
|
29
30
|
"zod": "^3.25.76",
|
|
30
|
-
"@fabrika/app": "0.9.0-dev.
|
|
31
|
-
"@fabrika/email": "0.9.0-dev.
|
|
32
|
-
"@fabrika/iam-contract": "0.9.0-dev.
|
|
31
|
+
"@fabrika/app": "0.9.0-dev.2",
|
|
32
|
+
"@fabrika/email": "0.9.0-dev.2",
|
|
33
|
+
"@fabrika/iam-contract": "0.9.0-dev.2"
|
|
33
34
|
}
|
|
34
35
|
}
|
|
@@ -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,12 @@
|
|
|
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 CommandEnv, describeArgument, readEnv, readFlag, requiredSetting, setting, shellWord } from './options';
|
|
9
|
+
export { confirm } from './prompt';
|
|
10
|
+
export { branchProtectionCommand, DEPLOYMENT_BRANCH_POLICY, environmentPolicyCommand, environmentReviewerCommand, type GhAttempt, isPlanLimited, PLAN_LIMITED, probeSidecarBranchProtection, type ProtectionOutcome, protectSidecarBranch, } from './protection';
|
|
11
|
+
export { INITIAL_SCAFFOLD_COMMIT_MESSAGE, REFRESH_SCAFFOLD_COMMIT_MESSAGE, scaffoldSidecarRepository, type SidecarScaffoldEffects, type SidecarScaffoldInput, type SidecarScaffoldResult, } from './scaffold';
|
|
12
|
+
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,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,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>;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The mechanics of a sidecar repository: ensure `<owner>/<name>` exists on GitHub, materialize the
|
|
3
|
+
* files the CLI owns, commit and push. Idempotent, and the same on every provider:
|
|
4
|
+
* - repo absent → create a fresh local checkout, commit the scaffold, `gh repo create … --source --push`,
|
|
5
|
+
* - repo present → clone it (if not already local), refresh the CLI-owned files, commit + push on drift.
|
|
6
|
+
*
|
|
7
|
+
* WHAT the files are is the provider's answer, not this module's — `materialize` writes them and `files`
|
|
8
|
+
* lists everything the scaffold ever stages. Scaffold commits carry `[skip ci]`, so a push here cannot
|
|
9
|
+
* deploy anything before `init` has written the GitHub Environment the pipeline reads.
|
|
10
|
+
*
|
|
11
|
+
* A freshly created repository is protected before anything else is written to it. An EXISTING one is
|
|
12
|
+
* only PROBED, never PUT to: its protection is whatever its operator chose, and a blind PUT would replace
|
|
13
|
+
* stricter rules with ours. Either way the outcome is reported, so an unprotected sidecar is never silent.
|
|
14
|
+
*/
|
|
15
|
+
import { type ProtectionOutcome } from './protection';
|
|
16
|
+
import { type ShellStep } from './shell';
|
|
17
|
+
export declare const INITIAL_SCAFFOLD_COMMIT_MESSAGE = "chore: initial fabrika platform scaffold [skip ci]";
|
|
18
|
+
export declare const REFRESH_SCAFFOLD_COMMIT_MESSAGE = "chore: refresh fabrika platform scaffold [skip ci]";
|
|
19
|
+
export interface SidecarScaffoldInput {
|
|
20
|
+
/** The sidecar repository, e.g. `contember/fabrika-zerops-test`. */
|
|
21
|
+
readonly repo: string;
|
|
22
|
+
/** Local directory to check the repository out into. */
|
|
23
|
+
readonly dir: string;
|
|
24
|
+
/** Every path the scaffold stages — CLI-owned files plus any operator-owned, write-once pin. */
|
|
25
|
+
readonly files: readonly string[];
|
|
26
|
+
/** Write the scaffold into an existing directory. Called for a fresh creation and for a refresh alike. */
|
|
27
|
+
materialize(dir: string): Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
/** Result of scaffolding: the local checkout dir + whether the repository was freshly created. */
|
|
30
|
+
export interface SidecarScaffoldResult {
|
|
31
|
+
readonly dir: string;
|
|
32
|
+
readonly created: boolean;
|
|
33
|
+
/** Applied for a repository this run created, probed read-only for one that already existed. */
|
|
34
|
+
readonly branchProtection?: ProtectionOutcome;
|
|
35
|
+
}
|
|
36
|
+
/** The child processes and GitHub calls the scaffold drives. Replaced wholesale in tests. */
|
|
37
|
+
export interface SidecarScaffoldEffects {
|
|
38
|
+
hasGhCli(): Promise<boolean>;
|
|
39
|
+
repositoryExists(repo: string): Promise<boolean>;
|
|
40
|
+
run(step: ShellStep): Promise<void>;
|
|
41
|
+
probe(step: ShellStep): Promise<number>;
|
|
42
|
+
protect(repo: string): Promise<ProtectionOutcome>;
|
|
43
|
+
probeProtection(repo: string): Promise<ProtectionOutcome>;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Ensure the sidecar repository exists and carries the current scaffold. Returns the local checkout.
|
|
47
|
+
* Requires `gh` authenticated with rights to create/administer the repository.
|
|
48
|
+
*/
|
|
49
|
+
export declare function scaffoldSidecarRepository(input: SidecarScaffoldInput, effects?: SidecarScaffoldEffects): Promise<SidecarScaffoldResult>;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shell-out helpers for the CLI, over `Bun.spawn`. Every orchestrated step (git, gh, the deploy
|
|
3
|
+
* trigger) goes through here so the rules are enforced in ONE place:
|
|
4
|
+
* - argv is passed verbatim (no shell) — nothing interpolated can be a metacharacter,
|
|
5
|
+
* - the child gets ONLY `process.env` + the explicit `env` the step needs,
|
|
6
|
+
* - on a non-zero exit we throw a SHORT message naming the command — never the child env, never a
|
|
7
|
+
* secret value, never the raw error object (which could carry a token-bearing URL).
|
|
8
|
+
*/
|
|
9
|
+
/** A child step to run: the command + args, its cwd, and the extra env it needs (merged over process.env). */
|
|
10
|
+
export interface ShellStep {
|
|
11
|
+
command: string;
|
|
12
|
+
args: string[];
|
|
13
|
+
cwd: string;
|
|
14
|
+
/** Extra env vars for this child only. Secret VALUES live here and nowhere else. */
|
|
15
|
+
env?: Record<string, string>;
|
|
16
|
+
/** Optional stdin written to the child (used for `gh secret set`, which reads the value from stdin). */
|
|
17
|
+
stdin?: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* What a streaming child gets on stdin.
|
|
21
|
+
*
|
|
22
|
+
* A TTY child may INHERIT it — `git` and `gh` prompt on their own, and a terminal is shared by turns.
|
|
23
|
+
* Without a terminal nobody can answer a child's question, so it gets none and fails instead of waiting.
|
|
24
|
+
*/
|
|
25
|
+
export declare const childStdin: (step: ShellStep, isTty?: boolean) => Uint8Array | "inherit" | "ignore";
|
|
26
|
+
/**
|
|
27
|
+
* Run a step, STREAMING its stdout/stderr to this terminal so the operator watches the underlying
|
|
28
|
+
* tool live (git/gh print progress). Throws on a non-zero exit. The thrown message names the command
|
|
29
|
+
* + exit code only.
|
|
30
|
+
*/
|
|
31
|
+
export declare function run(step: ShellStep): Promise<void>;
|
|
32
|
+
/**
|
|
33
|
+
* Run a step and CAPTURE its stdout (returned), while streaming stderr to this terminal. Used when we
|
|
34
|
+
* need to parse a tool's output (e.g. `gh repo view`). Throws on a non-zero exit; the captured stdout
|
|
35
|
+
* is NOT echoed by this helper.
|
|
36
|
+
*/
|
|
37
|
+
export declare function capture(step: ShellStep): Promise<string>;
|
|
38
|
+
/** Run a step quietly: no stdout/stderr to the terminal, returns the exit code. For existence probes. */
|
|
39
|
+
export declare function probe(step: ShellStep): Promise<number>;
|
|
40
|
+
/** What a step that is allowed to fail reported: its exit code plus everything it wrote. */
|
|
41
|
+
export interface ShellAttempt {
|
|
42
|
+
readonly exitCode: number;
|
|
43
|
+
readonly stdout: string;
|
|
44
|
+
readonly stderr: string;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Run a step WITHOUT throwing, capturing both pipes. For a call whose failure is an ANSWER rather than
|
|
48
|
+
* an error — a `gh api` PUT that GitHub refuses because the plan does not offer the feature.
|
|
49
|
+
*/
|
|
50
|
+
export declare function attempt(step: ShellStep): Promise<ShellAttempt>;
|