@open-everywhere/create 0.1.4

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 (82) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +21 -0
  3. package/README.md +69 -0
  4. package/bin/create-open-everywhere.js +57 -0
  5. package/package.json +57 -0
  6. package/src/cli.ts +241 -0
  7. package/src/deploy-probe.ts +99 -0
  8. package/src/engine.ts +106 -0
  9. package/src/env-file.ts +53 -0
  10. package/src/scaffold.ts +188 -0
  11. package/src/steps/build-deploy.ts +78 -0
  12. package/src/steps/database.ts +85 -0
  13. package/src/steps/env-checklist.ts +55 -0
  14. package/src/steps/index.ts +49 -0
  15. package/src/steps/prerequisites.ts +50 -0
  16. package/src/steps/repo.ts +69 -0
  17. package/src/steps/roster.ts +111 -0
  18. package/src/steps/slack.ts +74 -0
  19. package/src/steps/vercel.ts +40 -0
  20. package/src/steps/verify.ts +119 -0
  21. package/src/version.ts +20 -0
  22. package/template/.env.example +58 -0
  23. package/template/.mcp.json +8 -0
  24. package/template/AGENTS.md +62 -0
  25. package/template/CLAUDE.md +1 -0
  26. package/template/README.md +87 -0
  27. package/template/agent/access/access-requests.ts +69 -0
  28. package/template/agent/access/browser-hosts.ts +28 -0
  29. package/template/agent/access/bundles.ts +78 -0
  30. package/template/agent/access/connector-credentials.ts +74 -0
  31. package/template/agent/access/credential-bindings.generated.ts +17 -0
  32. package/template/agent/access/credential-bindings.ts +28 -0
  33. package/template/agent/access/grants.generated.ts +18 -0
  34. package/template/agent/access/grants.ts +30 -0
  35. package/template/agent/access/network-access.ts +32 -0
  36. package/template/agent/agent.ts +125 -0
  37. package/template/agent/blob/hosting.ts +42 -0
  38. package/template/agent/identity/authors.ts +79 -0
  39. package/template/agent/identity/groups-overlay.ts +24 -0
  40. package/template/agent/identity/groups.generated.ts +17 -0
  41. package/template/agent/identity/groups.ts +84 -0
  42. package/template/agent/identity/principals.ts +37 -0
  43. package/template/agent/identity/surfaces.ts +48 -0
  44. package/template/agent/instructions/access.ts +11 -0
  45. package/template/agent/instructions.md +295 -0
  46. package/template/agent/instrumentation.ts +6 -0
  47. package/template/agent/lib/memory-grants.ts +52 -0
  48. package/template/agent/lib/register-adapters.ts +286 -0
  49. package/template/agent/lib/register-config.ts +185 -0
  50. package/template/agent/memory/config/agents-overlay.ts +26 -0
  51. package/template/agent/memory/config/agents.generated.ts +21 -0
  52. package/template/agent/memory/config/domains/dm.ts +10 -0
  53. package/template/agent/memory/config/domains/index.ts +125 -0
  54. package/template/agent/memory/config/domains/org.ts +27 -0
  55. package/template/agent/memory/config/domains.ts +165 -0
  56. package/template/agent/model/models.ts +60 -0
  57. package/template/agent/model/policy.ts +19 -0
  58. package/template/agent/model/stacks.ts +75 -0
  59. package/template/agent/plugins/generated/index.ts +13 -0
  60. package/template/agent/plugins/registry.ts +16 -0
  61. package/template/agent/plugins/skill-content.ts +13 -0
  62. package/template/agent/roles/roles.ts +146 -0
  63. package/template/agent/routines/collectors.ts +89 -0
  64. package/template/agent/sandbox/backend-select.ts +16 -0
  65. package/template/agent/sandbox/sandbox.ts +242 -0
  66. package/template/agent/sandbox/workspace/.keep +6 -0
  67. package/template/agent/settings/settings.ts +19 -0
  68. package/template/agent/spend/budgets.ts +25 -0
  69. package/template/agent/spend/checkpoints.ts +51 -0
  70. package/template/agent/spend/model-pricing.ts +25 -0
  71. package/template/agent/webhooks/hooks-overlay.ts +34 -0
  72. package/template/agent/webhooks/hooks.generated.ts +17 -0
  73. package/template/agent/webhooks/hooks.ts +133 -0
  74. package/template/claude/skills/quickstart-open-everywhere/SKILL.md +167 -0
  75. package/template/claude/skills/upgrade-open-everywhere/SKILL.md +160 -0
  76. package/template/gitignore +42 -0
  77. package/template/package.json +62 -0
  78. package/template/roster.config.ts +39 -0
  79. package/template/roster.lock.json +182 -0
  80. package/template/scripts/env-pipe.mjs +74 -0
  81. package/template/scripts/ops-mcp.ts +35 -0
  82. package/template/tsconfig.json +23 -0
@@ -0,0 +1,99 @@
1
+ // The deploy probe — the one deployment fact the setup check can observe without credentials.
2
+ //
3
+ // It sends an unsigned POST to the deployment's Slack route. The Slack bundle answers an unsigned
4
+ // body with exactly `401 unauthorized` (slack-front.ts), and it can do so only when the function
5
+ // booted and the route is mounted. So:
6
+ // • 401 with that body → the function is up and refuses unsigned input: satisfied;
7
+ // • 401/403 with another body → something in FRONT of the function answered (Vercel Deployment
8
+ // Protection is the usual one) — Slack is blocked too, so this is NOT satisfied;
9
+ // • 404 → the route is not on this deployment; 5xx → the function failed; no answer → unreachable.
10
+ // A 5xx or no answer gets ONE retry with a longer timeout, because the first request to a fresh
11
+ // deployment boots the function (see probeDeployment).
12
+ // It proves boot and routing. It does not prove that the deployment holds SLACK_SIGNING_SECRET: the
13
+ // route answers an unsigned body with the same 401 when the secret is unset. Slack's own Event
14
+ // Subscriptions "Verified" is the check that proves the secret. It does not prove the stores or the
15
+ // model either.
16
+
17
+ /** The route the probe posts to. The quickstart app mounts Slack. */
18
+ export const DEPLOY_PROBE_PATH = "/eve/v1/slack";
19
+
20
+ export interface ProbeResult {
21
+ readonly ok: boolean;
22
+ readonly detail: string;
23
+ }
24
+
25
+ /** Normalise `--host` to an origin: scheme optional, path and trailing slash dropped. */
26
+ export function normalizeHost(raw: string): string | undefined {
27
+ const trimmed = raw.trim();
28
+ if (trimmed === "") return undefined;
29
+ try {
30
+ const url = new URL(/^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`);
31
+ return url.origin;
32
+ } catch {
33
+ return undefined;
34
+ }
35
+ }
36
+
37
+ /** One attempt's verdict. `retry` marks a failure a cold function can cause: no answer, or a 5xx. */
38
+ interface Attempt extends ProbeResult {
39
+ readonly retry: boolean;
40
+ /** The outcome in a few words, for the cold-start note. */
41
+ readonly brief: string;
42
+ }
43
+
44
+ async function probeOnce(url: string, fetchImpl: typeof fetch, timeoutMs: number): Promise<Attempt> {
45
+ let res: Response;
46
+ try {
47
+ res = await fetchImpl(url, {
48
+ method: "POST",
49
+ headers: { "content-type": "application/json" },
50
+ body: "{}",
51
+ signal: AbortSignal.timeout(timeoutMs),
52
+ redirect: "manual",
53
+ });
54
+ } catch (err) {
55
+ const why = err instanceof Error ? err.message : String(err);
56
+ return { ok: false, retry: true, brief: `no answer within ${timeoutMs / 1000}s`, detail: `${url} did not answer (${why})` };
57
+ }
58
+ const body = (await res.text().catch(() => "")).trim();
59
+ if (res.status === 401 && body === "unauthorized") {
60
+ return {
61
+ ok: true,
62
+ retry: false,
63
+ brief: "401 unauthorized",
64
+ detail: `${url} answered 401 unauthorized to an unsigned request: the function booted and the Slack route is mounted. This does NOT prove the deployment holds SLACK_SIGNING_SECRET (the route answers the same without it). Slack proves that: Event Subscriptions shows the request URL as Verified`,
65
+ };
66
+ }
67
+ const fail = (detail: string, retry = false): Attempt => ({ ok: false, retry, brief: `${res.status}`, detail });
68
+ if (res.status === 401 || res.status === 403) {
69
+ return fail(
70
+ `${url} answered ${res.status}, but not from the Slack route — Vercel Deployment Protection (or a proxy) blocks it, and it blocks Slack too. Vercel project → Settings → Deployment Protection`,
71
+ );
72
+ }
73
+ if (res.status === 404) return fail(`${url} answered 404: the Slack route is not on this deployment (wrong host, or the roster does not mount Slack)`);
74
+ if (res.status >= 500) return fail(`${url} answered ${res.status}: the function failed — read the deployment's logs (\`npx vercel logs <host>\`)`, true);
75
+ return fail(`${url} answered ${res.status}: expected 401 to an unsigned request`);
76
+ }
77
+
78
+ /**
79
+ * Probe the deployment's Slack route, with ONE retry for a cold start.
80
+ *
81
+ * The first request to a fresh deployment boots the function. That boot can outlast the first
82
+ * timeout, or answer 5xx, and a practice install met both (OPE-2222). So a timeout, a network
83
+ * error or a 5xx gets one more attempt with the longer `retryTimeoutMs`. A pass on the retry says
84
+ * "cold start", so the operator knows the first failure was the boot. A non-Slack 401/403, a 404 or
85
+ * any other status is never retried: a second request cannot change it.
86
+ */
87
+ export async function probeDeployment(
88
+ origin: string,
89
+ fetchImpl: typeof fetch = fetch,
90
+ timeoutMs = 10_000,
91
+ retryTimeoutMs = 30_000,
92
+ ): Promise<ProbeResult> {
93
+ const url = `${origin}${DEPLOY_PROBE_PATH}`;
94
+ const first = await probeOnce(url, fetchImpl, timeoutMs);
95
+ if (first.ok || !first.retry) return { ok: first.ok, detail: first.detail };
96
+ const second = await probeOnce(url, fetchImpl, retryTimeoutMs);
97
+ if (second.ok) return { ok: true, detail: `cold start: the first request got ${first.brief}, and the retry passed. ${second.detail}` };
98
+ return { ok: false, detail: `${second.detail} (after one retry; the first request got ${first.brief})` };
99
+ }
package/src/engine.ts ADDED
@@ -0,0 +1,106 @@
1
+ // The setup engine — ONE step-driven checklist that the `@open-everywhere/create` CLI's `--check`
2
+ // mode reports, the `quickstart-open-everywhere` skill reads, and `scripts/setup-steps-docs.ts`
3
+ // renders into the human reference page (docs/deploy/setup-steps.md). A step is data:
4
+ // { id, title, check(), guide() } — see `SetupStep` below. This module owns the TYPES and the
5
+ // ORCHESTRATOR only; it never touches stdin/stdout, so a test drives it exactly as the CLI does.
6
+ //
7
+ // 🔴 THERE IS NO INTERACTIVE WIZARD (OPE-2233). The TTY wizard, its `WizardIO` prompt surface and
8
+ // every step's `automate()` are retired: setup is AGENT-LED. The agent reads this engine's report
9
+ // and runs the commands with the user's consent, and the ops MCP server's `scaffold_env` /
10
+ // `scaffold_credential_key` tools do the writes a step's `automate()` used to do. So this engine
11
+ // is READ-ONLY by construction: `check()` inspects, `guide()` describes, and nothing writes.
12
+ //
13
+ // Resumability is structural, not a bookkeeping flag: `check()` inspects the REAL WORLD (env vars,
14
+ // files, `.vercel/project.json`, node version), so a re-run after a stop picks up exactly where it
15
+ // left off — a step is only "already done" because the thing it checks for is actually true.
16
+
17
+ /**
18
+ * The deployment route the steps judge against. TWO routes are supported, and the user picks one
19
+ * FIRST (OPE-2228): `vercel` (on rails — the Vercel CLI plus Marketplace Neon) and `docker`
20
+ * (self-host against the deployment contract, docs/deploy/contract.md). Nothing else is supported,
21
+ * and a local run is NEVER a route: `npm run dev` is the development loop AFTER a deploy, because
22
+ * a local success proves nothing about a deployment (no public HTTPS URL for a chat vendor to
23
+ * reach, and a different store).
24
+ */
25
+ export type SetupRoute = "vercel" | "docker";
26
+
27
+ /** Every supported route, in the order the setup guidance offers them. */
28
+ export const SETUP_ROUTES: readonly SetupRoute[] = ["vercel", "docker"];
29
+
30
+ /** Is this string one of the two supported routes? */
31
+ export function isSetupRoute(value: string): value is SetupRoute {
32
+ return (SETUP_ROUTES as readonly string[]).includes(value);
33
+ }
34
+
35
+ export interface StepContext {
36
+ /** Directory the check is running in (the target project checkout). */
37
+ readonly cwd: string;
38
+ /** Process env — a param (not a bare `process.env` read) so tests can inject one. */
39
+ readonly env: NodeJS.ProcessEnv;
40
+ /** The route the steps judge against. Absent means `vercel`. */
41
+ readonly route?: SetupRoute;
42
+ /** The deployment's public origin (`--host`), for the deploy probe. */
43
+ readonly host?: string;
44
+ /** The chat app's name (`--app-name`), for the Slack manifest. Absent means the default name. */
45
+ readonly appName?: string;
46
+ }
47
+
48
+ /** The route a context judges against. */
49
+ export function routeOf(ctx: StepContext): SetupRoute {
50
+ return ctx.route ?? "vercel";
51
+ }
52
+
53
+ export interface StepCheckResult {
54
+ readonly satisfied: boolean;
55
+ /** One line, human-readable — NEVER a secret value (see AGENTS.md security rules). */
56
+ readonly detail: string;
57
+ /** The command (or the one action) that moves an unsatisfied step forward. */
58
+ readonly next?: string;
59
+ }
60
+
61
+ export interface StepGuide {
62
+ readonly summary: string;
63
+ readonly instructions: readonly string[];
64
+ }
65
+
66
+ export interface SetupStep {
67
+ readonly id: string;
68
+ readonly title: string;
69
+ check(ctx: StepContext): Promise<StepCheckResult> | StepCheckResult;
70
+ guide(ctx: StepContext): StepGuide;
71
+ }
72
+
73
+ export interface StepRunResult {
74
+ readonly step: SetupStep;
75
+ readonly check: StepCheckResult;
76
+ }
77
+
78
+ export interface SetupCheckSummary {
79
+ readonly results: readonly StepRunResult[];
80
+ readonly satisfiedCount: number;
81
+ readonly total: number;
82
+ readonly allSatisfied: boolean;
83
+ }
84
+
85
+ /**
86
+ * The status report — no prompts, no automation, no side effects. This is what `--check` mode (and
87
+ * the skill, through it) calls: run every step's `check()` and report satisfied/unsatisfied. Safe
88
+ * to call as often as you like.
89
+ *
90
+ * Never throws: a step whose `check()` rejects is reported as unsatisfied rather than aborting the
91
+ * whole run, so one bad step cannot hide the rest of the checklist.
92
+ */
93
+ export async function checkAll(steps: readonly SetupStep[], ctx: StepContext): Promise<SetupCheckSummary> {
94
+ const results: StepRunResult[] = [];
95
+ for (const step of steps) {
96
+ let check: StepCheckResult;
97
+ try {
98
+ check = await step.check(ctx);
99
+ } catch (err) {
100
+ check = { satisfied: false, detail: `check failed: ${err instanceof Error ? err.message : String(err)}` };
101
+ }
102
+ results.push({ step, check });
103
+ }
104
+ const satisfiedCount = results.filter((r) => r.check.satisfied).length;
105
+ return { results, satisfiedCount, total: steps.length, allSatisfied: satisfiedCount === steps.length };
106
+ }
@@ -0,0 +1,53 @@
1
+ // Thin .env.local helpers for the setup steps' `check()`s.
2
+ //
3
+ // 🔴 READ-ONLY SINCE OPE-2233. This module used to carry `writeEnvLocal`, which the wizard's
4
+ // `automate()` steps called. Setup is agent-led now, and the ONE door that writes `.env.local` is
5
+ // the ops MCP server's `scaffold_env` tool — which gates the target filename, so a value can never
6
+ // land in a tracked file (agent/ops/server.ts § envTargetRefusal). A second writer here would be a
7
+ // second gate to keep right.
8
+ //
9
+ // SECURITY: nothing here ever returns a secret VALUE to a caller for printing — `mergedEnv` and
10
+ // `isSet` are presence checks only. See AGENTS.md's "never log secrets" rule.
11
+
12
+ import { existsSync, readFileSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import type { SetupRoute } from "./engine.js";
15
+
16
+ export function envLocalPath(cwd: string): string {
17
+ return join(cwd, ".env.local");
18
+ }
19
+
20
+ function readEnvLocal(cwd: string): Record<string, string> {
21
+ const p = envLocalPath(cwd);
22
+ if (!existsSync(p)) return {};
23
+ const text = readFileSync(p, "utf8");
24
+ const out: Record<string, string> = {};
25
+ for (const line of text.split("\n")) {
26
+ const m = /^\s*([A-Z0-9_]+)\s*=\s*(.*)$/.exec(line);
27
+ if (m) out[m[1]] = m[2];
28
+ }
29
+ return out;
30
+ }
31
+
32
+ /** Merged view: `.env.local` overlaid with `process.env` (a shell-exported var
33
+ * wins over the file — mirrors how a real boot resolves config), so `check()`
34
+ * reflects what actually happens at runtime, not just what's on disk. */
35
+ export function mergedEnv(cwd: string, processEnv: NodeJS.ProcessEnv): Record<string, string | undefined> {
36
+ return { ...readEnvLocal(cwd), ...processEnv };
37
+ }
38
+
39
+ export function isSet(v: string | undefined): boolean {
40
+ return v !== undefined && v.trim() !== "";
41
+ }
42
+
43
+ /**
44
+ * What a "missing" detail adds (OPE-2222). `--check` reads `.env.local` and the shell, and NEVER
45
+ * the deployment's own environment. Setting a value on the deployment instead is a normal thing to
46
+ * do, so a miss here can be a value the deployment already holds. The check does not call a vendor
47
+ * CLI to find out: that needs a login and a network round trip.
48
+ */
49
+ export function missNote(route: SetupRoute): string {
50
+ const where = route === "docker" ? "the container's environment" : "the Vercel project";
51
+ const list = route === "docker" ? "`docker inspect` lists the names there" : "`npx vercel env ls production` lists the names there";
52
+ return `Did you set these on ${where} instead? Then ignore this miss: the check reads only .env.local and this shell, never the deployment's own env (${list})`;
53
+ }
@@ -0,0 +1,188 @@
1
+ // THE SCAFFOLD — `npx @open-everywhere/create <dir>` copies the quickstart app (this package's
2
+ // `template/` directory) into a new or empty `<dir>`, and then prints the next steps.
3
+ //
4
+ // The template is a minimal npm-consumer app: the eight `@open-everywhere/*` libraries and `eve`,
5
+ // a roster of the launch surfaces (Slack, Notion, GitHub), and the agent config. Every
6
+ // `@open-everywhere/*` dependency, `@open-everywhere/create` itself, and the versions in
7
+ // `roster.lock.json` carry the {@link VERSION_PLACEHOLDER}. The scaffold replaces it with this
8
+ // CLI's own version: all nine packages publish in lockstep, so this CLI's version IS the version
9
+ // of every library it installs.
10
+ //
11
+ // THREE PACKING RULES SHAPE THE TEMPLATE:
12
+ // • npm never packs a file named `.gitignore`. The template carries it as `gitignore`, and the
13
+ // scaffold renames it.
14
+ // • The publish preview refuses any `.claude/` path in a tarball (rule `harness-tree`, which
15
+ // guards every package). The template carries the app's Claude Code skill under `claude/`, and
16
+ // the scaffold renames the directory to `.claude/`.
17
+ // • The scaffold refuses a directory that holds anything. It never merges into, or overwrites,
18
+ // existing files.
19
+ //
20
+ // This module imports nothing from core. The scaffold must run before anything is installed, and
21
+ // the boot-free test (scripts/boot-free-test.ts) holds every `src/` file to that.
22
+
23
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
24
+ import { EOL } from "node:os";
25
+ import { basename, join, relative, resolve } from "node:path";
26
+ import { fileURLToPath } from "node:url";
27
+ import { spawnSync } from "node:child_process";
28
+
29
+ /** Replaced by this CLI's own version in every template file that carries it. */
30
+ export const VERSION_PLACEHOLDER = "{{OE_VERSION}}";
31
+
32
+ /** Replaced by the npm package name derived from the target directory's name. */
33
+ export const NAME_PLACEHOLDER = "{{APP_NAME}}";
34
+
35
+ /**
36
+ * Template paths stored under another name because the tarball may not carry the real one, renamed
37
+ * on copy. Keys are template-relative paths, values are the scaffolded paths. A key that ends in `/`
38
+ * renames a whole directory.
39
+ */
40
+ export const RENAMED_ON_COPY: Readonly<Record<string, string>> = { gitignore: ".gitignore", "claude/": ".claude/" };
41
+
42
+ /** The scaffolded path of one template file. */
43
+ export function scaffoldPathFor(rel: string): string {
44
+ const exact = RENAMED_ON_COPY[rel];
45
+ if (exact !== undefined) return exact;
46
+ for (const [from, to] of Object.entries(RENAMED_ON_COPY)) {
47
+ if (from.endsWith("/") && rel.startsWith(from)) return to + rel.slice(from.length);
48
+ }
49
+ return rel;
50
+ }
51
+
52
+ /** The `.gitignore` lines the Vercel CLI appends when the file lacks them, exactly as it writes
53
+ * them: `vercel link` adds `.vercel`, and `vercel env pull` (which `vercel link` can run) adds
54
+ * `.env*`. The template carries both, so the CLI finds them and changes nothing. */
55
+ export const VERCEL_GITIGNORE_LINES: readonly string[] = [".vercel", ".env*"];
56
+
57
+ /** `text` with every line ending replaced by `eol`. */
58
+ export function withGitignoreEol(text: string, eol: string): string {
59
+ return text.replace(/\r?\n/g, eol);
60
+ }
61
+
62
+ /** The template that ships inside this package. */
63
+ export function defaultTemplateDir(): string {
64
+ return fileURLToPath(new URL("../template/", import.meta.url));
65
+ }
66
+
67
+ /** An npm-valid package name from a directory name: lower case, `[a-z0-9._-]`, no leading `.`/`_`. */
68
+ export function packageNameFor(dir: string): string {
69
+ const cleaned = basename(resolve(dir))
70
+ .toLowerCase()
71
+ .replace(/[^a-z0-9._-]+/g, "-")
72
+ .replace(/^[._-]+/, "")
73
+ .replace(/-+$/, "");
74
+ return cleaned.length > 0 ? cleaned.slice(0, 214) : "open-everywhere-app";
75
+ }
76
+
77
+ export class ScaffoldRefusal extends Error {}
78
+
79
+ export interface ScaffoldResult {
80
+ /** The absolute path of the scaffolded app. */
81
+ readonly dir: string;
82
+ /** Every file written, relative to `dir`, with `/` separators, sorted. */
83
+ readonly files: readonly string[];
84
+ }
85
+
86
+ /** Every file under `root`, relative to it, with `/` separators. */
87
+ export function listFiles(root: string, at = root): string[] {
88
+ return readdirSync(at).flatMap((name) => {
89
+ const p = join(at, name);
90
+ return statSync(p).isDirectory() ? listFiles(root, p) : [relative(root, p).split("\\").join("/")];
91
+ });
92
+ }
93
+
94
+ /**
95
+ * Does this directory count as empty for a scaffold? A directory that holds only `.git` counts:
96
+ * that is a fresh clone of an empty repository, so `npx @open-everywhere/create .` works inside one.
97
+ */
98
+ export function isScaffoldableDir(dir: string): boolean {
99
+ return readdirSync(dir).every((name) => name === ".git");
100
+ }
101
+
102
+ /**
103
+ * Copy the template into `targetDir`. Creates the directory when it is absent. Throws a
104
+ * {@link ScaffoldRefusal} when it exists and is not an empty directory. A directory that holds only
105
+ * `.git` counts as empty (see {@link isScaffoldableDir}).
106
+ */
107
+ export function scaffoldApp(options: {
108
+ readonly targetDir: string;
109
+ readonly version: string;
110
+ readonly templateDir?: string;
111
+ /** The line ending the app's `.gitignore` gets. Default: this platform's `os.EOL`. */
112
+ readonly eol?: string;
113
+ }): ScaffoldResult {
114
+ const dir = resolve(options.targetDir);
115
+ const templateDir = options.templateDir ?? defaultTemplateDir();
116
+ // `0.0.0` is src/version.ts's fallback when it cannot read this package's own manifest. No release
117
+ // carries it, so pinning it would only fail the app's first `npm install`.
118
+ if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(options.version) || options.version === "0.0.0") {
119
+ throw new ScaffoldRefusal(`refusing to scaffold: "${options.version}" is not a release version, so the app's dependencies could not be pinned`);
120
+ }
121
+ if (!existsSync(join(templateDir, "package.json"))) {
122
+ throw new ScaffoldRefusal(`refusing to scaffold: the quickstart template is missing from this package (${templateDir})`);
123
+ }
124
+ if (existsSync(dir)) {
125
+ if (!statSync(dir).isDirectory()) throw new ScaffoldRefusal(`refusing to scaffold: ${dir} exists and is not a directory`);
126
+ if (!isScaffoldableDir(dir)) {
127
+ throw new ScaffoldRefusal(`refusing to scaffold: ${dir} is not empty — choose a new directory name, or empty this one first`);
128
+ }
129
+ }
130
+ mkdirSync(dir, { recursive: true });
131
+
132
+ const name = packageNameFor(dir);
133
+ const written: string[] = [];
134
+ for (const rel of listFiles(templateDir)) {
135
+ const out = scaffoldPathFor(rel);
136
+ const target = join(dir, ...out.split("/"));
137
+ mkdirSync(join(target, ".."), { recursive: true });
138
+ const source = join(templateDir, ...rel.split("/"));
139
+ const bytes = readFileSync(source);
140
+ const text = bytes.toString("utf8");
141
+ if (out === ".gitignore") {
142
+ // 🔴 THE PLATFORM'S LINE ENDING, SO `vercel link` LEAVES THE FILE ALONE (OPE-2222). The
143
+ // Vercel CLI appends `.vercel` (on link) and `.env*` (on env pull) unless the file already
144
+ // holds that exact line. It splits the file on `\r\n` when the file holds one, and on
145
+ // `os.EOL` otherwise. On Windows `os.EOL` is `\r\n`, so an LF-only file reads as ONE line,
146
+ // and the CLI appends lines the template already carries. See withGitignoreEol.
147
+ writeFileSync(target, withGitignoreEol(text, options.eol ?? EOL));
148
+ } else if (text.includes(VERSION_PLACEHOLDER) || text.includes(NAME_PLACEHOLDER)) {
149
+ writeFileSync(target, text.split(VERSION_PLACEHOLDER).join(options.version).split(NAME_PLACEHOLDER).join(name));
150
+ } else {
151
+ copyFileSync(source, target);
152
+ }
153
+ written.push(out);
154
+ }
155
+ return { dir, files: written.sort() };
156
+ }
157
+
158
+ /** What {@link initGitRepo} did, and why. */
159
+ export interface GitInitResult {
160
+ readonly initialized: boolean;
161
+ readonly detail: string;
162
+ }
163
+
164
+ /** Run git with argv, never a shell. Returns `undefined` when git cannot be spawned at all. */
165
+ export type GitRunner = (args: readonly string[], cwd: string) => { readonly status: number | null } | undefined;
166
+
167
+ const realGit: GitRunner = (args, cwd) => {
168
+ const r = spawnSync("git", [...args], { cwd, stdio: "ignore" });
169
+ return r.error !== undefined ? undefined : { status: r.status };
170
+ };
171
+
172
+ /**
173
+ * Make the scaffolded app its own git repository. The app's files are its configuration, and every
174
+ * change to `roster.config.ts` and `roster.lock.json` is a review decision, so the history starts at
175
+ * the scaffold. It skips, and says why, when git is absent or the directory is already inside a
176
+ * work tree (a monorepo, for one): a nested repository there would hide the app from the outer one.
177
+ * It never commits, because a commit needs the person's git identity.
178
+ */
179
+ export function initGitRepo(dir: string, git: GitRunner = realGit): GitInitResult {
180
+ const version = git(["--version"], dir);
181
+ if (version === undefined || version.status !== 0) return { initialized: false, detail: "git is not on PATH — run `git init` yourself once it is installed" };
182
+ const inside = git(["rev-parse", "--is-inside-work-tree"], dir);
183
+ if (inside !== undefined && inside.status === 0) return { initialized: false, detail: "the directory is already inside a git work tree, so no new repository was made" };
184
+ const init = git(["init", "-q"], dir);
185
+ return init !== undefined && init.status === 0
186
+ ? { initialized: true, detail: "initialised a git repository (nothing committed yet)" }
187
+ : { initialized: false, detail: "`git init` failed — run it yourself" };
188
+ }
@@ -0,0 +1,78 @@
1
+ // Step 8 — build + deploy, judged against the route.
2
+ //
3
+ // Satisfied only when a build output exists AND the deployment answers the probe (deploy-probe.ts)
4
+ // at the host the operator names with `--host` (or OE_PUBLIC_BASE_URL in the env). A build
5
+ // directory alone NEVER satisfies this step: it says nothing about a deployment. That rule is the
6
+ // same on both routes — what differs is the command that deploys, and the probe does not care.
7
+ //
8
+ // Guide-only since OPE-2233: this step used to offer to run `npm run build`. The agent runs it in
9
+ // the user's shell now, with the user's consent, and `vercel deploy --prod` was never automated —
10
+ // deploy is a separate, explicit, human-confirmed step, and "git push does not deploy" is exactly
11
+ // the footgun this check exists to catch.
12
+
13
+ import { existsSync, readdirSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { routeOf, type SetupStep, type StepContext } from "../engine.js";
16
+ import { mergedEnv } from "../env-file.js";
17
+ import { normalizeHost, probeDeployment } from "../deploy-probe.js";
18
+
19
+ function nonEmptyDir(p: string): boolean {
20
+ try {
21
+ return existsSync(p) && readdirSync(p).length > 0;
22
+ } catch {
23
+ return false;
24
+ }
25
+ }
26
+
27
+ /** Is there a build output? A presence check: it can go stale after a source edit. */
28
+ function buildOutputPresent(cwd: string): boolean {
29
+ return nonEmptyDir(join(cwd, ".output")) || nonEmptyDir(join(cwd, ".vercel", "output"));
30
+ }
31
+
32
+ /** The deployment origin the probe uses: `--host`, else OE_PUBLIC_BASE_URL. */
33
+ function deployOrigin(ctx: StepContext): string | undefined {
34
+ if (ctx.host !== undefined) return normalizeHost(ctx.host);
35
+ const fromEnv = mergedEnv(ctx.cwd, ctx.env).OE_PUBLIC_BASE_URL;
36
+ return fromEnv ? normalizeHost(fromEnv) : undefined;
37
+ }
38
+
39
+ /** The command that deploys, per route. */
40
+ function deployCommand(ctx: StepContext): string {
41
+ return routeOf(ctx) === "docker"
42
+ ? "build the image and run it on your server (the deployment contract states what it must satisfy)"
43
+ : "npx vercel deploy --prod";
44
+ }
45
+
46
+ export const buildDeployStep: SetupStep = {
47
+ id: "build-deploy",
48
+ title: "Build + deploy",
49
+ async check(ctx: StepContext) {
50
+ if (!buildOutputPresent(ctx.cwd)) return { satisfied: false, detail: "not built yet", next: "npm run build" };
51
+ const origin = deployOrigin(ctx);
52
+ if (origin === undefined) {
53
+ return {
54
+ satisfied: false,
55
+ detail: "built. The deploy is unproven: no host to probe",
56
+ next: `${deployCommand(ctx)}, then re-run with --host https://<host>`,
57
+ };
58
+ }
59
+ const probe = await probeDeployment(origin);
60
+ return probe.ok
61
+ ? { satisfied: true, detail: probe.detail }
62
+ : { satisfied: false, detail: probe.detail, next: `${deployCommand(ctx)}, then re-run this check` };
63
+ },
64
+ guide(ctx: StepContext) {
65
+ const vercel = routeOf(ctx) === "vercel";
66
+ return {
67
+ summary: "Build before you deploy — a broken build must never be promoted.",
68
+ instructions: [
69
+ "`npm run build` (= `oe build`, then `eve build`) — must be green.",
70
+ vercel
71
+ ? "Deploy is a SEPARATE, explicit step: `npx vercel deploy --prod` (confirm the scope and team first). A `git push` does NOT deploy."
72
+ : "Deploy is a SEPARATE, explicit step: build the image and run it on your server, with the contract's environment set. See https://github.com/open-everywhere/open-everywhere/blob/main/docs/deploy/contract.md.",
73
+ "Then re-run `npx @open-everywhere/create --check --host https://<host>`: it probes the deployed Slack route.",
74
+ "The probe passes on the route's own `401 unauthorized`, which proves the function booted and the route is mounted. It does NOT prove the deployment holds SLACK_SIGNING_SECRET: the route answers the same without it. Only Event Subscriptions showing **Verified** in the Slack console proves that.",
75
+ ],
76
+ };
77
+ },
78
+ };
@@ -0,0 +1,85 @@
1
+ // Step 5 — database (Postgres). REQUIRED ON BOTH ROUTES (OPE-2233): the deployment contract names
2
+ // Postgres, and a local run is no longer a setup route, so there is no embedded-floor exemption to
3
+ // judge. On Vercel a function's disk is ephemeral — core throws at the first operational-store use
4
+ // and at the first DM or personal memory call on an embedded floor. In Docker the same store has to
5
+ // outlive the container.
6
+ //
7
+ // 🔴 IT ASKS THE RUNTIME'S OWN RULE, NOT FOR THREE ENV NAMES (OPE-2230). A bare `DATABASE_URL`
8
+ // resolves BOTH stores to postgres by itself, so the two selectors are not demanded beside it: an
9
+ // operator who had already connected Neon used to meet a red check and a fix list naming two
10
+ // variables that now change nothing. A selector is still honoured — one pointing AWAY from postgres
11
+ // is what this check refuses, and that is a different fix from a missing value, so it gets its own
12
+ // clause rather than being folded into a "missing variables" list it does not belong in.
13
+ //
14
+ // The step reads the OPERATOR's env (`.env.local` or the shell): that is the state in which the
15
+ // operator tools (`oe secret`, the ops server's `secret_set`) write the store the deployment reads.
16
+ // It cannot read the deployment's own environment, and its detail says so — a ✓ here never claims
17
+ // the deployment holds the same value.
18
+
19
+ import { isSet, mergedEnv, missNote } from "../env-file.js";
20
+ import { routeOf, type SetupRoute, type SetupStep, type StepContext } from "../engine.js";
21
+ import { resolveOperationalSelection } from "@open-everywhere/core/agent/operational/selection.js";
22
+ import { resolveMemoryProviderId } from "@open-everywhere/core/agent/memory/backing-selection.js";
23
+ import { OWNER_URL_ENV, RUNTIME_ROLE_NAME } from "@open-everywhere/core/agent/ops/two-role.js";
24
+
25
+ /** Which stores this env resolves to postgres, by the runtime's own two rules. */
26
+ function postgresResolution(env: NodeJS.ProcessEnv): { operational: boolean; memory: boolean } {
27
+ const op = resolveOperationalSelection(env);
28
+ return { operational: op.id === "postgres" && op.floor === "none", memory: resolveMemoryProviderId(env) === "postgres" };
29
+ }
30
+
31
+ /** The next action for an unsatisfied database step, per route. */
32
+ function nextFor(route: SetupRoute): string {
33
+ const write = 'write it with the ops MCP tool `scaffold_env { values: { DATABASE_URL: "<the connection string>" } }` — it selects postgres for both stores on its own';
34
+ return route === "vercel"
35
+ ? `add Neon from the Vercel Marketplace (\`npx vercel install neon\`, or the project's Storage → Neon → Connect; either sets DATABASE_URL on the project), then ${write}`
36
+ : `provision a Postgres the container can reach (the deployment contract names it), then ${write}`;
37
+ }
38
+
39
+ export const databaseStep: SetupStep = {
40
+ id: "database",
41
+ title: "Database (Postgres)",
42
+ check(ctx: StepContext) {
43
+ const env = mergedEnv(ctx.cwd, ctx.env) as NodeJS.ProcessEnv;
44
+ const route = routeOf(ctx);
45
+ const resolved = postgresResolution(env);
46
+ if (isSet(env.DATABASE_URL) && resolved.operational && resolved.memory) {
47
+ return {
48
+ satisfied: true,
49
+ detail:
50
+ "DATABASE_URL is set locally and selects postgres for both stores. This check cannot read the deployment's own " +
51
+ "environment: the same connection string must reach it too",
52
+ };
53
+ }
54
+ const pointedAway = [
55
+ ...(resolved.operational || !isSet(env.OE_OPERATIONAL_PROVIDER) ? [] : ["OE_OPERATIONAL_PROVIDER"]),
56
+ ...(resolved.memory || !isSet(env.OE_MEMORY_PROVIDER) ? [] : ["OE_MEMORY_PROVIDER"]),
57
+ ];
58
+ return {
59
+ satisfied: false,
60
+ detail:
61
+ "a deployment needs Postgres on both routes. " +
62
+ (isSet(env.DATABASE_URL)
63
+ ? `DATABASE_URL is set, but a selector points away from postgres: ${pointedAway.join(", ")}. Remove it, or set it to postgres. `
64
+ : "Missing from .env.local and this shell: DATABASE_URL — it selects postgres for both stores on its own. ") +
65
+ missNote(route),
66
+ next: nextFor(route),
67
+ };
68
+ },
69
+ guide(ctx: StepContext) {
70
+ const vercel = routeOf(ctx) === "vercel";
71
+ return {
72
+ summary: "Both routes need Postgres: an ephemeral disk (Vercel) or a replaced container (Docker) loses every local store.",
73
+ instructions: [
74
+ vercel
75
+ ? "`npx vercel install neon` provisions Neon and connects it to the linked project, which sets DATABASE_URL there. The project's Storage → Neon → Connect does the same thing."
76
+ : "Provision a Postgres the container can reach (Neon, RDS, or your own server). The deployment contract states what it must satisfy.",
77
+ "Write the same connection string into .env.local with the ops tool `scaffold_env`. It selects postgres for the operational store and the memory backing on its own — no provider selector needed. Never paste a connection string into the chat.",
78
+ vercel
79
+ ? "The deployment needs it too: the Neon integration already set DATABASE_URL on the Vercel project."
80
+ : "The deployment needs it too: DATABASE_URL is part of the container's environment.",
81
+ `Harden later: the two-role split parks the owner credential as ${OWNER_URL_ENV} and runs the app as the non-owner "${RUNTIME_ROLE_NAME}" role, which the owner-DDL escape cannot reach. It is MANUAL — nothing provisions it for you. See https://github.com/open-everywhere/open-everywhere/blob/main/docs/extending/database.md.`,
82
+ ],
83
+ };
84
+ },
85
+ };