@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.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +498 -2
  3. package/dist/cli.js +29 -0
  4. package/dist/contract.js +13 -0
  5. package/dist/db/branch-lifecycle.js +23 -0
  6. package/dist/db/commands.js +303 -0
  7. package/dist/db/config.js +75 -0
  8. package/dist/db/credentials.js +177 -0
  9. package/dist/db/domain.js +106 -0
  10. package/dist/db/environment.js +14 -0
  11. package/dist/db/lease.js +132 -0
  12. package/dist/db/neon.js +350 -0
  13. package/dist/db/operations.js +246 -0
  14. package/dist/db/policy.js +59 -0
  15. package/dist/env/commands.js +39 -0
  16. package/dist/env/domain.js +14 -0
  17. package/dist/env/install.js +25 -0
  18. package/dist/env/paths.js +105 -0
  19. package/dist/env/provision.js +320 -0
  20. package/dist/env/sanitize.js +88 -0
  21. package/dist/env/vercel.js +180 -0
  22. package/dist/factory/approval.js +33 -0
  23. package/dist/factory/commands.js +173 -0
  24. package/dist/factory/config-commands.js +215 -0
  25. package/dist/factory/context.js +129 -0
  26. package/dist/factory/http.js +37 -0
  27. package/dist/factory/onboarding.js +155 -0
  28. package/dist/factory/onepassword.js +140 -0
  29. package/dist/factory/op-credential.js +154 -0
  30. package/dist/factory/passphrase.js +145 -0
  31. package/dist/factory/providers/cloudflare.js +126 -0
  32. package/dist/factory/providers/neon.js +81 -0
  33. package/dist/factory/providers/report-receiver.js +37 -0
  34. package/dist/factory/providers/resend.js +32 -0
  35. package/dist/factory/providers/upstash.js +21 -0
  36. package/dist/factory/registry.js +50 -0
  37. package/dist/factory/scoped-key.js +30 -0
  38. package/dist/factory/sealed.js +64 -0
  39. package/dist/factory/secret-input.js +43 -0
  40. package/dist/factory/steps/domain.js +23 -0
  41. package/dist/factory/steps/neon.js +101 -0
  42. package/dist/factory/steps/r2.js +70 -0
  43. package/dist/factory/steps/reports.js +44 -0
  44. package/dist/factory/steps/resend.js +37 -0
  45. package/dist/factory/steps/secrets.js +104 -0
  46. package/dist/factory/steps/upstash.js +92 -0
  47. package/dist/factory/steps/vercel.js +68 -0
  48. package/dist/factory/vercel-api.js +165 -0
  49. package/dist/package-info.js +15 -0
  50. package/dist/shared/agent.js +28 -0
  51. package/dist/shared/env-file.js +54 -0
  52. package/dist/shared/git.js +48 -0
  53. package/dist/shared/output.js +10 -0
  54. package/dist/shared/private-file.js +63 -0
  55. package/dist/shared/process.js +55 -0
  56. package/dist/shared/repo-config.js +104 -0
  57. package/dist/shared/sandbox-profile.js +12 -0
  58. package/package.json +44 -4
  59. 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,46 @@
1
1
  {
2
2
  "name": "@magoz/provision",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
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": "4.0.2"
33
+ },
34
+ "devDependencies": {
35
+ "@effect/vitest": "4.0.2",
36
+ "@types/node": "24.19.1",
37
+ "oxfmt": "0.72.0",
38
+ "oxlint": "1.87.0",
39
+ "typescript": "7.0.2",
40
+ "vitest": "5.0.3"
41
+ },
42
+ "engines": {
43
+ "node": ">=24"
44
+ },
45
+ "packageManager": "pnpm@12.10.1"
46
+ }
@@ -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).