@spawn-dock/create 0.2.0-canary.20260321111558.f5e5655

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,55 @@
1
+ # @spawn-dock/create
2
+
3
+ SpawnDock bootstrap CLI for local TMA projects.
4
+
5
+ This repository now follows an `effect-template`-style layout:
6
+
7
+ - root workspace with `packages/app`
8
+ - TypeScript + Effect entrypoint
9
+ - built-in TMA overlay bundled inside the CLI repo
10
+
11
+ The canonical TMA starter lives in `https://github.com/SpawnDock/tma-project`.
12
+ `@spawn-dock/create` clones that repo, applies the bundled SpawnDock TMA overlay,
13
+ and then writes project-specific runtime files.
14
+
15
+ ## Usage
16
+
17
+ ```bash
18
+ npx @spawn-dock/create --token <pairing-token> [project-dir]
19
+ ```
20
+
21
+ If npm registry access is unavailable, the GitHub fallback remains:
22
+
23
+ ```bash
24
+ npx --yes github:SpawnDock/create#main --token <pairing-token> [project-dir]
25
+ ```
26
+
27
+ ## What it writes
28
+
29
+ - `spawndock.config.json`
30
+ - `.env.local`
31
+ - `spawndock.dev-tunnel.json`
32
+ - `opencode.json`
33
+ - `public/tonconnect-manifest.json`
34
+
35
+ ## Built-in Overlay
36
+
37
+ The package also ships a built-in TMA overlay and applies it after cloning
38
+ `SpawnDock/tma-project`. This overlay is responsible for:
39
+
40
+ - `spawndock/dev.mjs`
41
+ - `spawndock/next.mjs`
42
+ - `spawndock/tunnel.mjs`
43
+ - `next.config.ts`
44
+ - `public/tonconnect-manifest.json`
45
+ - patching project scripts and `@spawn-dock/dev-tunnel`
46
+
47
+ Generated MCP config points to `<controlPlaneUrl>/mcp/sse`.
48
+
49
+ ## Development
50
+
51
+ ```bash
52
+ pnpm install
53
+ pnpm test
54
+ pnpm build
55
+ ```
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@spawn-dock/create",
3
+ "version": "0.2.0-canary.20260321111558.f5e5655",
4
+ "type": "module",
5
+ "description": "SpawnDock bootstrap CLI for local TMA projects",
6
+ "license": "ISC",
7
+ "packageManager": "pnpm@10.32.1",
8
+ "workspaces": [
9
+ "packages/*"
10
+ ],
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/SpawnDock/create.git"
14
+ },
15
+ "homepage": "https://github.com/SpawnDock/create-spawn-dock#readme",
16
+ "bugs": {
17
+ "url": "https://github.com/SpawnDock/create/issues"
18
+ },
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "files": [
23
+ "README.md",
24
+ "packages/app/dist",
25
+ "packages/app/template-nextjs-overlay"
26
+ ],
27
+ "bin": {
28
+ "create": "./packages/app/dist/src/app/main.js"
29
+ },
30
+ "scripts": {
31
+ "prepare": "node ./scripts/prepare.mjs",
32
+ "build": "pnpm --filter @spawn-dock/create-app build",
33
+ "check": "pnpm --filter @spawn-dock/create-app check",
34
+ "test": "pnpm --filter @spawn-dock/create-app test",
35
+ "typecheck": "pnpm --filter @spawn-dock/create-app typecheck",
36
+ "start": "pnpm --filter @spawn-dock/create-app start"
37
+ },
38
+ "dependencies": {
39
+ "effect": "^3.21.0"
40
+ },
41
+ "devDependencies": {
42
+ "@changesets/changelog-github": "^0.6.0",
43
+ "@changesets/cli": "^2.30.0",
44
+ "@types/node": "^24.12.0",
45
+ "typescript": "^5.9.3",
46
+ "vitest": "^4.1.0"
47
+ }
48
+ }
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import { Effect } from "effect";
3
+ import { program } from "./program.js";
4
+ Effect.runPromise(program).catch((error) => {
5
+ console.error(error);
6
+ process.exit(1);
7
+ });
@@ -0,0 +1,17 @@
1
+ import { Console, Effect, pipe } from "effect";
2
+ import { bootstrapProject } from "../shell/bootstrap.js";
3
+ import { formatUsage, readCliOptions } from "../shell/cli.js";
4
+ const formatSuccess = (summary) => [
5
+ "",
6
+ `SpawnDock project created at ${summary.projectDir}`,
7
+ `Project: ${summary.projectName}`,
8
+ `Preview URL: ${summary.previewOrigin}`,
9
+ `Run: cd "${summary.projectDir}" && npm run dev`,
10
+ ].join("\n");
11
+ const cliProgram = pipe(readCliOptions, Effect.flatMap((options) => options.token.length > 0
12
+ ? bootstrapProject(options)
13
+ : Effect.fail(new Error(formatUsage()))));
14
+ export const program = Effect.matchEffect(cliProgram, {
15
+ onFailure: (error) => Console.error(error.message),
16
+ onSuccess: (summary) => Console.log(formatSuccess(summary)),
17
+ });
@@ -0,0 +1,123 @@
1
+ export const DEFAULT_PROJECT_DIR = "spawndock-tma";
2
+ export const DEFAULT_CONTROL_PLANE_URL = "https://api.spawndock.app";
3
+ export const DEFAULT_CLAIM_PATH = "/v1/bootstrap/claim";
4
+ export const DEFAULT_TEMPLATE_REPO = "https://github.com/SpawnDock/tma-project.git";
5
+ export const DEFAULT_TEMPLATE_BRANCH = "master";
6
+ export const TEMPLATE_ID = "nextjs-template";
7
+ export const normalizeDisplayName = (value) => value
8
+ .split(/[^a-zA-Z0-9]+/g)
9
+ .filter(Boolean)
10
+ .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1).toLowerCase())
11
+ .join(" ");
12
+ export const resolveProjectContext = (projectDir) => {
13
+ const projectSlug = projectDir.split(/[\\/]/g).filter(Boolean).at(-1) ?? projectDir;
14
+ return {
15
+ projectDir,
16
+ projectSlug,
17
+ projectName: normalizeDisplayName(projectSlug),
18
+ templateId: TEMPLATE_ID,
19
+ };
20
+ };
21
+ export const resolvePreviewPath = (previewOrigin) => {
22
+ const url = new URL(previewOrigin);
23
+ const normalizedPath = url.pathname.replace(/\/$/, "");
24
+ return normalizedPath.length > 0 ? normalizedPath : "";
25
+ };
26
+ export const resolvePreviewHost = (previewOrigin) => new URL(previewOrigin).host;
27
+ export const buildMcpServerUrl = (controlPlaneUrl) => {
28
+ const url = new URL(controlPlaneUrl);
29
+ const normalizedPath = url.pathname.replace(/\/$/, "");
30
+ url.pathname = normalizedPath.length > 0 ? `${normalizedPath}/mcp/sse` : "/mcp/sse";
31
+ return url.toString();
32
+ };
33
+ export const buildTonConnectManifest = (context, claim) => `${JSON.stringify({
34
+ url: claim.previewOrigin,
35
+ name: context.projectName,
36
+ iconUrl: `${claim.previewOrigin}/favicon.ico`,
37
+ }, null, 2)}\n`;
38
+ export const buildGeneratedFiles = (context, claim) => {
39
+ const previewPath = resolvePreviewPath(claim.previewOrigin);
40
+ const previewHost = resolvePreviewHost(claim.previewOrigin);
41
+ const mcpServerUrl = buildMcpServerUrl(claim.controlPlaneUrl);
42
+ const appConfig = {
43
+ templateId: context.templateId,
44
+ projectId: claim.projectId,
45
+ projectSlug: claim.projectSlug,
46
+ projectName: context.projectName,
47
+ controlPlaneUrl: claim.controlPlaneUrl,
48
+ previewOrigin: claim.previewOrigin,
49
+ previewPath,
50
+ previewHost,
51
+ localPort: claim.localPort,
52
+ deviceSecret: claim.deviceSecret,
53
+ mcpServerUrl,
54
+ };
55
+ const env = {
56
+ SPAWNDOCK_CONTROL_PLANE_URL: claim.controlPlaneUrl,
57
+ SPAWNDOCK_PREVIEW_ORIGIN: claim.previewOrigin,
58
+ SPAWNDOCK_PREVIEW_PATH: previewPath,
59
+ SPAWNDOCK_ASSET_PREFIX: previewPath,
60
+ SPAWNDOCK_PREVIEW_HOST: previewHost,
61
+ SPAWNDOCK_SERVER_ACTIONS_ALLOWED_ORIGINS: previewHost,
62
+ SPAWNDOCK_DEVICE_SECRET: claim.deviceSecret,
63
+ SPAWNDOCK_PROJECT_ID: claim.projectId,
64
+ SPAWNDOCK_PROJECT_SLUG: claim.projectSlug,
65
+ SPAWNDOCK_ALLOWED_DEV_ORIGINS: claim.previewOrigin,
66
+ };
67
+ const opencode = {
68
+ $schema: "https://opencode.ai/config.json",
69
+ mcp: {
70
+ spawndock: {
71
+ type: "local",
72
+ command: ["npx", "-y", "@spawn-dock/mcp"],
73
+ enabled: true,
74
+ environment: {
75
+ MCP_SERVER_URL: mcpServerUrl,
76
+ },
77
+ },
78
+ },
79
+ };
80
+ return [
81
+ {
82
+ path: "spawndock.config.json",
83
+ content: `${JSON.stringify(appConfig, null, 2)}\n`,
84
+ },
85
+ {
86
+ path: ".env.local",
87
+ content: `${Object.entries(env)
88
+ .map(([key, value]) => `${key}=${value}`)
89
+ .join("\n")}\n`,
90
+ },
91
+ {
92
+ path: "spawndock.dev-tunnel.json",
93
+ content: `${JSON.stringify({
94
+ controlPlane: claim.controlPlaneUrl,
95
+ projectSlug: claim.projectSlug,
96
+ deviceSecret: claim.deviceSecret,
97
+ port: claim.localPort,
98
+ }, null, 2)}\n`,
99
+ },
100
+ {
101
+ path: "opencode.json",
102
+ content: `${JSON.stringify(opencode, null, 2)}\n`,
103
+ },
104
+ {
105
+ path: "public/tonconnect-manifest.json",
106
+ content: buildTonConnectManifest(context, claim),
107
+ },
108
+ ];
109
+ };
110
+ export const patchPackageJsonContent = (input) => {
111
+ const packageJson = JSON.parse(input);
112
+ packageJson.scripts = {
113
+ ...(packageJson.scripts ?? {}),
114
+ dev: "node ./spawndock/dev.mjs",
115
+ "dev:next": "node ./spawndock/next.mjs",
116
+ "dev:tunnel": "node ./spawndock/tunnel.mjs",
117
+ };
118
+ packageJson.devDependencies = {
119
+ ...(packageJson.devDependencies ?? {}),
120
+ "@spawn-dock/dev-tunnel": "latest",
121
+ };
122
+ return `${JSON.stringify(packageJson, null, 2)}\n`;
123
+ };
@@ -0,0 +1,201 @@
1
+ import { mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
2
+ import { spawnSync } from "node:child_process";
3
+ import { fileURLToPath } from "node:url";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { Effect } from "effect";
6
+ import { buildGeneratedFiles, patchPackageJsonContent, resolveProjectContext, } from "../core/bootstrap.js";
7
+ const TEMPLATE_OVERLAY_DIR = resolve(fileURLToPath(new URL("../../../template-nextjs-overlay", import.meta.url)));
8
+ export const bootstrapProject = (options) => Effect.gen(function* () {
9
+ if (options.token.length === 0) {
10
+ yield* Effect.fail(new Error("Missing pairing token."));
11
+ }
12
+ const projectDir = resolve(process.cwd(), options.projectDir);
13
+ const context = resolveProjectContext(projectDir);
14
+ yield* ensureEmptyProjectDir(projectDir);
15
+ yield* ensureParentDirectory(projectDir);
16
+ yield* cloneTemplateRepo(projectDir, options.templateRepo, options.templateBranch);
17
+ yield* applyTemplateOverlay(projectDir);
18
+ const claim = yield* claimProject(options.controlPlaneUrl, options.claimPath, {
19
+ token: options.token,
20
+ ["projectSlug"]: context.projectSlug,
21
+ projectName: context.projectName,
22
+ templateId: context.templateId,
23
+ ["localPort"]: 3000,
24
+ });
25
+ yield* writeGeneratedFilesToProject(projectDir, context, claim);
26
+ yield* installDependencies(projectDir);
27
+ return {
28
+ projectDir,
29
+ projectName: context.projectName,
30
+ previewOrigin: claim.previewOrigin,
31
+ };
32
+ });
33
+ const ensureEmptyProjectDir = (projectDir) => Effect.try({
34
+ try: () => {
35
+ try {
36
+ const entries = readdirSync(projectDir);
37
+ if (entries.length > 0) {
38
+ throw new Error(`Target directory is not empty: ${projectDir}`);
39
+ }
40
+ }
41
+ catch (error) {
42
+ if (isNodeError(error) && error.code === "ENOENT") {
43
+ return;
44
+ }
45
+ throw toError(error);
46
+ }
47
+ },
48
+ catch: toError,
49
+ });
50
+ const ensureParentDirectory = (projectDir) => Effect.try({
51
+ try: () => {
52
+ mkdirSync(dirname(projectDir), { recursive: true });
53
+ },
54
+ catch: toError,
55
+ });
56
+ const cloneTemplateRepo = (projectDir, templateRepo, templateBranch) => runCommand("git", [
57
+ "clone",
58
+ "--depth",
59
+ "1",
60
+ "--branch",
61
+ templateBranch,
62
+ templateRepo,
63
+ projectDir,
64
+ ]).pipe(Effect.asVoid);
65
+ const applyTemplateOverlay = (projectDir) => Effect.gen(function* () {
66
+ yield* copyOverlayTree(TEMPLATE_OVERLAY_DIR, projectDir);
67
+ yield* patchPackageJson(projectDir);
68
+ });
69
+ const copyOverlayTree = (sourceDir, targetDir) => Effect.try({
70
+ try: () => {
71
+ copyOverlayTreeSync(sourceDir, targetDir);
72
+ },
73
+ catch: toError,
74
+ });
75
+ const patchPackageJson = (projectDir) => Effect.try({
76
+ try: () => {
77
+ const packageJsonPath = join(projectDir, "package.json");
78
+ const content = readFileSync(packageJsonPath, "utf8");
79
+ writeFileSync(packageJsonPath, patchPackageJsonContent(content), "utf8");
80
+ },
81
+ catch: toError,
82
+ });
83
+ const claimProject = (controlPlaneUrl, claimPath, payload) => Effect.tryPromise({
84
+ try: async () => {
85
+ const normalizedControlPlaneUrl = controlPlaneUrl.replace(/\/$/, "");
86
+ const resolvedClaimPath = claimPath.startsWith("/") ? claimPath : `/${claimPath}`;
87
+ const response = await fetch(`${normalizedControlPlaneUrl}${resolvedClaimPath}`, {
88
+ method: "POST",
89
+ headers: {
90
+ "content-type": "application/json",
91
+ },
92
+ body: JSON.stringify(payload),
93
+ });
94
+ if (!response.ok) {
95
+ throw new Error(`SpawnDock control plane claim failed: ${response.status}`);
96
+ }
97
+ const json = (await response.json());
98
+ const parsed = parseClaimResponse(json, payload["projectSlug"], normalizedControlPlaneUrl, payload["localPort"]);
99
+ if (parsed === null) {
100
+ throw new Error("SpawnDock control plane response is missing required bootstrap fields");
101
+ }
102
+ return parsed;
103
+ },
104
+ catch: toError,
105
+ });
106
+ const writeGeneratedFilesToProject = (projectDir, context, claim) => Effect.try({
107
+ try: () => {
108
+ for (const file of buildGeneratedFiles(context, claim)) {
109
+ const targetPath = join(projectDir, file.path);
110
+ mkdirSync(dirname(targetPath), { recursive: true });
111
+ writeFileSync(targetPath, file.content, "utf8");
112
+ }
113
+ },
114
+ catch: toError,
115
+ });
116
+ const installDependencies = (projectDir) => Effect.gen(function* () {
117
+ const corepackResult = yield* runCommand("corepack", ["pnpm", "install"], projectDir, false);
118
+ if (corepackResult.status === 0) {
119
+ return;
120
+ }
121
+ yield* runCommand("pnpm", ["install"], projectDir);
122
+ });
123
+ const runCommand = (command, args, cwd = process.cwd(), failOnNonZero = true) => Effect.try({
124
+ try: () => {
125
+ const result = spawnSync(command, [...args], {
126
+ cwd,
127
+ encoding: "utf8",
128
+ stdio: "pipe",
129
+ });
130
+ if (failOnNonZero && result.status !== 0) {
131
+ throw new Error(result.stderr.trim() ||
132
+ result.stdout.trim() ||
133
+ `Command failed: ${command} ${args.join(" ")}`);
134
+ }
135
+ return result;
136
+ },
137
+ catch: toError,
138
+ });
139
+ const parseClaimResponse = (input, fallbackProjectSlug, fallbackControlPlaneUrl, fallbackLocalPort) => {
140
+ if (!isRecord(input)) {
141
+ return null;
142
+ }
143
+ const projectValue = input["project"];
144
+ const project = isRecord(projectValue) ? projectValue : {};
145
+ const projectId = readString(input, "projectId") ?? readString(project, "id");
146
+ const projectSlug = readString(input, "projectSlug") ??
147
+ readString(input, "slug") ??
148
+ readString(project, "slug") ??
149
+ (typeof fallbackProjectSlug === "string" ? fallbackProjectSlug : null);
150
+ const controlPlaneUrl = readString(input, "controlPlaneUrl") ?? fallbackControlPlaneUrl;
151
+ const previewOrigin = readString(input, "previewOrigin") ??
152
+ readString(input, "launchUrl") ??
153
+ readString(input, "staticAssetsBaseUrl") ??
154
+ readString(input, "url");
155
+ const deviceSecret = readString(input, "deviceSecret") ??
156
+ readString(input, "deviceToken") ??
157
+ readString(input, "deployToken") ??
158
+ readString(input, "token");
159
+ const localPort = readNumber(input, "localPort") ??
160
+ (typeof fallbackLocalPort === "number" ? fallbackLocalPort : 3000);
161
+ if (projectId === null ||
162
+ projectSlug === null ||
163
+ previewOrigin === null ||
164
+ deviceSecret === null) {
165
+ return null;
166
+ }
167
+ return {
168
+ projectId,
169
+ projectSlug,
170
+ controlPlaneUrl,
171
+ previewOrigin,
172
+ deviceSecret,
173
+ localPort,
174
+ };
175
+ };
176
+ const readNumber = (input, key) => {
177
+ const value = input[key];
178
+ return typeof value === "number" ? value : null;
179
+ };
180
+ const readString = (input, key) => {
181
+ const value = input[key];
182
+ return typeof value === "string" ? value : null;
183
+ };
184
+ const isRecord = (value) => typeof value === "object" && value !== null;
185
+ const isNodeError = (error) => error instanceof Error;
186
+ const toError = (cause) => cause instanceof Error ? cause : new Error(String(cause));
187
+ const copyOverlayTreeSync = (sourceDir, targetDir) => {
188
+ const entries = readdirSync(sourceDir, { withFileTypes: true });
189
+ for (const entry of entries) {
190
+ const sourcePath = join(sourceDir, entry.name);
191
+ const targetPath = join(targetDir, entry.name);
192
+ if (entry.isDirectory()) {
193
+ mkdirSync(targetPath, { recursive: true });
194
+ copyOverlayTreeSync(sourcePath, targetPath);
195
+ continue;
196
+ }
197
+ const content = readFileSync(sourcePath, "utf8");
198
+ mkdirSync(dirname(targetPath), { recursive: true });
199
+ writeFileSync(targetPath, content, "utf8");
200
+ }
201
+ };
@@ -0,0 +1,72 @@
1
+ import { Effect } from "effect";
2
+ import { DEFAULT_CLAIM_PATH, DEFAULT_CONTROL_PLANE_URL, DEFAULT_PROJECT_DIR, DEFAULT_TEMPLATE_BRANCH, DEFAULT_TEMPLATE_REPO, } from "../core/bootstrap.js";
3
+ export const formatUsage = (invocation = "npx @spawn-dock/create --token <pairing-token> [project-dir]") => `Usage: ${invocation}`;
4
+ export const parseArgs = (argv, env = process.env) => {
5
+ const result = {
6
+ token: "",
7
+ controlPlaneUrl: env["SPAWNDOCK_CONTROL_PLANE_URL"] ?? DEFAULT_CONTROL_PLANE_URL,
8
+ claimPath: env["SPAWNDOCK_CLAIM_PATH"] ?? DEFAULT_CLAIM_PATH,
9
+ projectDir: DEFAULT_PROJECT_DIR,
10
+ templateRepo: env["SPAWNDOCK_TEMPLATE_REPO"] ?? DEFAULT_TEMPLATE_REPO,
11
+ templateBranch: env["SPAWNDOCK_TEMPLATE_BRANCH"] ?? DEFAULT_TEMPLATE_BRANCH,
12
+ };
13
+ for (let index = 0; index < argv.length; index += 1) {
14
+ const value = argv[index];
15
+ if (value === undefined) {
16
+ continue;
17
+ }
18
+ if (value === "--token") {
19
+ result.token = argv[index + 1] ?? "";
20
+ index += 1;
21
+ continue;
22
+ }
23
+ if (value.startsWith("--token=")) {
24
+ result.token = value.slice("--token=".length);
25
+ continue;
26
+ }
27
+ if (value === "--control-plane-url") {
28
+ result.controlPlaneUrl = argv[index + 1] ?? DEFAULT_CONTROL_PLANE_URL;
29
+ index += 1;
30
+ continue;
31
+ }
32
+ if (value.startsWith("--control-plane-url=")) {
33
+ result.controlPlaneUrl = value.slice("--control-plane-url=".length);
34
+ continue;
35
+ }
36
+ if (value === "--claim-path") {
37
+ result.claimPath = argv[index + 1] ?? DEFAULT_CLAIM_PATH;
38
+ index += 1;
39
+ continue;
40
+ }
41
+ if (value.startsWith("--claim-path=")) {
42
+ result.claimPath = value.slice("--claim-path=".length);
43
+ continue;
44
+ }
45
+ if (value === "--template-repo") {
46
+ result.templateRepo = argv[index + 1] ?? DEFAULT_TEMPLATE_REPO;
47
+ index += 1;
48
+ continue;
49
+ }
50
+ if (value.startsWith("--template-repo=")) {
51
+ result.templateRepo = value.slice("--template-repo=".length);
52
+ continue;
53
+ }
54
+ if (value === "--template-branch") {
55
+ result.templateBranch = argv[index + 1] ?? DEFAULT_TEMPLATE_BRANCH;
56
+ index += 1;
57
+ continue;
58
+ }
59
+ if (value.startsWith("--template-branch=")) {
60
+ result.templateBranch = value.slice("--template-branch=".length);
61
+ continue;
62
+ }
63
+ if (value.startsWith("--")) {
64
+ continue;
65
+ }
66
+ if (result.projectDir === DEFAULT_PROJECT_DIR) {
67
+ result.projectDir = value;
68
+ }
69
+ }
70
+ return result;
71
+ };
72
+ export const readCliOptions = Effect.sync(() => parseArgs(process.argv.slice(2), process.env));
@@ -0,0 +1,35 @@
1
+ import type { NextConfig } from "next"
2
+ import createNextIntlPlugin from "next-intl/plugin"
3
+
4
+ const withNextIntl = createNextIntlPlugin("./src/core/i18n/i18n.ts")
5
+
6
+ const parseAllowedOrigins = (value: string | undefined): Array<string> =>
7
+ value
8
+ ? value
9
+ .split(",")
10
+ .map((origin) => origin.trim())
11
+ .filter(Boolean)
12
+ : []
13
+
14
+ const allowedDevOrigins = parseAllowedOrigins(
15
+ process.env.SPAWNDOCK_ALLOWED_DEV_ORIGINS,
16
+ )
17
+ const previewPath = process.env.SPAWNDOCK_PREVIEW_PATH
18
+ const serverActionOrigins = parseAllowedOrigins(
19
+ process.env.SPAWNDOCK_SERVER_ACTIONS_ALLOWED_ORIGINS,
20
+ )
21
+ const normalizedPreviewPath =
22
+ previewPath && previewPath.length > 0 ? previewPath : undefined
23
+
24
+ const nextConfig: NextConfig = {
25
+ allowedDevOrigins,
26
+ assetPrefix: normalizedPreviewPath,
27
+ basePath: normalizedPreviewPath,
28
+ experimental: {
29
+ serverActions: {
30
+ allowedOrigins: serverActionOrigins,
31
+ },
32
+ },
33
+ }
34
+
35
+ export default withNextIntl(nextConfig)
@@ -0,0 +1,5 @@
1
+ {
2
+ "url": "__SPAWNDOCK_PREVIEW_ORIGIN__",
3
+ "name": "__SPAWNDOCK_PROJECT_NAME__",
4
+ "iconUrl": "__SPAWNDOCK_PREVIEW_ORIGIN__/favicon.ico"
5
+ }
@@ -0,0 +1,14 @@
1
+ import { readFileSync } from "node:fs"
2
+ import { resolve } from "node:path"
3
+
4
+ export const readSpawndockConfig = (cwd = process.cwd()) =>
5
+ JSON.parse(readFileSync(resolve(cwd, "spawndock.config.json"), "utf8"))
6
+
7
+ export const resolveLocalOrigin = (config) =>
8
+ `http://127.0.0.1:${config.localPort ?? 3000}`
9
+
10
+ export const resolvePreviewOrigin = (config) =>
11
+ config.previewOrigin ?? ""
12
+
13
+ export const resolveAllowedDevOrigins = (config) =>
14
+ [config.previewOrigin].filter(Boolean)
@@ -0,0 +1,48 @@
1
+ import { spawn } from "node:child_process"
2
+ import { setTimeout } from "node:timers"
3
+
4
+ const scripts = [
5
+ ["node", ["spawndock/next.mjs"]],
6
+ ["node", ["spawndock/tunnel.mjs"]],
7
+ ]
8
+
9
+ const children = []
10
+
11
+ const stopChildren = (signal) => {
12
+ for (const child of children) {
13
+ if (!child.killed) {
14
+ child.kill(signal ?? "SIGTERM")
15
+ }
16
+ }
17
+ }
18
+
19
+ process.on("SIGINT", () => {
20
+ stopChildren("SIGINT")
21
+ process.exit(0)
22
+ })
23
+
24
+ process.on("SIGTERM", () => {
25
+ stopChildren("SIGTERM")
26
+ process.exit(0)
27
+ })
28
+
29
+ for (const [command, args] of scripts) {
30
+ const child = spawn(command, args, {
31
+ cwd: process.cwd(),
32
+ env: process.env,
33
+ stdio: "inherit",
34
+ })
35
+
36
+ children.push(child)
37
+
38
+ child.on("exit", (code) => {
39
+ if (typeof code === "number" && code !== 0) {
40
+ stopChildren()
41
+ process.exit(code)
42
+ }
43
+ })
44
+ }
45
+
46
+ setTimeout(() => {
47
+ console.log("SpawnDock dev session is ready.")
48
+ }, 0)
@@ -0,0 +1,69 @@
1
+ import { spawn } from "node:child_process"
2
+ import readline from "node:readline"
3
+
4
+ import { readSpawndockConfig, resolveAllowedDevOrigins } from "./config.mjs"
5
+
6
+ const config = readSpawndockConfig()
7
+ const localPort = Number(config.localPort ?? 3000)
8
+ const allowedOrigins = resolveAllowedDevOrigins(config)
9
+ const previewOrigin = config.previewOrigin ?? ""
10
+
11
+ const child = spawn("pnpm", ["exec", "next", "dev", "-p", String(localPort)], {
12
+ cwd: process.cwd(),
13
+ env: {
14
+ ...process.env,
15
+ SPAWNDOCK_ALLOWED_DEV_ORIGINS: allowedOrigins.join(","),
16
+ SPAWNDOCK_PREVIEW_PATH: config.previewPath ?? "",
17
+ SPAWNDOCK_ASSET_PREFIX: config.previewPath ?? "",
18
+ SPAWNDOCK_SERVER_ACTIONS_ALLOWED_ORIGINS: config.previewHost ?? "",
19
+ },
20
+ stdio: ["inherit", "pipe", "pipe"],
21
+ })
22
+
23
+ const exitWithChild = (code) => {
24
+ process.exit(typeof code === "number" ? code : 1)
25
+ }
26
+
27
+ const rewriteNextLine = (line) => {
28
+ if (previewOrigin.length === 0) {
29
+ return line
30
+ }
31
+
32
+ if (line.includes("Local:")) {
33
+ return ` - Local: ${previewOrigin}`
34
+ }
35
+
36
+ if (line.includes("Network:")) {
37
+ return ` - Network: ${previewOrigin}`
38
+ }
39
+
40
+ return line
41
+ }
42
+
43
+ const forwardStream = (stream, writer) => {
44
+ if (!stream) {
45
+ return
46
+ }
47
+
48
+ const interfaceHandle = readline.createInterface({
49
+ input: stream,
50
+ })
51
+
52
+ interfaceHandle.on("line", (line) => {
53
+ writer(`${rewriteNextLine(line)}\n`)
54
+ })
55
+ }
56
+
57
+ forwardStream(child.stdout, (chunk) => {
58
+ process.stdout.write(chunk)
59
+ })
60
+
61
+ forwardStream(child.stderr, (chunk) => {
62
+ process.stderr.write(chunk)
63
+ })
64
+
65
+ child.on("exit", exitWithChild)
66
+ child.on("error", (error) => {
67
+ console.error(error)
68
+ process.exit(1)
69
+ })
@@ -0,0 +1,16 @@
1
+ import { spawn } from "node:child_process"
2
+
3
+ const child = spawn("pnpm", ["exec", "spawn-dock-tunnel"], {
4
+ cwd: process.cwd(),
5
+ env: process.env,
6
+ stdio: "inherit",
7
+ })
8
+
9
+ child.on("exit", (code) => {
10
+ process.exit(typeof code === "number" ? code : 1)
11
+ })
12
+
13
+ child.on("error", (error) => {
14
+ console.error(error)
15
+ process.exit(1)
16
+ })