@magoz/provision 0.0.0-stage → 0.1.1
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 +45 -4
- package/skills/provision/SKILL.md +172 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { Effect, Redacted } from 'effect';
|
|
2
|
+
import { readPrivately, writePrivately } from './private-file.js';
|
|
3
|
+
/** Parses `KEY=value` lines; blank lines and comments are skipped, matching quotes are removed. */
|
|
4
|
+
export const parseEnvFile = (content) => {
|
|
5
|
+
const entries = new Map();
|
|
6
|
+
for (const raw of content.split('\n')) {
|
|
7
|
+
const line = raw.trim();
|
|
8
|
+
if (line.length === 0 || line.startsWith('#'))
|
|
9
|
+
continue;
|
|
10
|
+
const index = line.indexOf('=');
|
|
11
|
+
if (index <= 0)
|
|
12
|
+
continue;
|
|
13
|
+
let value = line.slice(index + 1).trim();
|
|
14
|
+
if (value.length >= 2 &&
|
|
15
|
+
((value.startsWith('"') && value.endsWith('"')) ||
|
|
16
|
+
(value.startsWith("'") && value.endsWith("'")))) {
|
|
17
|
+
value = value.slice(1, -1);
|
|
18
|
+
}
|
|
19
|
+
entries.set(line.slice(0, index).trim(), value);
|
|
20
|
+
}
|
|
21
|
+
return entries;
|
|
22
|
+
};
|
|
23
|
+
export const envKeyPattern = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
24
|
+
const declares = (line, key) => line.startsWith(`${key}=`);
|
|
25
|
+
const readLines = (file) => readPrivately(file).pipe(Effect.map(content => (content === undefined ? [] : content.split('\n'))));
|
|
26
|
+
/** Whether `file` exists and declares every key. */
|
|
27
|
+
export const hasEnvKeys = (file, keys) => readPrivately(file).pipe(Effect.map(content => {
|
|
28
|
+
if (content === undefined)
|
|
29
|
+
return false;
|
|
30
|
+
const lines = content.split('\n');
|
|
31
|
+
return keys.every(key => lines.some(line => declares(line, key)));
|
|
32
|
+
}));
|
|
33
|
+
/**
|
|
34
|
+
* Replaces `keys` in `file`, keeping every other line. The only place redacted values are
|
|
35
|
+
* unwrapped for writing.
|
|
36
|
+
*/
|
|
37
|
+
export const writeEnvKeys = (file, values) => Effect.gen(function* () {
|
|
38
|
+
const existing = yield* readLines(file);
|
|
39
|
+
const keys = values.map(([key]) => key);
|
|
40
|
+
const kept = existing.filter(line => !keys.some(key => declares(line, key)));
|
|
41
|
+
while (kept.at(-1)?.trim() === '')
|
|
42
|
+
kept.pop();
|
|
43
|
+
const rendered = values.map(([key, value]) => `${key}=${Redacted.value(value)}`);
|
|
44
|
+
yield* writePrivately(file, `${[...kept, ...rendered].join('\n')}\n`);
|
|
45
|
+
});
|
|
46
|
+
/** Removes `keys` from `file`; a missing file is left alone. */
|
|
47
|
+
export const removeEnvKeys = (file, keys) => Effect.gen(function* () {
|
|
48
|
+
const content = yield* readPrivately(file);
|
|
49
|
+
if (content === undefined)
|
|
50
|
+
return;
|
|
51
|
+
const kept = content.split('\n').filter(line => !keys.some(key => declares(line, key)));
|
|
52
|
+
const body = kept.join('\n').trimEnd();
|
|
53
|
+
yield* writePrivately(file, body.length > 0 ? `${body}\n` : '');
|
|
54
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Data, Effect, FileSystem, Option, Path } from 'effect';
|
|
2
|
+
import { capture } from './process.js';
|
|
3
|
+
export class WorkspaceError extends Data.TaggedError('WorkspaceError') {
|
|
4
|
+
}
|
|
5
|
+
export class SecretPathError extends Data.TaggedError('SecretPathError') {
|
|
6
|
+
}
|
|
7
|
+
/** Trimmed stdout of a git command, or `None` when it fails or cannot run. */
|
|
8
|
+
export const gitOutput = (cwd, args) => capture('git', ['-C', cwd, ...args]).pipe(Effect.map(result => result.exitCode === 0 ? Option.some(result.stdout.trim()) : Option.none()), Effect.orElseSucceed(() => Option.none()));
|
|
9
|
+
export const gitSucceeds = (cwd, args) => capture('git', ['-C', cwd, ...args]).pipe(Effect.map(result => result.exitCode === 0), Effect.orElseSucceed(() => false));
|
|
10
|
+
export const repositoryFromRemote = (url) => /[:/]([^/:]+\/[^/]+?)(?:\.git)?\/?$/.exec(url.trim())?.[1];
|
|
11
|
+
/** Resolves and validates a Git checkout root; anything outside Git is refused. */
|
|
12
|
+
export const resolveWorkspace = (requested) => Effect.gen(function* () {
|
|
13
|
+
const path = yield* Path.Path;
|
|
14
|
+
const fs = yield* FileSystem.FileSystem;
|
|
15
|
+
const candidate = path.resolve(Option.getOrElse(requested, () => process.cwd()));
|
|
16
|
+
const isDirectory = yield* fs.stat(candidate).pipe(Effect.map(info => info.type === 'Directory'), Effect.orElseSucceed(() => false));
|
|
17
|
+
if (!isDirectory) {
|
|
18
|
+
return yield* new WorkspaceError({ message: `not a directory: ${candidate}` });
|
|
19
|
+
}
|
|
20
|
+
const inside = yield* gitOutput(candidate, ['rev-parse', '--is-inside-work-tree']);
|
|
21
|
+
if (Option.getOrElse(inside, () => '') !== 'true') {
|
|
22
|
+
return yield* new WorkspaceError({ message: `not inside a git worktree: ${candidate}` });
|
|
23
|
+
}
|
|
24
|
+
const top = yield* gitOutput(candidate, ['rev-parse', '--show-toplevel']);
|
|
25
|
+
const root = path.resolve(Option.getOrElse(top, () => candidate));
|
|
26
|
+
const remote = yield* gitOutput(root, ['remote', 'get-url', 'origin']);
|
|
27
|
+
const repository = Option.match(remote, {
|
|
28
|
+
onNone: () => path.basename(root),
|
|
29
|
+
onSome: url => repositoryFromRemote(url) ?? path.basename(root)
|
|
30
|
+
});
|
|
31
|
+
return { root, repository };
|
|
32
|
+
});
|
|
33
|
+
/**
|
|
34
|
+
* Secrets may only be written to a path Git will never track. This is the guardrail that makes
|
|
35
|
+
* automatic provisioning safe.
|
|
36
|
+
*/
|
|
37
|
+
export const ensureIgnored = (root, file) => Effect.gen(function* () {
|
|
38
|
+
if (yield* gitSucceeds(root, ['ls-files', '--error-unmatch', '--', file])) {
|
|
39
|
+
return yield* new SecretPathError({
|
|
40
|
+
message: `refusing to write secrets: ${file} is tracked by git`
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
if (!(yield* gitSucceeds(root, ['check-ignore', '-q', '--', file]))) {
|
|
44
|
+
return yield* new SecretPathError({
|
|
45
|
+
message: `refusing to write secrets: ${file} is not git-ignored\nadd it to .gitignore first`
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Console } from 'effect';
|
|
2
|
+
/** Prints a flat report: pretty JSON with `--json`, otherwise aligned `key value` lines. */
|
|
3
|
+
export const report = (json, payload) => {
|
|
4
|
+
if (json)
|
|
5
|
+
return Console.log(JSON.stringify(payload, null, 2));
|
|
6
|
+
const width = Math.max(0, ...Object.keys(payload).map(key => key.length));
|
|
7
|
+
return Console.log(Object.entries(payload)
|
|
8
|
+
.map(([key, value]) => `${key.padEnd(width)} ${String(value)}`)
|
|
9
|
+
.join('\n'));
|
|
10
|
+
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import * as NodeFs from 'node:fs/promises';
|
|
3
|
+
import { Data, Effect, Predicate } from 'effect';
|
|
4
|
+
export class PrivateFileError extends Data.TaggedError('PrivateFileError') {
|
|
5
|
+
}
|
|
6
|
+
const isMissing = (cause) => Predicate.hasProperty(cause, 'code') && cause.code === 'ENOENT';
|
|
7
|
+
/** Fails when `file` is a symbolic link (dangling links included); a missing path is fine. */
|
|
8
|
+
export const ensureNotSymlink = (file) => Effect.tryPromise({
|
|
9
|
+
try: async () => {
|
|
10
|
+
try {
|
|
11
|
+
if ((await NodeFs.lstat(file)).isSymbolicLink())
|
|
12
|
+
throw new Error('symbolic link');
|
|
13
|
+
}
|
|
14
|
+
catch (cause) {
|
|
15
|
+
if (isMissing(cause))
|
|
16
|
+
return;
|
|
17
|
+
throw cause;
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
catch: () => new PrivateFileError({
|
|
21
|
+
reason: 'symbolic-link',
|
|
22
|
+
message: `refusing symbolic-link path: ${file}`
|
|
23
|
+
})
|
|
24
|
+
});
|
|
25
|
+
/**
|
|
26
|
+
* Atomically replaces `file` with `content`, mode `0600` from the first byte. The temporary file is
|
|
27
|
+
* created exclusively next to the target and removed if anything fails.
|
|
28
|
+
*/
|
|
29
|
+
export const writePrivately = (file, content) => Effect.gen(function* () {
|
|
30
|
+
yield* ensureNotSymlink(file);
|
|
31
|
+
const temporary = `${file}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
|
|
32
|
+
yield* Effect.tryPromise({
|
|
33
|
+
try: async () => {
|
|
34
|
+
try {
|
|
35
|
+
await NodeFs.writeFile(temporary, content, { flag: 'wx', mode: 0o600 });
|
|
36
|
+
await NodeFs.chmod(temporary, 0o600);
|
|
37
|
+
await NodeFs.rename(temporary, file);
|
|
38
|
+
}
|
|
39
|
+
catch (cause) {
|
|
40
|
+
await NodeFs.rm(temporary, { force: true }).catch(() => undefined);
|
|
41
|
+
throw cause;
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
catch: () => new PrivateFileError({ reason: 'io', message: `private atomic write failed: ${file}` })
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
/** Reads `file`, refusing symbolic links; a missing file reads as `undefined`. */
|
|
48
|
+
export const readPrivately = (file) => Effect.gen(function* () {
|
|
49
|
+
yield* ensureNotSymlink(file);
|
|
50
|
+
return yield* Effect.tryPromise({
|
|
51
|
+
try: async () => {
|
|
52
|
+
try {
|
|
53
|
+
return await NodeFs.readFile(file, 'utf8');
|
|
54
|
+
}
|
|
55
|
+
catch (cause) {
|
|
56
|
+
if (isMissing(cause))
|
|
57
|
+
return undefined;
|
|
58
|
+
throw cause;
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
catch: () => new PrivateFileError({ reason: 'io', message: `cannot read ${file}` })
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { Context, Data, Effect, Layer, Stream } from 'effect';
|
|
2
|
+
import { ChildProcess, ChildProcessSpawner } from 'effect/process';
|
|
3
|
+
export class ProcessError extends Data.TaggedError('ProcessError') {
|
|
4
|
+
get message() {
|
|
5
|
+
return this.exitCode === undefined
|
|
6
|
+
? `${this.command}: could not be started`
|
|
7
|
+
: `${this.command}: exited with ${this.exitCode}`;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export class ProcessRunner extends Context.Service()('provision/ProcessRunner') {
|
|
11
|
+
}
|
|
12
|
+
const notStarted = (command) => () => new ProcessError({ command, exitCode: undefined });
|
|
13
|
+
export const makeProcessRunner = Effect.gen(function* () {
|
|
14
|
+
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
|
|
15
|
+
return ProcessRunner.of({
|
|
16
|
+
capture: (command, args, options = {}) => Effect.scoped(Effect.gen(function* () {
|
|
17
|
+
const handle = yield* spawner.spawn(ChildProcess.make(command, args, {
|
|
18
|
+
cwd: options.cwd,
|
|
19
|
+
...(options.env === undefined ? {} : { env: options.env, extendEnv: true }),
|
|
20
|
+
stdin: options.interactive === true
|
|
21
|
+
? 'inherit'
|
|
22
|
+
: options.input === undefined
|
|
23
|
+
? 'ignore'
|
|
24
|
+
: Stream.make(new TextEncoder().encode(options.input)),
|
|
25
|
+
stdout: 'pipe',
|
|
26
|
+
stderr: options.interactive === true ? 'inherit' : 'pipe'
|
|
27
|
+
}));
|
|
28
|
+
const [stdout, stderr, exitCode] = yield* Effect.all([
|
|
29
|
+
Stream.mkString(Stream.decodeText(handle.stdout)),
|
|
30
|
+
options.interactive === true
|
|
31
|
+
? Effect.succeed('')
|
|
32
|
+
: Stream.mkString(Stream.decodeText(handle.stderr)),
|
|
33
|
+
handle.exitCode
|
|
34
|
+
], { concurrency: 3 });
|
|
35
|
+
return { exitCode, stdout, stderr };
|
|
36
|
+
})).pipe(Effect.mapError(notStarted(command))),
|
|
37
|
+
inherit: (command, args, options = {}) => Effect.scoped(Effect.gen(function* () {
|
|
38
|
+
const handle = yield* spawner.spawn(ChildProcess.make(command, args, {
|
|
39
|
+
cwd: options.cwd,
|
|
40
|
+
...(options.env === undefined ? {} : { env: options.env, extendEnv: true }),
|
|
41
|
+
stdin: 'inherit',
|
|
42
|
+
stdout: 'inherit',
|
|
43
|
+
stderr: 'inherit'
|
|
44
|
+
}));
|
|
45
|
+
return yield* handle.exitCode;
|
|
46
|
+
})).pipe(Effect.mapError(notStarted(command)), Effect.flatMap(exitCode => exitCode === 0 ? Effect.void : Effect.fail(new ProcessError({ command, exitCode }))))
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
export const ProcessRunnerLive = Layer.effect(ProcessRunner, makeProcessRunner);
|
|
50
|
+
export const capture = (command, args, options) => Effect.gen(function* () {
|
|
51
|
+
return yield* (yield* ProcessRunner).capture(command, args, options);
|
|
52
|
+
});
|
|
53
|
+
export const inherit = (command, args, options) => Effect.gen(function* () {
|
|
54
|
+
return yield* (yield* ProcessRunner).inherit(command, args, options);
|
|
55
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { posix } from 'node:path';
|
|
2
|
+
import { Data, Effect, FileSystem, Path, Predicate, Schema } from 'effect';
|
|
3
|
+
/** Factory steps in execution order. `--only` and `provision.factory.steps` select a subset. */
|
|
4
|
+
export const factorySteps = [
|
|
5
|
+
'vercel',
|
|
6
|
+
'neon',
|
|
7
|
+
'r2',
|
|
8
|
+
'upstash',
|
|
9
|
+
'resend',
|
|
10
|
+
'reports',
|
|
11
|
+
'secrets',
|
|
12
|
+
'domain'
|
|
13
|
+
];
|
|
14
|
+
/**
|
|
15
|
+
* How the Neon development project provides the parent branch for disposable databases:
|
|
16
|
+
* - `empty-baseline`: the factory owns the `<repo>-dev` project and its protected, empty default
|
|
17
|
+
* `baseline` branch; `setup` migrates. `provision db` attests this before every mutation;
|
|
18
|
+
* - `existing-branch`: the repository maintains its own development data and branch.
|
|
19
|
+
*
|
|
20
|
+
* Undeclared (`undefined`): `provision db` skips the attestation; `provision factory` treats it as
|
|
21
|
+
* `empty-baseline`.
|
|
22
|
+
*/
|
|
23
|
+
export const sandboxParentModes = ['empty-baseline', 'existing-branch'];
|
|
24
|
+
export class RepoConfigError extends Data.TaggedError('RepoConfigError') {
|
|
25
|
+
}
|
|
26
|
+
const SetupCommand = Schema.Trimmed.check(Schema.isNonEmpty());
|
|
27
|
+
const ProvisionInput = Schema.Struct({
|
|
28
|
+
appDir: Schema.optionalKey(Schema.NonEmptyString),
|
|
29
|
+
setup: Schema.optionalKey(Schema.Array(SetupCommand)),
|
|
30
|
+
factory: Schema.optionalKey(Schema.Struct({
|
|
31
|
+
steps: Schema.optionalKey(Schema.NonEmptyArray(Schema.Literals(factorySteps)).check(Schema.isUnique())),
|
|
32
|
+
neon: Schema.optionalKey(Schema.Struct({
|
|
33
|
+
sandboxParent: Schema.optionalKey(Schema.Literals(sandboxParentModes))
|
|
34
|
+
}))
|
|
35
|
+
}))
|
|
36
|
+
});
|
|
37
|
+
const PackageJson = Schema.Struct({
|
|
38
|
+
provision: Schema.optionalKey(Schema.Unknown),
|
|
39
|
+
provisionEnv: Schema.optionalKey(Schema.Unknown),
|
|
40
|
+
worktree: Schema.optionalKey(Schema.Unknown)
|
|
41
|
+
});
|
|
42
|
+
const decodePackageJson = Schema.decodeUnknownEffect(Schema.fromJsonString(PackageJson));
|
|
43
|
+
const decodeProvision = Schema.decodeUnknownEffect(ProvisionInput, {
|
|
44
|
+
onExcessProperty: 'error',
|
|
45
|
+
errors: 'all'
|
|
46
|
+
});
|
|
47
|
+
const decodeLegacy = Schema.decodeUnknownEffect(Schema.Struct({
|
|
48
|
+
appDir: Schema.optionalKey(Schema.UndefinedOr(Schema.NonEmptyString)),
|
|
49
|
+
setup: Schema.optionalKey(Schema.UndefinedOr(Schema.Array(SetupCommand)))
|
|
50
|
+
}));
|
|
51
|
+
const configError = (message) => new RepoConfigError({ message });
|
|
52
|
+
export const normalizeAppDir = (appDir) => {
|
|
53
|
+
if (appDir.includes('\\') || posix.isAbsolute(appDir))
|
|
54
|
+
return undefined;
|
|
55
|
+
const normalized = posix.normalize(appDir).replace(/\/+$/, '');
|
|
56
|
+
if (normalized === '' || normalized === '..' || normalized.startsWith('../'))
|
|
57
|
+
return undefined;
|
|
58
|
+
return normalized;
|
|
59
|
+
};
|
|
60
|
+
export const defaultRepoConfig = {
|
|
61
|
+
appDir: '.',
|
|
62
|
+
setup: [],
|
|
63
|
+
factory: { steps: factorySteps, neon: { sandboxParent: undefined } }
|
|
64
|
+
};
|
|
65
|
+
/** Parses root `package.json` text into a resolved {@link RepoConfig}. */
|
|
66
|
+
export const parseRepoConfig = (packageJsonText) => Effect.gen(function* () {
|
|
67
|
+
const packageJson = yield* decodePackageJson(packageJsonText).pipe(Effect.mapError(cause => configError(`invalid package.json: ${cause.message}`)));
|
|
68
|
+
// Transition: legacy keys still used by the `worktree` CLI are read as fallbacks.
|
|
69
|
+
const legacy = yield* decodeLegacy({
|
|
70
|
+
appDir: Predicate.hasProperty(packageJson.provisionEnv, 'appDir')
|
|
71
|
+
? packageJson.provisionEnv.appDir
|
|
72
|
+
: undefined,
|
|
73
|
+
setup: Predicate.hasProperty(packageJson.worktree, 'setup')
|
|
74
|
+
? packageJson.worktree.setup
|
|
75
|
+
: undefined
|
|
76
|
+
}).pipe(Effect.mapError(cause => configError(`invalid package.json "provisionEnv"/"worktree.setup": ${cause.message}`)));
|
|
77
|
+
const input = yield* decodeProvision(packageJson.provision ?? {}).pipe(Effect.mapError(cause => configError(`invalid package.json "provision": ${cause.message}`)));
|
|
78
|
+
const appDir = normalizeAppDir(input.appDir ?? legacy.appDir ?? '.');
|
|
79
|
+
if (appDir === undefined) {
|
|
80
|
+
return yield* configError('package.json "provision.appDir" must be a relative path inside the checkout');
|
|
81
|
+
}
|
|
82
|
+
const selected = input.factory?.steps;
|
|
83
|
+
return {
|
|
84
|
+
appDir,
|
|
85
|
+
setup: input.setup ?? legacy.setup ?? [],
|
|
86
|
+
factory: {
|
|
87
|
+
steps: selected === undefined
|
|
88
|
+
? factorySteps
|
|
89
|
+
: factorySteps.filter(step => selected.includes(step)),
|
|
90
|
+
neon: { sandboxParent: input.factory?.neon?.sandboxParent }
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
});
|
|
94
|
+
/** Reads the checkout root's `package.json`; a checkout without one uses the defaults. */
|
|
95
|
+
export const readRepoConfig = (checkoutRoot) => Effect.gen(function* () {
|
|
96
|
+
const fs = yield* FileSystem.FileSystem;
|
|
97
|
+
const path = yield* Path.Path;
|
|
98
|
+
const file = path.join(checkoutRoot, 'package.json');
|
|
99
|
+
const exists = yield* fs.exists(file);
|
|
100
|
+
if (!exists)
|
|
101
|
+
return defaultRepoConfig;
|
|
102
|
+
const text = yield* fs.readFileString(file);
|
|
103
|
+
return yield* parseRepoConfig(text);
|
|
104
|
+
}).pipe(Effect.catchTag('PlatformError', cause => Effect.fail(configError(`cannot read package.json: ${cause.message}`))));
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sandbox database profile: written to the Vercel Development environment by
|
|
3
|
+
* `provision factory`, pulled into `.env.local` by `provision env`, and read by `provision db`.
|
|
4
|
+
* All three keys are required together.
|
|
5
|
+
*/
|
|
6
|
+
export const sandboxProfileKeys = [
|
|
7
|
+
'SANDBOX_DB_NEON_API_KEY',
|
|
8
|
+
'SANDBOX_DB_NEON_PROJECT_ID',
|
|
9
|
+
'SANDBOX_DB_PARENT_BRANCH_ID'
|
|
10
|
+
];
|
|
11
|
+
/** Env keys `provision db create` writes by default. */
|
|
12
|
+
export const defaultDatabaseUrlKeys = ['DATABASE_URL', 'DATABASE_URL_UNPOOLED'];
|
package/package.json
CHANGED
|
@@ -1,6 +1,47 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@magoz/provision",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Provision projects (factory), local checkouts (env), and disposable databases (db).",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/magoz/provision.git"
|
|
9
|
+
},
|
|
10
|
+
"bin": {
|
|
11
|
+
"provision": "dist/cli.js"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist",
|
|
15
|
+
"skills"
|
|
16
|
+
],
|
|
17
|
+
"type": "module",
|
|
18
|
+
"scripts": {
|
|
19
|
+
"dev": "node src/cli.ts",
|
|
20
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
21
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
22
|
+
"lint": "oxlint --max-warnings 0",
|
|
23
|
+
"lint:fix": "oxlint --fix",
|
|
24
|
+
"format:check": "oxfmt --check .",
|
|
25
|
+
"format:fix": "oxfmt --write .",
|
|
26
|
+
"test": "vitest run",
|
|
27
|
+
"verify": "pnpm format:check && pnpm typecheck && pnpm lint && pnpm test && pnpm build",
|
|
28
|
+
"prepublishOnly": "pnpm verify"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@effect/platform-node": "4.0.2",
|
|
32
|
+
"@effect/platform-node-shared": "4.0.2",
|
|
33
|
+
"effect": "4.0.2"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@effect/vitest": "4.0.2",
|
|
37
|
+
"@types/node": "24.19.1",
|
|
38
|
+
"oxfmt": "0.72.0",
|
|
39
|
+
"oxlint": "1.87.0",
|
|
40
|
+
"typescript": "7.0.2",
|
|
41
|
+
"vitest": "5.0.3"
|
|
42
|
+
},
|
|
43
|
+
"engines": {
|
|
44
|
+
"node": ">=24"
|
|
45
|
+
},
|
|
46
|
+
"packageManager": "pnpm@12.10.1"
|
|
47
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: provision
|
|
3
|
+
description: Use the provision CLI to make a checkout or worktree runnable (dependencies, Vercel link, env files, setup commands), manage disposable Neon databases for local checkouts, and prepare provision factory runs that create a project's provider resources. Use when a checkout needs env files or databases, when a repository's package.json has a "provision" key, or when the user mentions provision, factories, sandbox databases, or provisioning a new project.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# provision
|
|
7
|
+
|
|
8
|
+
`provision` has three modes:
|
|
9
|
+
|
|
10
|
+
- `provision env`: make a checkout runnable.
|
|
11
|
+
- `provision db`: manage disposable Neon database branches for local checkouts.
|
|
12
|
+
- `provision factory`: create a project's provider resources and write scoped keys to Vercel.
|
|
13
|
+
|
|
14
|
+
Run `provision <mode> --help` for every flag. The repository's root `package.json` `provision` key
|
|
15
|
+
configures it (`appDir`, `setup`, `factory.steps`).
|
|
16
|
+
|
|
17
|
+
## What you may run, and what only the human may run
|
|
18
|
+
|
|
19
|
+
| Command | Who |
|
|
20
|
+
| ----------------------------------------------------------------- | -------------------------------- |
|
|
21
|
+
| `provision env …`, `provision db …`, `provision contract` | you |
|
|
22
|
+
| `provision config list`, `provision config op-token status` | you |
|
|
23
|
+
| `provision factory …`, `provision config add/verify/op-token set` | **the human, in their terminal** |
|
|
24
|
+
|
|
25
|
+
Commands that read a factory's 1Password item need the operator's **provision passphrase**, typed
|
|
26
|
+
on the terminal. That passphrase is the human's approval for using admin keys.
|
|
27
|
+
|
|
28
|
+
When you run one of them, `provision` detects the agent, changes nothing, and exits **4** with one
|
|
29
|
+
JSON object on stderr:
|
|
30
|
+
|
|
31
|
+
```json
|
|
32
|
+
{
|
|
33
|
+
"status": "approval_required",
|
|
34
|
+
"reason": "…",
|
|
35
|
+
"hint": "…",
|
|
36
|
+
"next": [
|
|
37
|
+
"cd /home/acme/src/acme-app && /usr/local/bin/provision factory --domain app.acme.com --dry-run"
|
|
38
|
+
]
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Give the human the `next` command verbatim, say in one or two sentences what it will do, and wait
|
|
43
|
+
for them to paste the output. You may also skip the attempt and write the command yourself; use
|
|
44
|
+
`/usr/local/bin/provision` when it exists, since a `provision` earlier on `PATH` could be a
|
|
45
|
+
different binary.
|
|
46
|
+
|
|
47
|
+
Agents are also detected for `provision env`, which then never prompts (as with
|
|
48
|
+
`--non-interactive`).
|
|
49
|
+
|
|
50
|
+
## Make a checkout or worktree runnable
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
provision env --repo <checkout> --database --non-interactive
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
This installs dependencies, links the Vercel project, pulls Development into `.env.local` and the
|
|
57
|
+
`test` environment into `.env.test`, creates two disposable databases (`default` and `test`
|
|
58
|
+
leases), and runs `package.json` `provision.setup` (usually migrations). Without `--database` the
|
|
59
|
+
env files keep whatever database Vercel provides; prefer `--database` for anything that writes data.
|
|
60
|
+
|
|
61
|
+
- **Exit 3** (`{"status":"vercel_link_required",…}` on stderr): the app is not linked and the
|
|
62
|
+
project is ambiguous. Rerun with `--source <already-linked checkout>` or
|
|
63
|
+
`--vercel-project <name>`; ask the human which project if you cannot tell.
|
|
64
|
+
- **Setup failed**: env files and databases are kept. Fix the cause, then rerun the same command;
|
|
65
|
+
it resumes. `--skip-setup` skips setup on purpose.
|
|
66
|
+
- **Existing env files**: refreshed from Vercel by default. `--env-conflict preserve` keeps them.
|
|
67
|
+
- Never print `.env*` files or connection strings; they hold secrets. Check for a key with
|
|
68
|
+
`grep -c '^KEY=' .env.local` instead of reading values.
|
|
69
|
+
|
|
70
|
+
## Add or change an environment variable
|
|
71
|
+
|
|
72
|
+
Vercel is the source of truth. `provision env` rewrites `.env.local` and `.env.test` from Vercel on
|
|
73
|
+
every run, so a value added only to a local file is lost. Add it to the Vercel project, then pull:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
cd <checkout> # linked to the project (.vercel/project.json)
|
|
77
|
+
vercel env add <KEY> development # local development only
|
|
78
|
+
vercel env add <KEY> preview # repeat per environment as needed
|
|
79
|
+
vercel env add <KEY> test # the custom e2e environment (.env.test)
|
|
80
|
+
vercel env add <KEY> production --sensitive # production secrets are Sensitive
|
|
81
|
+
provision env --repo <checkout> --skip-install --skip-setup # refresh the env files
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Follow the project's convention: pre-production values (Development, Preview, `test`) and
|
|
85
|
+
production values are separate, and production secrets are Sensitive. Development cannot hold
|
|
86
|
+
Sensitive values.
|
|
87
|
+
|
|
88
|
+
- **Secrets** (API keys, tokens): don't handle the value. Give the human the `vercel env add`
|
|
89
|
+
command; it prompts for the value. Never pass a secret with `--value` or put it in a file,
|
|
90
|
+
commit, or the chat.
|
|
91
|
+
- **Non-secret config** (URLs, feature flags, thresholds): you may run
|
|
92
|
+
`vercel env add <KEY> <environment> --value <value> --yes` yourself.
|
|
93
|
+
- **Variables `provision factory` manages** (`DATABASE_URL*`, `SANDBOX_DB_*`, `R2_*`, `QSTASH_*`,
|
|
94
|
+
`RESEND_API_KEY`, `REPORT_RECEIVER_*`, `BETTER_AUTH_SECRET`, `INTEGRATION_CREDENTIAL_ENCRYPTION_KEY`,
|
|
95
|
+
`CRON_SECRET`, `VAPID_*`, `AUTH_EMAIL_FROM`, `AI_PROVIDER_USAGE_ALERT_THRESHOLDS`,
|
|
96
|
+
`ALLOW_E2E_DATABASE_RESET`): don't edit them by hand. Have the human rerun the step, e.g.
|
|
97
|
+
`--only resend --on-existing overwrite` to rotate a key.
|
|
98
|
+
- Deployments only see variables added before they were built; redeploy to pick up a change.
|
|
99
|
+
|
|
100
|
+
## Disposable databases
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
provision db status --worktree <checkout> --lease default --json # exit 1: no lease
|
|
104
|
+
provision db renew --worktree <checkout> --lease default # extend TTL (max 7d)
|
|
105
|
+
provision db create --worktree <checkout> --lease default # create, or reuse a live lease
|
|
106
|
+
provision db create --worktree <checkout> --lease test \
|
|
107
|
+
--env-file .env.test --config-env-file .env.local # test lease writes .env.test
|
|
108
|
+
provision db release --worktree <checkout> --lease default # delete branch, drop env keys
|
|
109
|
+
provision db release --worktree <checkout> --lease test
|
|
110
|
+
provision db list --json
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Release both leases before deleting a worktree. Branches expire on their own after their TTL;
|
|
114
|
+
`provision db gc --dry-run` lists stale ones. A database whose TTL ran out is gone: rerun
|
|
115
|
+
`provision env --repo <checkout> --database`, which recreates both leases and reruns setup.
|
|
116
|
+
|
|
117
|
+
## Provision a new project (human runs it)
|
|
118
|
+
|
|
119
|
+
Prerequisites: the GitHub repository exists under the factory's GitHub owner, there is a local
|
|
120
|
+
checkout with that `origin`, and the factory is registered (`provision config list`).
|
|
121
|
+
|
|
122
|
+
Prepare these for the human, in order:
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
# 1. Plan: reads providers, changes nothing
|
|
126
|
+
/usr/local/bin/provision factory --checkout <checkout> --domain <production-host> --dry-run
|
|
127
|
+
|
|
128
|
+
# 2. Apply
|
|
129
|
+
/usr/local/bin/provision factory --checkout <checkout> --domain <production-host> \
|
|
130
|
+
--sender-name <App name> --email-from noreply@<verified-domain>
|
|
131
|
+
|
|
132
|
+
# 3. Confirm idempotence: should print only "reusing" lines
|
|
133
|
+
/usr/local/bin/provision factory --checkout <checkout> --domain <production-host> \
|
|
134
|
+
--sender-name <App name> --email-from noreply@<verified-domain> --on-existing reuse
|
|
135
|
+
|
|
136
|
+
# 4. Local checkout (you can run this one)
|
|
137
|
+
provision env --repo <checkout> --database
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Reading the output: each step prints one block. `would …` is a dry-run plan, `exists:` /
|
|
141
|
+
`reusing …` means nothing changed, `skipped:` means the factory lacks that provider, and
|
|
142
|
+
`manual: …` is a follow-up the human must do by hand. Relay `manual:` lines to the human.
|
|
143
|
+
|
|
144
|
+
QStash credentials are pasted by the human when `provision factory` asks for them (hidden input,
|
|
145
|
+
written straight to Vercel). If they skipped it, give them
|
|
146
|
+
`/usr/local/bin/provision factory --checkout <checkout> --domain <host> --only upstash --on-existing reuse`.
|
|
147
|
+
Never ask the human to paste QStash values, or any other credential, into the chat.
|
|
148
|
+
|
|
149
|
+
`--on-existing`: `ask` (default, prompts), `reuse` (change nothing that exists), `overwrite`
|
|
150
|
+
(rotate keys and regenerate secrets: invalidates sessions and encrypted data; only on the human's
|
|
151
|
+
explicit request), `abort`. Data (databases, buckets, teams) is never deleted or replaced.
|
|
152
|
+
`--only <steps>` reruns a subset, e.g. `--only reports` after the factory gains a report receiver.
|
|
153
|
+
|
|
154
|
+
## Never
|
|
155
|
+
|
|
156
|
+
- Run a passphrase command in a way that gets around the approval: piping input, `script`,
|
|
157
|
+
`expect`, `tmux send-keys`, a pseudo-terminal, or unsetting agent variables such as `AI_AGENT`.
|
|
158
|
+
- Ask the human for the passphrase, a 1Password token, or an admin key; set
|
|
159
|
+
`OP_SERVICE_ACCOUNT_TOKEN`; run `op`; or read `~/.config/provision/*.cred`.
|
|
160
|
+
- Print `.env*` files, connection strings, or keys, or copy them into chat, commits, or logs.
|
|
161
|
+
- Pass `--on-existing overwrite` unless the human asked to rotate: it invalidates sessions and
|
|
162
|
+
encrypted data.
|
|
163
|
+
- Delete provider resources (Neon projects, R2 buckets, Upstash teams, Vercel projects) to "start
|
|
164
|
+
over"; `provision` reuses what exists.
|
|
165
|
+
- Delete a worktree before `provision db release` for both leases.
|
|
166
|
+
- Edit `~/.config/provision/` or the lease files under `~/.local/state/pi/sandbox-db/` by hand.
|
|
167
|
+
|
|
168
|
+
## Exit codes
|
|
169
|
+
|
|
170
|
+
`0` success; `1` negative result (for example no lease) or usage error; `2` failure, explained in
|
|
171
|
+
one `provision <mode>: …` line on stderr; `3` `env` needs a Vercel link (JSON on stderr); `4` a
|
|
172
|
+
human must run this command (JSON with `next` on stderr).
|