@magoz/provision 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +498 -2
- package/dist/cli.js +29 -0
- package/dist/contract.js +13 -0
- package/dist/db/branch-lifecycle.js +23 -0
- package/dist/db/commands.js +303 -0
- package/dist/db/config.js +75 -0
- package/dist/db/credentials.js +177 -0
- package/dist/db/domain.js +106 -0
- package/dist/db/environment.js +14 -0
- package/dist/db/lease.js +132 -0
- package/dist/db/neon.js +350 -0
- package/dist/db/operations.js +246 -0
- package/dist/db/policy.js +59 -0
- package/dist/env/commands.js +39 -0
- package/dist/env/domain.js +14 -0
- package/dist/env/install.js +25 -0
- package/dist/env/paths.js +105 -0
- package/dist/env/provision.js +320 -0
- package/dist/env/sanitize.js +88 -0
- package/dist/env/vercel.js +180 -0
- package/dist/factory/approval.js +33 -0
- package/dist/factory/commands.js +173 -0
- package/dist/factory/config-commands.js +215 -0
- package/dist/factory/context.js +129 -0
- package/dist/factory/http.js +37 -0
- package/dist/factory/onboarding.js +155 -0
- package/dist/factory/onepassword.js +140 -0
- package/dist/factory/op-credential.js +154 -0
- package/dist/factory/passphrase.js +145 -0
- package/dist/factory/providers/cloudflare.js +126 -0
- package/dist/factory/providers/neon.js +81 -0
- package/dist/factory/providers/report-receiver.js +37 -0
- package/dist/factory/providers/resend.js +32 -0
- package/dist/factory/providers/upstash.js +21 -0
- package/dist/factory/registry.js +50 -0
- package/dist/factory/scoped-key.js +30 -0
- package/dist/factory/sealed.js +64 -0
- package/dist/factory/secret-input.js +43 -0
- package/dist/factory/steps/domain.js +23 -0
- package/dist/factory/steps/neon.js +101 -0
- package/dist/factory/steps/r2.js +70 -0
- package/dist/factory/steps/reports.js +44 -0
- package/dist/factory/steps/resend.js +37 -0
- package/dist/factory/steps/secrets.js +104 -0
- package/dist/factory/steps/upstash.js +92 -0
- package/dist/factory/steps/vercel.js +68 -0
- package/dist/factory/vercel-api.js +165 -0
- package/dist/package-info.js +15 -0
- package/dist/shared/agent.js +28 -0
- package/dist/shared/env-file.js +54 -0
- package/dist/shared/git.js +48 -0
- package/dist/shared/output.js +10 -0
- package/dist/shared/private-file.js +63 -0
- package/dist/shared/process.js +55 -0
- package/dist/shared/repo-config.js +104 -0
- package/dist/shared/sandbox-profile.js +12 -0
- package/package.json +44 -4
- package/skills/provision/SKILL.md +172 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { format } from 'node:util';
|
|
2
|
+
import { Console, Effect, Option, Result } from 'effect';
|
|
3
|
+
import { Command, Flag } from 'effect/cli';
|
|
4
|
+
import { resolveAppDirectory } from '../env/paths.js';
|
|
5
|
+
import { resolveWorkspace } from '../shared/git.js';
|
|
6
|
+
import { factorySteps, readRepoConfig } from '../shared/repo-config.js';
|
|
7
|
+
import { humanOnly } from './approval.js';
|
|
8
|
+
import { factoryError, onExistingPolicies, withTerminal } from './context.js';
|
|
9
|
+
import { decodeCredentials, readItemFields } from './onepassword.js';
|
|
10
|
+
import { resolveOpAuth } from './op-credential.js';
|
|
11
|
+
import { readRegistry } from './registry.js';
|
|
12
|
+
import { domainStep } from './steps/domain.js';
|
|
13
|
+
import { neonStep } from './steps/neon.js';
|
|
14
|
+
import { r2Step } from './steps/r2.js';
|
|
15
|
+
import { reportsStep } from './steps/reports.js';
|
|
16
|
+
import { resendStep } from './steps/resend.js';
|
|
17
|
+
import { secretsStep } from './steps/secrets.js';
|
|
18
|
+
import { upstashStep } from './steps/upstash.js';
|
|
19
|
+
import { vercelStep } from './steps/vercel.js';
|
|
20
|
+
const steps = {
|
|
21
|
+
vercel: vercelStep,
|
|
22
|
+
neon: neonStep,
|
|
23
|
+
r2: r2Step,
|
|
24
|
+
upstash: upstashStep,
|
|
25
|
+
resend: resendStep,
|
|
26
|
+
reports: reportsStep,
|
|
27
|
+
secrets: secretsStep,
|
|
28
|
+
domain: domainStep
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Runs one step with its output buffered and printed as one block when it ends, so concurrent
|
|
32
|
+
* steps do not interleave. A failing step never interrupts the others mid-mutation.
|
|
33
|
+
*/
|
|
34
|
+
const runStep = (step, context) => Effect.gen(function* () {
|
|
35
|
+
const lines = [`\n[${step}]`];
|
|
36
|
+
const console = yield* Console.Console;
|
|
37
|
+
const buffered = {
|
|
38
|
+
...console,
|
|
39
|
+
log: (...args) => {
|
|
40
|
+
lines.push(format(...args));
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
yield* steps[step](context).pipe(Effect.provideService(Console.Console, buffered), Effect.tapError(error => Effect.sync(() => lines.push(` failed: ${error.message}`))),
|
|
44
|
+
// Waits while another step is prompting, so output never lands in the middle of a prompt.
|
|
45
|
+
Effect.ensuring(Effect.suspend(() => withTerminal(Console.log(lines.join('\n'))))));
|
|
46
|
+
});
|
|
47
|
+
const isFactoryStep = (value) => factorySteps.some(step => step === value);
|
|
48
|
+
/** `--only` narrows the repository's steps; execution order is always the canonical order. */
|
|
49
|
+
export const selectSteps = (configured, only) => Effect.gen(function* () {
|
|
50
|
+
if (Option.isNone(only))
|
|
51
|
+
return configured;
|
|
52
|
+
const requested = only.value
|
|
53
|
+
.split(',')
|
|
54
|
+
.map(step => step.trim())
|
|
55
|
+
.filter(step => step.length > 0);
|
|
56
|
+
const unknown = requested.filter(step => !isFactoryStep(step));
|
|
57
|
+
if (unknown.length > 0 || requested.length === 0) {
|
|
58
|
+
return yield* factoryError(`--only accepts ${factorySteps.join(', ')}${unknown.length > 0 ? `; unknown: ${unknown.join(', ')}` : ''}`);
|
|
59
|
+
}
|
|
60
|
+
const outside = requested.filter(step => !configured.some(candidate => candidate === step));
|
|
61
|
+
if (outside.length > 0) {
|
|
62
|
+
return yield* factoryError(`--only names steps this repository does not declare in provision.factory.steps: ${outside.join(', ')}`);
|
|
63
|
+
}
|
|
64
|
+
return configured.filter(step => requested.includes(step));
|
|
65
|
+
});
|
|
66
|
+
/** Reads one factory's item with that factory's own credentials. */
|
|
67
|
+
const readFactory = (name, entry) => resolveOpAuth(name).pipe(Effect.flatMap(auth => readItemFields(entry, auth)), Effect.flatMap(decodeCredentials), Effect.map(credentials => ({ name, credentials })));
|
|
68
|
+
/**
|
|
69
|
+
* The explicit factory, or the single registered factory for the checkout's GitHub owner. The
|
|
70
|
+
* owner recorded by `config add` selects without reading any other factory's item; entries
|
|
71
|
+
* without it fall back to reading their items.
|
|
72
|
+
*/
|
|
73
|
+
const selectFactory = (registry, requested, owner) => Effect.gen(function* () {
|
|
74
|
+
const entries = Object.entries(registry.factories);
|
|
75
|
+
if (entries.length === 0) {
|
|
76
|
+
return yield* factoryError('no factories registered; run: provision config add');
|
|
77
|
+
}
|
|
78
|
+
if (Option.isSome(requested)) {
|
|
79
|
+
const entry = registry.factories[requested.value];
|
|
80
|
+
if (entry === undefined) {
|
|
81
|
+
return yield* factoryError(`no factory named ${requested.value}`);
|
|
82
|
+
}
|
|
83
|
+
return yield* readFactory(requested.value, entry);
|
|
84
|
+
}
|
|
85
|
+
const recorded = entries.filter(([, entry]) => entry.githubOwner === owner);
|
|
86
|
+
const unrecorded = entries.filter(([, entry]) => entry.githubOwner === undefined);
|
|
87
|
+
const matches = [];
|
|
88
|
+
for (const [name, entry] of recorded)
|
|
89
|
+
matches.push(yield* readFactory(name, entry));
|
|
90
|
+
if (matches.length === 0) {
|
|
91
|
+
for (const [name, entry] of unrecorded) {
|
|
92
|
+
const factory = yield* readFactory(name, entry);
|
|
93
|
+
if (factory.credentials.githubOwner === owner)
|
|
94
|
+
matches.push(factory);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const match = matches.length === 1 ? matches[0] : undefined;
|
|
98
|
+
if (match === undefined) {
|
|
99
|
+
return yield* factoryError(matches.length === 0
|
|
100
|
+
? `no registered factory has github.owner ${owner}; pass --factory`
|
|
101
|
+
: `several factories have github.owner ${owner}; pass --factory`);
|
|
102
|
+
}
|
|
103
|
+
return match;
|
|
104
|
+
});
|
|
105
|
+
const domainPattern = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;
|
|
106
|
+
export const runFactory = (options) => Effect.gen(function* () {
|
|
107
|
+
const workspace = yield* resolveWorkspace(Option.some(options.checkout));
|
|
108
|
+
const [owner, name] = workspace.repository.split('/');
|
|
109
|
+
if (owner === undefined || name === undefined || name === '') {
|
|
110
|
+
return yield* factoryError('the checkout needs a GitHub origin remote (owner/repository)');
|
|
111
|
+
}
|
|
112
|
+
const repoConfig = yield* readRepoConfig(workspace.root);
|
|
113
|
+
const selected = yield* selectSteps(repoConfig.factory.steps, options.only);
|
|
114
|
+
const domain = Option.getOrUndefined(options.domain)?.toLowerCase();
|
|
115
|
+
if (domain !== undefined && !domainPattern.test(domain)) {
|
|
116
|
+
return yield* factoryError(`--domain must be a host name such as app.example.com`);
|
|
117
|
+
}
|
|
118
|
+
const needsDomain = selected.filter(step => step === 'r2' || step === 'domain');
|
|
119
|
+
if (domain === undefined && needsDomain.length > 0) {
|
|
120
|
+
return yield* factoryError(`--domain is required for ${needsDomain.join(', ')}`);
|
|
121
|
+
}
|
|
122
|
+
const emailFrom = Option.getOrUndefined(options.emailFrom)?.trim().toLowerCase();
|
|
123
|
+
if (emailFrom !== undefined && !/^[^\s@<>]+@[a-z0-9.-]+\.[a-z]{2,}$/.test(emailFrom)) {
|
|
124
|
+
return yield* factoryError('--email-from must be an address such as noreply@acme.com');
|
|
125
|
+
}
|
|
126
|
+
const factory = yield* selectFactory(yield* readRegistry, options.factory, owner);
|
|
127
|
+
if (factory.credentials.githubOwner !== owner) {
|
|
128
|
+
return yield* factoryError(`factory ${factory.name} serves GitHub owner ${factory.credentials.githubOwner}, not ${owner}`);
|
|
129
|
+
}
|
|
130
|
+
const app = yield* resolveAppDirectory(workspace.root, repoConfig.appDir);
|
|
131
|
+
const context = {
|
|
132
|
+
factory: factory.name,
|
|
133
|
+
credentials: factory.credentials,
|
|
134
|
+
checkout: { root: workspace.root, owner, name, repository: workspace.repository },
|
|
135
|
+
repoConfig,
|
|
136
|
+
appDirectory: app.directory,
|
|
137
|
+
domain,
|
|
138
|
+
senderName: Option.getOrElse(options.senderName, () => name),
|
|
139
|
+
emailFrom,
|
|
140
|
+
neonParentBranch: Option.getOrUndefined(options.neonParentBranch),
|
|
141
|
+
dryRun: options.dryRun,
|
|
142
|
+
onExisting: options.onExisting,
|
|
143
|
+
names: { project: name, development: `${name}-dev`, production: `${name}-prod` }
|
|
144
|
+
};
|
|
145
|
+
yield* Console.log(`provision factory: ${workspace.repository} with factory ${factory.name}` +
|
|
146
|
+
`${options.dryRun ? ' (dry run: nothing is changed)' : ''}\n` +
|
|
147
|
+
` steps: ${selected.join(', ')}`);
|
|
148
|
+
// Every other step needs the Vercel project, so `vercel` runs alone first.
|
|
149
|
+
if (selected.includes('vercel'))
|
|
150
|
+
yield* runStep('vercel', context);
|
|
151
|
+
const results = yield* Effect.forEach(selected.filter(step => step !== 'vercel'), step => runStep(step, context).pipe(Effect.result), { concurrency: 'unbounded' });
|
|
152
|
+
const failed = results.flatMap(result => (Result.isFailure(result) ? [result.failure] : []));
|
|
153
|
+
if (failed.length === 1 && failed[0] !== undefined)
|
|
154
|
+
return yield* failed[0];
|
|
155
|
+
if (failed.length > 1) {
|
|
156
|
+
return yield* factoryError(`${failed.length} steps failed: ${failed.map(error => error.message).join('; ')}`);
|
|
157
|
+
}
|
|
158
|
+
yield* Console.log(`\nprovision factory: ${options.dryRun ? 'dry run complete' : 'done'}` +
|
|
159
|
+
`\n next: provision env --repo ${workspace.root} --database`);
|
|
160
|
+
});
|
|
161
|
+
export const factoryCommand = Command.make('factory', {
|
|
162
|
+
checkout: Flag.String('checkout').pipe(Flag.withDescription('company checkout (default: current directory)'), Flag.withDefault('.')),
|
|
163
|
+
domain: Flag.String('domain').pipe(Flag.withDescription('production host, e.g. app.example.com (r2 and domain steps)'), Flag.optional),
|
|
164
|
+
senderName: Flag.String('sender-name').pipe(Flag.withDescription('AUTH_EMAIL_FROM display name (default: repository name)'), Flag.optional),
|
|
165
|
+
emailFrom: Flag.String('email-from').pipe(Flag.withDescription('AUTH_EMAIL_FROM address, e.g. noreply@acme.com (default: noreply@<resend.sending_domain>)'), Flag.optional),
|
|
166
|
+
factory: Flag.String('factory').pipe(Flag.withDescription('factory name (default: the factory whose github.owner matches)'), Flag.optional),
|
|
167
|
+
only: Flag.String('only').pipe(Flag.withDescription(`comma-separated subset of: ${factorySteps.join(', ')}`), Flag.optional),
|
|
168
|
+
onExisting: Flag.Literals('on-existing', onExistingPolicies).pipe(Flag.withDescription('what to do when a resource exists (data is only ever reused)'), Flag.withDefault('ask')),
|
|
169
|
+
neonParentBranch: Flag.String('neon-parent-branch').pipe(Flag.withDescription('existing-branch mode: sandbox parent branch id'), Flag.optional),
|
|
170
|
+
dryRun: Flag.Boolean('dry-run').pipe(Flag.withDescription('inspect and print planned changes without making them'), Flag.withDefault(false))
|
|
171
|
+
}, options => humanOnly(runFactory(options).pipe(Effect.catch(error => Console.error(`provision factory: ${error.message}`).pipe(Effect.andThen(Effect.sync(() => {
|
|
172
|
+
process.exitCode = 2;
|
|
173
|
+
}))))))).pipe(Command.withDescription('Create provider projects and resources with factory admin keys, mint scoped keys, and write them to Vercel.'));
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { Console, Effect, Option, Redacted, Result } from 'effect';
|
|
2
|
+
import { Argument, Command, Flag, Prompt } from 'effect/cli';
|
|
3
|
+
import { humanOnly } from './approval.js';
|
|
4
|
+
import { report } from '../shared/output.js';
|
|
5
|
+
import { decodeCredentials, describeSections, parseItemFields, readItemFields, readItemJson } from './onepassword.js';
|
|
6
|
+
import { ensureFactoryItem, missingTemplateFields, onboardFactory } from './onboarding.js';
|
|
7
|
+
import { readHiddenLine } from './passphrase.js';
|
|
8
|
+
import { describeOpAuth, opAuthKind, opCredentialFile, parseServiceAccountToken, removeServiceAccountToken, resolveOpAuth, storeServiceAccountToken } from './op-credential.js';
|
|
9
|
+
import { readRegistry, RegistryError, validateFactoryName, writeRegistry } from './registry.js';
|
|
10
|
+
const setExitCode = (code) => Effect.sync(() => {
|
|
11
|
+
process.exitCode = code;
|
|
12
|
+
});
|
|
13
|
+
const handled = (effect) => effect.pipe(Effect.catch(error => Console.error(`provision config: ${error.message}`).pipe(Effect.andThen(setExitCode(2)))));
|
|
14
|
+
/** `handled`, for commands that read factory items: only a human can approve them. */
|
|
15
|
+
const approved = (effect) => humanOnly(handled(effect));
|
|
16
|
+
const jsonFlag = Flag.Boolean('json').pipe(Flag.withDescription('machine-readable output'), Flag.withDefault(false));
|
|
17
|
+
/** Uses the flag value, or prompts when interactive; agents must pass every flag. */
|
|
18
|
+
const valueOrPrompt = (value, message, fallback) => Option.isSome(value)
|
|
19
|
+
? Effect.succeed(value.value)
|
|
20
|
+
: process.stdin.isTTY === true && process.stdout.isTTY === true
|
|
21
|
+
? Prompt.run(Prompt.String(fallback === undefined ? { message } : { message, default: fallback })).pipe(Effect.mapError(() => new RegistryError({ message: 'cancelled' })))
|
|
22
|
+
: fallback === undefined
|
|
23
|
+
? Effect.fail(new RegistryError({ message: `${message}: pass it as a flag` }))
|
|
24
|
+
: Effect.succeed(fallback);
|
|
25
|
+
const requiredSections = new Set(['github', 'vercel']);
|
|
26
|
+
/** One line per section: status plus the fields still to fill. */
|
|
27
|
+
const sectionTable = (sections) => sections
|
|
28
|
+
.map(section => {
|
|
29
|
+
const status = section.status === 'complete'
|
|
30
|
+
? '✓ complete'
|
|
31
|
+
: section.status === 'partial'
|
|
32
|
+
? `partial: also fill ${section.missing.join(', ')}`
|
|
33
|
+
: `empty: ${section.missing.join(', ')}${requiredSections.has(section.section) ? ' (required)' : ''}`;
|
|
34
|
+
return ` ${section.section.padEnd(16)} ${status}`;
|
|
35
|
+
})
|
|
36
|
+
.join('\n');
|
|
37
|
+
/** Whether `op` already has a service-account credential for this factory. */
|
|
38
|
+
const hasServiceAccount = (factory) => Effect.map(opAuthKind(factory), kind => kind !== 'session');
|
|
39
|
+
const listFactories = (json) => Effect.gen(function* () {
|
|
40
|
+
const registry = yield* readRegistry;
|
|
41
|
+
if (json)
|
|
42
|
+
return yield* Console.log(JSON.stringify(registry, null, 2));
|
|
43
|
+
const entries = Object.entries(registry.factories);
|
|
44
|
+
if (entries.length === 0) {
|
|
45
|
+
return yield* Console.log('no factories registered; run: provision config add');
|
|
46
|
+
}
|
|
47
|
+
yield* Console.log(entries
|
|
48
|
+
.map(([name, entry]) => `${name} vault=${entry.vault} item=${entry.item}${entry.account === undefined ? '' : ` account=${entry.account}`}`)
|
|
49
|
+
.join('\n'));
|
|
50
|
+
});
|
|
51
|
+
/** Reads the factory item and summarizes it without revealing any value. */
|
|
52
|
+
const inspectFactory = (entry, auth) => Effect.gen(function* () {
|
|
53
|
+
const fields = yield* readItemFields(entry, auth);
|
|
54
|
+
const credentials = yield* decodeCredentials(fields);
|
|
55
|
+
return {
|
|
56
|
+
githubOwner: credentials.githubOwner,
|
|
57
|
+
vercelTeamId: credentials.vercel.teamId,
|
|
58
|
+
sections: describeSections(fields)
|
|
59
|
+
};
|
|
60
|
+
});
|
|
61
|
+
const add = Command.make('add', {
|
|
62
|
+
name: Flag.String('name').pipe(Flag.withDescription('factory name, e.g. acme'), Flag.optional),
|
|
63
|
+
vault: Flag.String('vault').pipe(Flag.withDescription('1Password vault'), Flag.optional),
|
|
64
|
+
item: Flag.String('item').pipe(Flag.withDescription('1Password item (default: provision)'), Flag.optional),
|
|
65
|
+
account: Flag.String('account').pipe(Flag.withDescription('1Password account (default: the CLI default)'), Flag.optional),
|
|
66
|
+
yes: Flag.Boolean('yes').pipe(Flag.withDescription('create a missing vault or item without asking'), Flag.withDefault(false))
|
|
67
|
+
}, options => approved(Effect.gen(function* () {
|
|
68
|
+
const name = yield* valueOrPrompt(options.name, 'Factory name').pipe(Effect.flatMap(validateFactoryName));
|
|
69
|
+
const vault = yield* valueOrPrompt(options.vault, '1Password vault', `provision-${name}`);
|
|
70
|
+
const item = yield* valueOrPrompt(options.item, '1Password item', 'provision');
|
|
71
|
+
const location = Option.match(options.account, {
|
|
72
|
+
onNone: () => ({ vault, item }),
|
|
73
|
+
onSome: account => ({ vault, item, account })
|
|
74
|
+
});
|
|
75
|
+
// First run: create the vault, item, and read-only service account with the operator's
|
|
76
|
+
// own sign-in. Reruns reuse the stored token and only need the passphrase.
|
|
77
|
+
if (!(yield* hasServiceAccount(name))) {
|
|
78
|
+
yield* onboardFactory(name, vault, item, { assumeYes: options.yes });
|
|
79
|
+
}
|
|
80
|
+
const auth = yield* resolveOpAuth(name);
|
|
81
|
+
const first = yield* Effect.result(readItemJson(location, auth));
|
|
82
|
+
if (Result.isFailure(first) && first.failure.notFound !== true) {
|
|
83
|
+
return yield* first.failure;
|
|
84
|
+
}
|
|
85
|
+
const problem = Result.isFailure(first)
|
|
86
|
+
? `Item ${item} does not exist in ${vault} yet; signing in to 1Password to create it.`
|
|
87
|
+
: missingTemplateFields(first.success).length > 0
|
|
88
|
+
? `Item ${item} is missing ${missingTemplateFields(first.success).length} field(s); signing in to 1Password to add them.`
|
|
89
|
+
: undefined;
|
|
90
|
+
// A missing or hand-made item: create it, or add the missing fields (empty), with the
|
|
91
|
+
// operator's sign-in. Existing values are never touched.
|
|
92
|
+
if (problem !== undefined) {
|
|
93
|
+
yield* Console.log(problem);
|
|
94
|
+
yield* ensureFactoryItem(vault, item, { assumeYes: options.yes });
|
|
95
|
+
}
|
|
96
|
+
const json = problem === undefined && Result.isSuccess(first)
|
|
97
|
+
? first.success
|
|
98
|
+
: yield* readItemJson(location, auth);
|
|
99
|
+
const fields = yield* parseItemFields(json);
|
|
100
|
+
const sections = describeSections(fields);
|
|
101
|
+
const credentials = yield* Effect.result(decodeCredentials(fields));
|
|
102
|
+
const incomplete = sections.some(section => section.status === 'partial' ||
|
|
103
|
+
(section.status === 'absent' && requiredSections.has(section.section)));
|
|
104
|
+
// Every section is filled but a value is invalid: say which, not "fill in the item".
|
|
105
|
+
if (Result.isFailure(credentials) && !incomplete) {
|
|
106
|
+
yield* Console.log(sectionTable(sections));
|
|
107
|
+
return yield* new RegistryError({
|
|
108
|
+
message: `${credentials.failure.message} (item ${item} in vault ${vault})`
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
if (Result.isFailure(credentials)) {
|
|
112
|
+
yield* Console.log([
|
|
113
|
+
'',
|
|
114
|
+
`Next: fill in item ${item} in vault ${vault} (1Password app or web).`,
|
|
115
|
+
'github and vercel are required; fill the other sections for the providers this factory uses.',
|
|
116
|
+
'',
|
|
117
|
+
sectionTable(sections),
|
|
118
|
+
'',
|
|
119
|
+
`Then run: provision config add --name ${name}${vault === `provision-${name}` ? '' : ` --vault ${vault}`}`
|
|
120
|
+
].join('\n'));
|
|
121
|
+
return yield* setExitCode(1);
|
|
122
|
+
}
|
|
123
|
+
const registry = yield* readRegistry;
|
|
124
|
+
const entry = { ...location, githubOwner: credentials.success.githubOwner };
|
|
125
|
+
yield* writeRegistry({ factories: { ...registry.factories, [name]: entry } });
|
|
126
|
+
yield* Console.log(sectionTable(sections));
|
|
127
|
+
yield* report(false, {
|
|
128
|
+
status: name in registry.factories ? 'updated' : 'added',
|
|
129
|
+
factory: name,
|
|
130
|
+
auth: describeAuth(auth),
|
|
131
|
+
github_owner: credentials.success.githubOwner,
|
|
132
|
+
vercel_team: credentials.success.vercel.teamId
|
|
133
|
+
});
|
|
134
|
+
}))).pipe(Command.withDescription('Set up a factory: create its vault, item, and read-only service account if needed, then register it.'));
|
|
135
|
+
const list = Command.make('list', { json: jsonFlag }, ({ json }) => handled(listFactories(json))).pipe(Command.withDescription('List registered factories.'));
|
|
136
|
+
const remove = Command.make('remove', { name: Argument.String('name').pipe(Argument.withDescription('factory name')) }, ({ name }) => handled(Effect.gen(function* () {
|
|
137
|
+
const registry = yield* readRegistry;
|
|
138
|
+
if (!(name in registry.factories)) {
|
|
139
|
+
return yield* new RegistryError({ message: `no factory named ${name}` });
|
|
140
|
+
}
|
|
141
|
+
const { [name]: _removed, ...rest } = registry.factories;
|
|
142
|
+
yield* writeRegistry({ factories: rest });
|
|
143
|
+
yield* Console.log(`removed factory ${name}`);
|
|
144
|
+
}))).pipe(Command.withDescription('Unregister a factory (the 1Password item is untouched).'));
|
|
145
|
+
const verify = Command.make('verify', {
|
|
146
|
+
name: Argument.String('name').pipe(Argument.withDescription('factory name (default: all)'), Argument.optional),
|
|
147
|
+
json: jsonFlag
|
|
148
|
+
}, ({ json, name }) => approved(Effect.gen(function* () {
|
|
149
|
+
const registry = yield* readRegistry;
|
|
150
|
+
const selected = Object.entries(registry.factories).filter(([candidate]) => Option.match(name, { onNone: () => true, onSome: wanted => wanted === candidate }));
|
|
151
|
+
if (selected.length === 0) {
|
|
152
|
+
return yield* new RegistryError({ message: 'no matching factory registered' });
|
|
153
|
+
}
|
|
154
|
+
const results = yield* Effect.forEach(selected, ([factory, entry]) => Effect.gen(function* () {
|
|
155
|
+
const auth = yield* resolveOpAuth(factory);
|
|
156
|
+
const inspected = yield* inspectFactory(entry, auth);
|
|
157
|
+
return { auth: describeAuth(auth), ...inspected };
|
|
158
|
+
}).pipe(Effect.map(inspected => ({ factory, ok: true, ...inspected })), Effect.catch(error => Effect.succeed({ factory, ok: false, error: error.message }))));
|
|
159
|
+
if (json)
|
|
160
|
+
yield* Console.log(JSON.stringify(results, null, 2));
|
|
161
|
+
else {
|
|
162
|
+
for (const result of results) {
|
|
163
|
+
yield* Console.log(result.ok
|
|
164
|
+
? `${result.factory}: ok (github ${result.githubOwner}, vercel ${result.vercelTeamId}, auth ${result.auth})\n` +
|
|
165
|
+
result.sections
|
|
166
|
+
.map(section => ` ${section.section.padEnd(16)} ${section.status}${section.missing.length > 0 && section.status === 'partial' ? ` (missing ${section.missing.join(', ')})` : ''}`)
|
|
167
|
+
.join('\n')
|
|
168
|
+
: `${result.factory}: ${result.error}`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (results.some(result => !result.ok))
|
|
172
|
+
yield* setExitCode(1);
|
|
173
|
+
}))).pipe(Command.withDescription("Read each factory's 1Password item and report its sections."));
|
|
174
|
+
const describeAuth = (auth) => auth.kind === 'environment'
|
|
175
|
+
? 'service account (OP_SERVICE_ACCOUNT_TOKEN)'
|
|
176
|
+
: auth.kind === 'credential'
|
|
177
|
+
? `passphrase-protected service account (${auth.file})`
|
|
178
|
+
: 'op session or desktop app';
|
|
179
|
+
const readStdin = Effect.promise(async () => {
|
|
180
|
+
const chunks = [];
|
|
181
|
+
for await (const chunk of process.stdin) {
|
|
182
|
+
if (chunk instanceof Uint8Array)
|
|
183
|
+
chunks.push(chunk);
|
|
184
|
+
else if (typeof chunk === 'string')
|
|
185
|
+
chunks.push(Buffer.from(chunk));
|
|
186
|
+
}
|
|
187
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
188
|
+
});
|
|
189
|
+
const factoryFlag = Flag.String('factory').pipe(Flag.withDescription('factory the token belongs to (it may be registered afterwards)'));
|
|
190
|
+
const opTokenSet = Command.make('set', { factory: factoryFlag }, ({ factory }) => approved(Effect.gen(function* () {
|
|
191
|
+
const name = yield* validateFactoryName(factory);
|
|
192
|
+
const raw = process.stdin.isTTY === true
|
|
193
|
+
? Redacted.value(yield* readHiddenLine('1Password service-account token: '))
|
|
194
|
+
: yield* readStdin;
|
|
195
|
+
const token = yield* parseServiceAccountToken(raw);
|
|
196
|
+
const file = yield* storeServiceAccountToken(name, token);
|
|
197
|
+
yield* Console.log(`stored the ${name} service-account token encrypted at ${file}`);
|
|
198
|
+
}))).pipe(Command.withDescription('Store a factory service-account token (stdin or hidden prompt), sealed with the provision passphrase and systemd-creds.'));
|
|
199
|
+
const opTokenStatus = Command.make('status', {}, () => handled(Effect.gen(function* () {
|
|
200
|
+
const registry = yield* readRegistry;
|
|
201
|
+
const names = Object.keys(registry.factories);
|
|
202
|
+
if (names.length === 0)
|
|
203
|
+
return yield* Console.log('no factories registered');
|
|
204
|
+
for (const name of names) {
|
|
205
|
+
yield* Console.log(`${name}: ${yield* describeOpAuth(name)}`);
|
|
206
|
+
}
|
|
207
|
+
}))).pipe(Command.withDescription('Show how provision authenticates 1Password for each factory.'));
|
|
208
|
+
const opTokenRemove = Command.make('remove', { factory: factoryFlag }, ({ factory }) => handled(Effect.gen(function* () {
|
|
209
|
+
const name = yield* validateFactoryName(factory);
|
|
210
|
+
const file = yield* opCredentialFile(name);
|
|
211
|
+
const removed = yield* removeServiceAccountToken(name);
|
|
212
|
+
yield* Console.log(removed ? `removed ${file}` : `no stored token at ${file}`);
|
|
213
|
+
}))).pipe(Command.withDescription('Delete the stored token (revoke it in 1Password: Developer > Service accounts).'));
|
|
214
|
+
const opToken = Command.make('op-token').pipe(Command.withDescription("Manage each factory's 1Password service-account token."), Command.withSubcommands([opTokenSet, opTokenStatus, opTokenRemove]));
|
|
215
|
+
export const configCommand = Command.make('config', { json: jsonFlag }, ({ json }) => handled(listFactories(json))).pipe(Command.withDescription('Manage factories: which 1Password item holds each factory account set.'), Command.withSubcommands([add, list, remove, verify, opToken]));
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { Console, Data, Effect, Option, Semaphore } from 'effect';
|
|
2
|
+
import { Prompt } from 'effect/cli';
|
|
3
|
+
import { createEnvRecord, deleteEnvRecord, getProject, listCustomEnvironments, listEnvRecords, recordTargets } from './vercel-api.js';
|
|
4
|
+
export class FactoryError extends Data.TaggedError('FactoryError') {
|
|
5
|
+
}
|
|
6
|
+
export const onExistingPolicies = ['ask', 'reuse', 'overwrite', 'abort'];
|
|
7
|
+
export const factoryError = (message) => new FactoryError({ message });
|
|
8
|
+
const interactive = () => process.stdin.isTTY === true && process.stdout.isTTY === true;
|
|
9
|
+
/** Concurrent steps share one terminal: one prompt (or group of prompts) at a time. */
|
|
10
|
+
const promptLock = Semaphore.makeUnsafe(1);
|
|
11
|
+
export const withTerminal = (effect) => promptLock.withPermits(1)(effect);
|
|
12
|
+
/** Decides what to do with an existing resource: `reuse` or `overwrite` (abort fails). */
|
|
13
|
+
export const decide = (context, kind, description) => Effect.gen(function* () {
|
|
14
|
+
if (context.dryRun) {
|
|
15
|
+
yield* Console.log(` exists: ${description} (on-existing=${context.onExisting})`);
|
|
16
|
+
return 'reuse';
|
|
17
|
+
}
|
|
18
|
+
if (context.onExisting === 'abort') {
|
|
19
|
+
return yield* factoryError(`${description} already exists (--on-existing=abort)`);
|
|
20
|
+
}
|
|
21
|
+
if (context.onExisting === 'reuse')
|
|
22
|
+
return 'reuse';
|
|
23
|
+
if (context.onExisting === 'overwrite') {
|
|
24
|
+
if (kind === 'data') {
|
|
25
|
+
yield* Console.log(` reusing ${description}: data is never overwritten`);
|
|
26
|
+
return 'reuse';
|
|
27
|
+
}
|
|
28
|
+
return 'overwrite';
|
|
29
|
+
}
|
|
30
|
+
if (!interactive()) {
|
|
31
|
+
return yield* factoryError(`${description} already exists and prompting is unavailable; pass --on-existing`);
|
|
32
|
+
}
|
|
33
|
+
const warning = kind === 'secret'
|
|
34
|
+
? ' (overwriting invalidates sessions, encrypted data, or subscriptions that depend on it)'
|
|
35
|
+
: kind === 'key'
|
|
36
|
+
? ' (overwriting rotates the key)'
|
|
37
|
+
: '';
|
|
38
|
+
const choice = yield* Prompt.run(Prompt.Select({
|
|
39
|
+
message: `${description} already exists${warning}`,
|
|
40
|
+
choices: [
|
|
41
|
+
{ title: 'Reuse', value: 'reuse' },
|
|
42
|
+
...(kind === 'data' ? [] : [{ title: 'Overwrite', value: 'overwrite' }]),
|
|
43
|
+
{ title: 'Abort', value: 'abort' }
|
|
44
|
+
]
|
|
45
|
+
})).pipe(Effect.mapError(() => factoryError('cancelled')), withTerminal);
|
|
46
|
+
if (choice === 'abort')
|
|
47
|
+
return yield* factoryError(`aborted at ${description}`);
|
|
48
|
+
return choice;
|
|
49
|
+
});
|
|
50
|
+
/** Runs a mutation, or only describes it in a dry run (`None`). */
|
|
51
|
+
export const mutate = (context, description, effect) => context.dryRun
|
|
52
|
+
? Console.log(` would ${description}`).pipe(Effect.as(Option.none()))
|
|
53
|
+
: Console.log(` ${description}`).pipe(Effect.andThen(effect), Effect.map(Option.some));
|
|
54
|
+
/** The company Vercel project and its `test` environment, created by the `vercel` step. */
|
|
55
|
+
export const findProject = (context) => Effect.gen(function* () {
|
|
56
|
+
const teamId = context.credentials.vercel.teamId;
|
|
57
|
+
const project = yield* getProject(teamId, context.names.project);
|
|
58
|
+
if (Option.isNone(project))
|
|
59
|
+
return Option.none();
|
|
60
|
+
const environments = yield* listCustomEnvironments(teamId, project.value.id);
|
|
61
|
+
return Option.some({
|
|
62
|
+
project: project.value,
|
|
63
|
+
testEnvironmentId: environments.find(environment => environment.slug === 'test')?.id
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
/** Steps after `vercel` need the project; in a dry run on a fresh repository it does not exist. */
|
|
67
|
+
export const requireProject = (context) => findProject(context).pipe(Effect.flatMap(Option.match({
|
|
68
|
+
onNone: () => context.dryRun
|
|
69
|
+
? Effect.succeed(Option.none())
|
|
70
|
+
: Effect.fail(factoryError(`Vercel project ${context.names.project} does not exist; run the vercel step first`)),
|
|
71
|
+
onSome: handle => handle.testEnvironmentId === undefined && !context.dryRun
|
|
72
|
+
? Effect.fail(factoryError('Vercel project has no test environment; run the vercel step'))
|
|
73
|
+
: Effect.succeed(Option.some(handle))
|
|
74
|
+
})));
|
|
75
|
+
/** Development + Preview + `test` vs Production, plus single-target groups. */
|
|
76
|
+
export const envGroups = (handle) => {
|
|
77
|
+
const test = handle.testEnvironmentId === undefined ? [] : [handle.testEnvironmentId];
|
|
78
|
+
return {
|
|
79
|
+
preproduction: {
|
|
80
|
+
label: 'Development, Preview, test',
|
|
81
|
+
target: ['development', 'preview'],
|
|
82
|
+
customEnvironmentIds: test
|
|
83
|
+
},
|
|
84
|
+
production: { label: 'Production', target: ['production'], customEnvironmentIds: [] },
|
|
85
|
+
development: { label: 'Development', target: ['development'], customEnvironmentIds: [] },
|
|
86
|
+
test: { label: 'test', target: [], customEnvironmentIds: test },
|
|
87
|
+
all: {
|
|
88
|
+
label: 'all environments',
|
|
89
|
+
target: ['development', 'preview', 'production'],
|
|
90
|
+
customEnvironmentIds: test
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
};
|
|
94
|
+
const covers = (record, group) => {
|
|
95
|
+
const targets = recordTargets(record);
|
|
96
|
+
const custom = record.customEnvironmentIds ?? [];
|
|
97
|
+
return (group.target.every(target => targets.includes(target)) &&
|
|
98
|
+
group.customEnvironmentIds.every(id => custom.includes(id)));
|
|
99
|
+
};
|
|
100
|
+
const overlaps = (record, group) => recordTargets(record).some(target => group.target.some(wanted => wanted === target)) ||
|
|
101
|
+
(record.customEnvironmentIds ?? []).some(id => group.customEnvironmentIds.includes(id));
|
|
102
|
+
const containedIn = (record, group) => recordTargets(record).every(target => group.target.some(wanted => wanted === target)) &&
|
|
103
|
+
(record.customEnvironmentIds ?? []).every(id => group.customEnvironmentIds.includes(id));
|
|
104
|
+
/** Whether every key has a record covering the whole group. */
|
|
105
|
+
export const groupHasKeys = (records, group, keys) => keys.every(key => records.some(record => record.key === key && covers(record, group)));
|
|
106
|
+
/**
|
|
107
|
+
* Replaces the group's records for each key: overlapping records are deleted, then one record per
|
|
108
|
+
* key is created. A record that spans environments outside the group is never touched.
|
|
109
|
+
*/
|
|
110
|
+
export const writeGroup = (context, handle, group, entries) => mutate(context, `write ${entries.map(entry => entry.key).join(', ')} to Vercel (${group.label})`, Effect.gen(function* () {
|
|
111
|
+
const teamId = context.credentials.vercel.teamId;
|
|
112
|
+
const records = yield* listEnvRecords(teamId, handle.project.id);
|
|
113
|
+
for (const entry of entries) {
|
|
114
|
+
const existing = records.filter(record => record.key === entry.key && overlaps(record, group));
|
|
115
|
+
const spanning = existing.filter(record => !containedIn(record, group));
|
|
116
|
+
if (spanning.length > 0) {
|
|
117
|
+
return yield* factoryError(`Vercel ${entry.key} has a record that also targets environments outside ${group.label}; split it manually`);
|
|
118
|
+
}
|
|
119
|
+
for (const record of existing)
|
|
120
|
+
yield* deleteEnvRecord(teamId, handle.project.id, record.id);
|
|
121
|
+
yield* createEnvRecord(teamId, handle.project.id, {
|
|
122
|
+
key: entry.key,
|
|
123
|
+
value: entry.value,
|
|
124
|
+
type: entry.type,
|
|
125
|
+
target: group.target,
|
|
126
|
+
customEnvironmentIds: group.customEnvironmentIds
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}));
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Data, Effect, Option, Schema } from 'effect';
|
|
2
|
+
import { HttpClient, HttpClientRequest } from 'effect/http';
|
|
3
|
+
export class ProviderError extends Data.TaggedError('ProviderError') {
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* A provider JSON request. Failures name only the action and status: response bodies, URLs with
|
|
7
|
+
* query strings, and credentials never reach errors or output.
|
|
8
|
+
*/
|
|
9
|
+
export const requestJson = (label, url, schema, options) => Effect.gen(function* () {
|
|
10
|
+
const client = yield* HttpClient.HttpClient;
|
|
11
|
+
const base = HttpClientRequest.make(options.method ?? 'GET')(url).pipe(HttpClientRequest.setHeaders({ Accept: 'application/json', ...options.headers }));
|
|
12
|
+
const request = options.body === undefined
|
|
13
|
+
? base
|
|
14
|
+
: yield* HttpClientRequest.bodyJson(base, options.body).pipe(Effect.mapError(() => new ProviderError({ message: `${label}: invalid request body` })));
|
|
15
|
+
const response = yield* client
|
|
16
|
+
.execute(request)
|
|
17
|
+
.pipe(Effect.mapError(() => new ProviderError({ message: `${label}: provider unreachable` })));
|
|
18
|
+
if (options.allowNotFound === true && response.status === 404) {
|
|
19
|
+
return Option.none();
|
|
20
|
+
}
|
|
21
|
+
if (response.status >= 400) {
|
|
22
|
+
return yield* new ProviderError({
|
|
23
|
+
message: `${label} failed (${response.status}); provider response suppressed`,
|
|
24
|
+
status: response.status
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
if (response.status === 204) {
|
|
28
|
+
return yield* Schema.decodeUnknownEffect(schema)(undefined).pipe(Effect.map(Option.some), Effect.mapError(() => new ProviderError({ message: `${label}: unexpected empty provider response` })));
|
|
29
|
+
}
|
|
30
|
+
const json = yield* response.json.pipe(Effect.mapError(() => new ProviderError({ message: `${label}: provider returned invalid JSON` })));
|
|
31
|
+
return yield* Schema.decodeUnknownEffect(schema)(json).pipe(Effect.map(Option.some), Effect.mapError(() => new ProviderError({ message: `${label}: unexpected provider response shape` })));
|
|
32
|
+
});
|
|
33
|
+
/** `requestJson` for calls where 404 is an error: returns the decoded body. */
|
|
34
|
+
export const requestJsonRequired = (label, url, schema, options) => requestJson(label, url, schema, options).pipe(Effect.flatMap(Option.match({
|
|
35
|
+
onNone: () => Effect.fail(new ProviderError({ message: `${label}: not found` })),
|
|
36
|
+
onSome: Effect.succeed
|
|
37
|
+
})));
|