@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.
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 +45 -4
  59. package/skills/provision/SKILL.md +172 -0
@@ -0,0 +1,246 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { Console, Effect, FileSystem, Option, Path } from 'effect';
3
+ import { envKeyPattern, removeEnvKeys, writeEnvKeys } from '../shared/env-file.js';
4
+ import { ensureIgnored, resolveWorkspace } from '../shared/git.js';
5
+ import { readPrivately, writePrivately } from '../shared/private-file.js';
6
+ import { readRepoConfig } from '../shared/repo-config.js';
7
+ import { sandboxProfileKeys } from '../shared/sandbox-profile.js';
8
+ import { withBranchLifecycleUsing } from './branch-lifecycle.js';
9
+ import { loadConfig } from './config.js';
10
+ import { BRANCH_PREFIX, LifecycleError, NeonError, PolicyError } from './domain.js';
11
+ import { readLease, removeLease, withLeaseLock, writeLease } from './lease.js';
12
+ import { attestSandboxProfile, createBranch, deleteVerifiedBranch, getBranch, getConnectionUris, setExpiration, waitUntilReady } from './neon.js';
13
+ import { ensureLeaseProfile, parseTtl, validateDeletionTarget, validateLeaseName } from './policy.js';
14
+ export const timestamp = (offsetSeconds = 0) => new Date(Date.now() + offsetSeconds * 1000).toISOString().replace(/\.\d{3}Z$/, 'Z');
15
+ const slugify = (value) => value
16
+ .toLowerCase()
17
+ .replace(/[^a-z0-9]+/g, '-')
18
+ .replace(/^-|-$/g, '')
19
+ .slice(0, 60) || 'worktree';
20
+ const reservedKeys = new Set(sandboxProfileKeys);
21
+ const urlFor = (key, connection) => key.toUpperCase().endsWith('UNPOOLED') ? connection.direct : connection.pooled;
22
+ /**
23
+ * Repositories that declare `provision.factory.neon.sandboxParent: "empty-baseline"` get their
24
+ * profile attested against the factory-created project and baseline before any mutation.
25
+ */
26
+ export const attestIfDeclared = (config, workspace) => Effect.gen(function* () {
27
+ const repo = yield* readRepoConfig(workspace.root);
28
+ if (repo.factory.neon.sandboxParent === 'empty-baseline') {
29
+ yield* attestSandboxProfile(config, workspace.repository);
30
+ }
31
+ });
32
+ export const parseEnvKeys = (keys) => Effect.gen(function* () {
33
+ const envKeys = keys
34
+ .split(',')
35
+ .map(key => key.trim())
36
+ .filter(key => key.length > 0);
37
+ if (envKeys.length === 0) {
38
+ return yield* new PolicyError({ message: '--keys must name at least one variable' });
39
+ }
40
+ const invalid = envKeys.filter(key => !envKeyPattern.test(key));
41
+ if (invalid.length > 0) {
42
+ return yield* new PolicyError({
43
+ message: `--keys contains invalid environment names: ${invalid.join(', ')}`
44
+ });
45
+ }
46
+ const duplicates = envKeys.filter((key, index) => envKeys.indexOf(key) !== index);
47
+ if (duplicates.length > 0) {
48
+ return yield* new PolicyError({
49
+ message: `--keys contains duplicates: ${[...new Set(duplicates)].join(', ')}`
50
+ });
51
+ }
52
+ const reserved = envKeys.filter(key => reservedKeys.has(key));
53
+ if (reserved.length > 0) {
54
+ return yield* new PolicyError({
55
+ message: `--keys cannot overwrite sandbox configuration: ${reserved.join(', ')}`
56
+ });
57
+ }
58
+ return envKeys;
59
+ });
60
+ /** Refreshes a compatible live lease in place: TTL extended, connection URLs restored. */
61
+ const reuseLiveLease = (config, existing, ttlSeconds) => Effect.gen(function* () {
62
+ const live = yield* getBranch(config, existing.branchId);
63
+ if (Option.isNone(live))
64
+ return Option.none();
65
+ yield* validateDeletionTarget(existing, live.value);
66
+ const { databaseName, roleName } = existing;
67
+ const connection = yield* getConnectionUris(config, existing.branchId, databaseName !== undefined && roleName !== undefined ? { databaseName, roleName } : undefined);
68
+ const expiresAt = timestamp(ttlSeconds);
69
+ yield* setExpiration(config, existing, expiresAt);
70
+ yield* writeEnvKeys(existing.envFile, existing.envKeys.map(key => [key, urlFor(key, connection)]));
71
+ const lease = {
72
+ ...existing,
73
+ databaseName: connection.databaseName,
74
+ roleName: connection.roleName,
75
+ expiresAt
76
+ };
77
+ yield* writeLease(lease);
78
+ return Option.some({
79
+ status: 'reused',
80
+ lease,
81
+ report: {
82
+ status: 'reused',
83
+ lease: lease.leaseName,
84
+ branch_name: lease.branchName,
85
+ branch_id: lease.branchId,
86
+ expires_at: expiresAt,
87
+ config_env_file: lease.configEnvFile,
88
+ env_file: lease.envFile,
89
+ note: 'connection URLs were restored and the existing live lease TTL was refreshed'
90
+ }
91
+ });
92
+ });
93
+ /** Creates (or reuses) the lease for one worktree slot and writes its connection URLs. */
94
+ export const createLease = (options) => Effect.gen(function* () {
95
+ const fs = yield* FileSystem.FileSystem;
96
+ const path = yield* Path.Path;
97
+ const workspace = yield* resolveWorkspace(options.worktree);
98
+ const leaseName = yield* validateLeaseName(options.leaseName);
99
+ return yield* withLeaseLock(workspace.root, leaseName, Effect.gen(function* () {
100
+ const envPath = path.resolve(workspace.root, options.envFile);
101
+ const configEnvPath = path.resolve(workspace.root, Option.getOrElse(options.configEnvFile, () => options.envFile));
102
+ const config = yield* loadConfig(configEnvPath);
103
+ yield* attestIfDeclared(config, workspace);
104
+ const envKeys = yield* parseEnvKeys(options.keys);
105
+ yield* ensureIgnored(workspace.root, envPath);
106
+ const ttlSeconds = yield* parseTtl(options.ttl);
107
+ const existing = yield* readLease(workspace.root, leaseName);
108
+ if (Option.isSome(existing) && !options.forceNew) {
109
+ const lease = existing.value;
110
+ const compatible = lease.projectId === config.projectId &&
111
+ lease.parentBranch === config.parentBranch &&
112
+ lease.envFile === envPath &&
113
+ lease.configEnvFile === configEnvPath &&
114
+ lease.envKeys.length === envKeys.length &&
115
+ lease.envKeys.every((key, index) => key === envKeys[index]) &&
116
+ (lease.databaseName === undefined) === (lease.roleName === undefined);
117
+ if (compatible) {
118
+ const reused = yield* reuseLiveLease(config, lease, ttlSeconds);
119
+ if (Option.isSome(reused))
120
+ return reused.value;
121
+ }
122
+ else {
123
+ yield* Console.error(`provision db: ignoring stale ${leaseName} lease record; its branch will expire automatically`);
124
+ }
125
+ }
126
+ const label = slugify(Option.getOrElse(options.label, () => path.basename(workspace.root)));
127
+ const repositoryName = slugify(workspace.repository.split('/').pop() ?? workspace.repository);
128
+ const branchName = `${BRANCH_PREFIX}${repositoryName}-${label}-${randomBytes(3).toString('hex')}`;
129
+ const expiresAt = timestamp(ttlSeconds);
130
+ const envExisted = yield* fs.exists(envPath).pipe(Effect.orElseSucceed(() => false));
131
+ const previousEnv = envExisted
132
+ ? yield* readPrivately(envPath).pipe(Effect.mapError(() => new PolicyError({ message: `cannot snapshot ${envPath}` })))
133
+ : undefined;
134
+ const leaseFor = (created) => ({
135
+ version: 2,
136
+ leaseName,
137
+ projectId: config.projectId,
138
+ parentBranch: config.parentBranch,
139
+ branchId: created.branch.id,
140
+ branchName: created.branch.name,
141
+ worktree: workspace.root,
142
+ repository: workspace.repository,
143
+ label,
144
+ configEnvFile: configEnvPath,
145
+ envFile: envPath,
146
+ envKeys,
147
+ databaseName: created.databaseName,
148
+ roleName: created.roleName,
149
+ createdAt: timestamp(),
150
+ expiresAt
151
+ });
152
+ const publish = (created) => Effect.gen(function* () {
153
+ const lease = leaseFor(created);
154
+ yield* writeEnvKeys(envPath, envKeys.map(key => [key, urlFor(key, created)]));
155
+ yield* writeLease(lease);
156
+ const state = options.noWait
157
+ ? 'not-checked'
158
+ : yield* waitUntilReady(config, created.branch.id);
159
+ if (!options.noWait && state !== 'ready') {
160
+ return yield* new NeonError({
161
+ message: 'new branch did not become ready before the bounded timeout'
162
+ });
163
+ }
164
+ return {
165
+ status: 'created',
166
+ lease,
167
+ report: {
168
+ status: 'created',
169
+ lease: lease.leaseName,
170
+ branch_name: lease.branchName,
171
+ branch_id: lease.branchId,
172
+ state,
173
+ expires_at: lease.expiresAt,
174
+ env_file: lease.envFile,
175
+ env_keys: envKeys.join(','),
176
+ config_source: config.backend,
177
+ config_env_file: lease.configEnvFile,
178
+ parent_branch: config.parentBranch,
179
+ note: 'connection string written to env file; not printed'
180
+ }
181
+ };
182
+ });
183
+ /** Undo everything this run created: branch, env changes, and lease record. */
184
+ const rollback = (created) => Effect.gen(function* () {
185
+ const lease = leaseFor(created);
186
+ let cleanupFailed = false;
187
+ const attempt = (cleanup) => cleanup.pipe(Effect.matchEffect({
188
+ onFailure: () => Effect.sync(() => {
189
+ cleanupFailed = true;
190
+ }),
191
+ onSuccess: () => Effect.void
192
+ }));
193
+ yield* attempt(deleteVerifiedBranch(config, lease, lease.expiresAt));
194
+ if (previousEnv === undefined)
195
+ yield* attempt(fs.remove(envPath));
196
+ else
197
+ yield* attempt(writePrivately(envPath, previousEnv));
198
+ const currentLease = yield* readLease(workspace.root, leaseName);
199
+ if (Option.isSome(currentLease)) {
200
+ if (currentLease.value.branchId === lease.branchId) {
201
+ yield* attempt(removeLease(workspace.root, leaseName));
202
+ if (Option.isSome(existing))
203
+ yield* attempt(writeLease(existing.value));
204
+ }
205
+ else if (Option.isNone(existing) ||
206
+ currentLease.value.branchId !== existing.value.branchId) {
207
+ cleanupFailed = true;
208
+ }
209
+ }
210
+ else if (Option.isSome(existing)) {
211
+ yield* attempt(writeLease(existing.value));
212
+ }
213
+ if (cleanupFailed) {
214
+ return yield* new LifecycleError({
215
+ message: 'new branch setup cleanup could not be fully verified; manual review is required'
216
+ });
217
+ }
218
+ });
219
+ return yield* withBranchLifecycleUsing(createBranch(config, { name: branchName, expiresAt }), publish, rollback);
220
+ }));
221
+ });
222
+ /** Deletes a lease's branch (after re-verifying its exact identity), env keys, and record. */
223
+ export const releaseLease = (options) => Effect.gen(function* () {
224
+ const workspace = yield* resolveWorkspace(options.worktree);
225
+ const name = yield* validateLeaseName(options.leaseName);
226
+ return yield* withLeaseLock(workspace.root, name, Effect.gen(function* () {
227
+ const lease = yield* readLease(workspace.root, name);
228
+ if (Option.isNone(lease)) {
229
+ return { status: 'none', lease: name, worktree: workspace.root };
230
+ }
231
+ const config = yield* loadConfig(lease.value.configEnvFile);
232
+ yield* ensureLeaseProfile(config, lease.value);
233
+ yield* attestIfDeclared(config, workspace);
234
+ const deleted = yield* deleteVerifiedBranch(config, lease.value);
235
+ if (!options.keepEnv)
236
+ yield* removeEnvKeys(lease.value.envFile, lease.value.envKeys);
237
+ yield* removeLease(workspace.root, lease.value.leaseName);
238
+ return {
239
+ status: deleted ? 'released' : 'already-gone',
240
+ lease: lease.value.leaseName,
241
+ branch_name: lease.value.branchName,
242
+ branch_id: lease.value.branchId,
243
+ env_keys_removed: options.keepEnv ? 'no' : 'yes'
244
+ };
245
+ }));
246
+ });
@@ -0,0 +1,59 @@
1
+ import { Effect } from 'effect';
2
+ import { BRANCH_PREFIX, MAX_TTL_SECONDS, PolicyError } from './domain.js';
3
+ const ttlUnits = { s: 1, m: 60, h: 3600, d: 86400 };
4
+ const isTtlUnit = (unit) => unit in ttlUnits;
5
+ export const parseTtl = (value) => Effect.gen(function* () {
6
+ const match = /^(\d+)([smhd])$/.exec(value.trim().toLowerCase());
7
+ const amount = match?.[1];
8
+ const unit = match?.[2];
9
+ if (amount === undefined || unit === undefined || !isTtlUnit(unit)) {
10
+ return yield* new PolicyError({
11
+ message: `invalid ttl ${value}; use forms like 12h, 3d, 90m`
12
+ });
13
+ }
14
+ const seconds = Number(amount) * ttlUnits[unit];
15
+ if (seconds <= 0)
16
+ return yield* new PolicyError({ message: 'ttl must be positive' });
17
+ if (seconds > MAX_TTL_SECONDS) {
18
+ return yield* new PolicyError({
19
+ message: `ttl exceeds policy maximum of ${MAX_TTL_SECONDS / 86400}d`
20
+ });
21
+ }
22
+ return seconds;
23
+ });
24
+ export const validateLeaseName = (leaseName) => /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(leaseName)
25
+ ? Effect.succeed(leaseName)
26
+ : Effect.fail(new PolicyError({
27
+ message: '--lease must be 1-64 letters, digits, dots, underscores, or hyphens'
28
+ }));
29
+ export const ensureLeaseProfile = (config, lease) => {
30
+ if (config.projectId !== lease.projectId) {
31
+ return Effect.fail(new PolicyError({
32
+ message: `lease belongs to Neon project ${lease.projectId}, but the resolved profile selects ${config.projectId}\n` +
33
+ 'restore the original worktree environment before managing this lease'
34
+ }));
35
+ }
36
+ if (config.parentBranch !== lease.parentBranch) {
37
+ return Effect.fail(new PolicyError({
38
+ message: `lease belongs to parent branch ${lease.parentBranch}, but the resolved profile selects ${config.parentBranch}\n` +
39
+ 'release the existing lease before changing its parent branch'
40
+ }));
41
+ }
42
+ return Effect.void;
43
+ };
44
+ /** The provider branch is exactly the recorded, non-default, unprotected agent branch. */
45
+ export const branchMatchesExactIdentity = (expected, branch, expiresAt) => expected.branchId.length > 0 &&
46
+ expected.branchId !== expected.parentBranch &&
47
+ expected.branchName.startsWith(BRANCH_PREFIX) &&
48
+ branch.id === expected.branchId &&
49
+ branch.name === expected.branchName &&
50
+ branch.parent_id === expected.parentBranch &&
51
+ branch.default === false &&
52
+ branch.protected === false &&
53
+ (expiresAt === undefined || branch.expires_at === expiresAt);
54
+ export const validateBranchIdentity = (expected, branch, expiresAt) => branchMatchesExactIdentity(expected, branch, expiresAt)
55
+ ? Effect.void
56
+ : Effect.fail(new PolicyError({
57
+ message: 'provider branch does not exactly match the recorded non-default, unprotected agent lease'
58
+ }));
59
+ export const validateDeletionTarget = (lease, branch) => validateBranchIdentity(lease, branch);
@@ -0,0 +1,39 @@
1
+ import { Console, Effect, Option } from 'effect';
2
+ import { Command, Flag } from 'effect/cli';
3
+ import { detectAgent } from '../shared/agent.js';
4
+ import { envConflictPolicies } from './domain.js';
5
+ import { provisionEnvironment } from './provision.js';
6
+ const optionalText = (name, description) => Flag.String(name).pipe(Flag.withDescription(description), Flag.optional);
7
+ const booleanFlag = (name, description) => Flag.Boolean(name).pipe(Flag.withDescription(description), Flag.withDefault(false));
8
+ const setExitCode = (code) => Effect.sync(() => {
9
+ process.exitCode = code;
10
+ });
11
+ export const envCommand = Command.make('env', {
12
+ repo: Flag.String('repo').pipe(Flag.withDescription('target Git checkout (default: current directory)'), Flag.withDefault('.')),
13
+ checkVercelLink: booleanFlag('check-vercel-link', 'check/reuse a local app link only; missing or ambiguous links emit JSON on stderr and exit 3'),
14
+ appDir: optionalText('app-dir', 'checkout-relative app directory (default: package.json provision.appDir or ".")'),
15
+ source: optionalText('source', "linked checkout to copy the selected app's .vercel/project.json from"),
16
+ vercelProject: optionalText('vercel-project', 'link explicitly to this Vercel project'),
17
+ database: booleanFlag('database', 'allocate independent development and test databases'),
18
+ testEnvironment: Flag.String('test-environment').pipe(Flag.withDescription('Vercel environment for .env.test (default: test)'), Flag.withDefault('test')),
19
+ label: optionalText('label', 'database label prefix (default: branch or directory name)'),
20
+ ttl: Flag.String('ttl').pipe(Flag.withDescription('database lifetime (default: 7d; maximum: 7d)'), Flag.withDefault('7d')),
21
+ skipInstall: booleanFlag('skip-install', 'do not install dependencies'),
22
+ skipVercel: booleanFlag('skip-vercel', 'do not pull either Vercel environment'),
23
+ skipSetup: booleanFlag('skip-setup', 'do not run package.json provision.setup commands'),
24
+ nonInteractive: booleanFlag('non-interactive', 'never prompt; unresolved env conflicts fail safely'),
25
+ envConflict: Flag.Literals('env-conflict', envConflictPolicies).pipe(Flag.withDescription('existing env policy (default: refresh from Vercel)'), Flag.withDefault('overwrite'))
26
+ }, options => provisionEnvironment({
27
+ ...options,
28
+ // Agents cannot answer prompts; fail safely instead, as with --non-interactive.
29
+ nonInteractive: options.nonInteractive || detectAgent() !== undefined,
30
+ appDir: Option.getOrUndefined(options.appDir),
31
+ source: Option.getOrUndefined(options.source),
32
+ vercelProject: Option.getOrUndefined(options.vercelProject),
33
+ label: Option.getOrUndefined(options.label)
34
+ }).pipe(Effect.catchTag('VercelLinkRequired', error => Console.error(JSON.stringify({
35
+ status: 'vercel_link_required',
36
+ directory: error.directory,
37
+ reason: error.reason
38
+ })).pipe(Effect.andThen(setExitCode(3)))), Effect.catch(error => Console.error(`provision env: ${error.message}`).pipe(Effect.andThen(setExitCode(2)))))).pipe(Command.withDescription('Make a checkout runnable: install dependencies, link Vercel, pull Development and test env ' +
39
+ 'files, optionally allocate isolated databases, then run package.json provision.setup.'));
@@ -0,0 +1,14 @@
1
+ import { Data } from 'effect';
2
+ export class EnvError extends Data.TaggedError('EnvError') {
3
+ }
4
+ /**
5
+ * The selected app has no usable Vercel link. Reported as one JSON object on stderr with exit code
6
+ * 3 so a calling agent can link the project and retry.
7
+ */
8
+ export class VercelLinkRequired extends Data.TaggedError('VercelLinkRequired') {
9
+ get message() {
10
+ return `Vercel link required in ${this.directory}: ${this.reason}`;
11
+ }
12
+ }
13
+ export const envConflictPolicies = ['ask', 'error', 'preserve', 'overwrite'];
14
+ export const fail = (message) => new EnvError({ message });
@@ -0,0 +1,25 @@
1
+ import { Console, Effect, FileSystem, Path } from 'effect';
2
+ import { capture, inherit } from '../shared/process.js';
3
+ import { fail } from './domain.js';
4
+ /** Installs dependencies from the checkout root's committed lockfile, never updating it. */
5
+ export const installDependencies = (repo) => Effect.gen(function* () {
6
+ const fs = yield* FileSystem.FileSystem;
7
+ const path = yield* Path.Path;
8
+ const exists = (file) => fs.exists(path.join(repo, file)).pipe(Effect.orElseSucceed(() => false));
9
+ const run = (label, command, args) => Console.log(`provision env: installing dependencies with ${label}`).pipe(Effect.andThen(inherit(command, args, { cwd: repo })), Effect.mapError(error => fail(error.message)));
10
+ if (yield* exists('pnpm-lock.yaml')) {
11
+ return yield* run('pnpm --frozen-lockfile', 'pnpm', ['install', '--frozen-lockfile']);
12
+ }
13
+ if ((yield* exists('bun.lock')) || (yield* exists('bun.lockb'))) {
14
+ return yield* run('bun --frozen-lockfile', 'bun', ['install', '--frozen-lockfile']);
15
+ }
16
+ if (yield* exists('package-lock.json'))
17
+ return yield* run('npm ci', 'npm', ['ci']);
18
+ if (yield* exists('yarn.lock')) {
19
+ const version = yield* capture('yarn', ['--version'], { cwd: repo }).pipe(Effect.mapError(error => fail(error.message)));
20
+ const major = Number(version.stdout.trim().split('.')[0]);
21
+ const flag = Number.isInteger(major) && major >= 2 ? '--immutable' : '--frozen-lockfile';
22
+ return yield* run(`yarn ${flag}`, 'yarn', ['install', flag]);
23
+ }
24
+ return yield* fail(`no supported lockfile found in ${repo}`);
25
+ });
@@ -0,0 +1,105 @@
1
+ import * as NodeFs from 'node:fs/promises';
2
+ import { Effect, FileSystem, Option, Path } from 'effect';
3
+ import { gitOutput, gitSucceeds } from '../shared/git.js';
4
+ import { ensureNotSymlink } from '../shared/private-file.js';
5
+ import { normalizeAppDir } from '../shared/repo-config.js';
6
+ import { EnvError, fail } from './domain.js';
7
+ /** Resolves and validates a Git checkout root. */
8
+ export const resolveCheckout = (requested) => Effect.gen(function* () {
9
+ const fs = yield* FileSystem.FileSystem;
10
+ const path = yield* Path.Path;
11
+ const candidate = path.resolve(requested);
12
+ const isDirectory = yield* fs.stat(candidate).pipe(Effect.map(info => info.type === 'Directory'), Effect.orElseSucceed(() => false));
13
+ if (!isDirectory)
14
+ return yield* fail(`not a directory: ${candidate}`);
15
+ const inside = yield* gitOutput(candidate, ['rev-parse', '--is-inside-work-tree']);
16
+ if (Option.getOrUndefined(inside) !== 'true') {
17
+ return yield* fail(`not a Git checkout: ${candidate}`);
18
+ }
19
+ const top = yield* gitOutput(candidate, ['rev-parse', '--show-toplevel']);
20
+ if (Option.isNone(top))
21
+ return yield* fail(`cannot resolve checkout root: ${candidate}`);
22
+ return path.resolve(top.value);
23
+ });
24
+ /**
25
+ * Resolves the selected application directory without changing checkout or lease identity. The
26
+ * app must exist, contain `package.json` (unless it is the root), and be reached without symlinks.
27
+ */
28
+ export const resolveAppDirectory = (repo, requested) => Effect.gen(function* () {
29
+ const fs = yield* FileSystem.FileSystem;
30
+ const path = yield* Path.Path;
31
+ const appDir = normalizeAppDir(requested);
32
+ if (appDir === undefined || appDir.includes(':')) {
33
+ return yield* fail("app directory must be '.' or a checkout-relative path without traversal");
34
+ }
35
+ const directory = path.join(repo, appDir);
36
+ const canonicalRoot = yield* fs
37
+ .realPath(repo)
38
+ .pipe(Effect.mapError(() => fail('cannot resolve checkout directory')));
39
+ const canonicalApp = yield* fs
40
+ .realPath(directory)
41
+ .pipe(Effect.mapError(() => fail(`app directory does not exist: ${appDir}`)));
42
+ if (canonicalApp !== path.join(canonicalRoot, appDir)) {
43
+ return yield* fail('app directory must not traverse symlinks');
44
+ }
45
+ const info = yield* fs
46
+ .stat(directory)
47
+ .pipe(Effect.mapError(() => fail(`cannot inspect app directory: ${appDir}`)));
48
+ if (info.type !== 'Directory')
49
+ return yield* fail(`not an app directory: ${appDir}`);
50
+ if (appDir !== '.') {
51
+ const manifest = yield* fs
52
+ .stat(path.join(directory, 'package.json'))
53
+ .pipe(Effect.mapError(() => fail('app directory must contain package.json')));
54
+ if (manifest.type !== 'File')
55
+ return yield* fail('app directory must contain package.json');
56
+ }
57
+ return { appDir, directory };
58
+ });
59
+ /**
60
+ * Fails when any existing component of `directory/file` is a symlink (dangling links included).
61
+ * Preflight validation, not a sandbox against concurrent same-user mutation.
62
+ */
63
+ export const ensureProvisionPaths = (directory, files) => Effect.gen(function* () {
64
+ const fs = yield* FileSystem.FileSystem;
65
+ const path = yield* Path.Path;
66
+ for (const file of files) {
67
+ let parent = directory;
68
+ for (const component of file.split('/')) {
69
+ const entries = yield* fs
70
+ .readDirectory(parent)
71
+ .pipe(Effect.mapError(() => fail(`cannot inspect provisioning path: ${file}`)));
72
+ if (!entries.includes(component))
73
+ break;
74
+ const child = path.join(parent, component);
75
+ const symbolic = yield* Effect.tryPromise({
76
+ try: async () => (await NodeFs.lstat(child)).isSymbolicLink(),
77
+ catch: () => fail(`cannot inspect provisioning path: ${file}`)
78
+ });
79
+ if (symbolic)
80
+ return yield* fail(`provisioning path must not traverse symlinks: ${file}`);
81
+ parent = child;
82
+ }
83
+ }
84
+ });
85
+ /** Env files and Vercel links may only be written to untracked, ignored, non-symlink paths. */
86
+ export const ensureSecretPath = (repo, relativeFile) => Effect.gen(function* () {
87
+ const path = yield* Path.Path;
88
+ yield* ensureNotSymlink(path.join(repo, relativeFile)).pipe(Effect.mapError(() => fail(`refusing symbolic-link environment path: ${relativeFile}`)));
89
+ if (yield* gitSucceeds(repo, ['ls-files', '--error-unmatch', '--', relativeFile])) {
90
+ return yield* fail(`refusing to write ${relativeFile} because it is tracked`);
91
+ }
92
+ if (!(yield* gitSucceeds(repo, ['check-ignore', '-q', '--', relativeFile]))) {
93
+ return yield* fail(`refusing to write ${relativeFile} because it is not git-ignored`);
94
+ }
95
+ });
96
+ const lstatCheck = (file, check, message) => Effect.tryPromise({
97
+ try: async () => {
98
+ if (!check(await NodeFs.lstat(file)))
99
+ throw new Error('unsafe path');
100
+ },
101
+ catch: () => new EnvError({ message })
102
+ });
103
+ export const ensureLocalDirectory = (directory, label) => lstatCheck(directory, info => !info.isSymbolicLink() && info.isDirectory(), `${label} is not a local directory`);
104
+ export const ensureRegularFile = (file, label) => lstatCheck(file, info => !info.isSymbolicLink() && info.isFile(), `${label} is not a regular local file`);
105
+ export const ensurePrivateMode = (file, mode, label) => lstatCheck(file, info => !info.isSymbolicLink() && (Number(info.mode) & 0o777) === mode, `${label} does not have the required private mode`);