@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 +55 -0
- package/package.json +48 -0
- package/packages/app/dist/src/app/main.js +7 -0
- package/packages/app/dist/src/app/program.js +17 -0
- package/packages/app/dist/src/core/bootstrap.js +123 -0
- package/packages/app/dist/src/shell/bootstrap.js +201 -0
- package/packages/app/dist/src/shell/cli.js +72 -0
- package/packages/app/template-nextjs-overlay/next.config.ts +35 -0
- package/packages/app/template-nextjs-overlay/public/tonconnect-manifest.json +5 -0
- package/packages/app/template-nextjs-overlay/spawndock/config.mjs +14 -0
- package/packages/app/template-nextjs-overlay/spawndock/dev.mjs +48 -0
- package/packages/app/template-nextjs-overlay/spawndock/next.mjs +69 -0
- package/packages/app/template-nextjs-overlay/spawndock/tunnel.mjs +16 -0
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,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,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
|
+
})
|