@usequark/quark-create-app 1.25.2 → 1.25.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.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@usequark/quark-create-app",
|
|
3
|
-
"version": "1.25.
|
|
3
|
+
"version": "1.25.4",
|
|
4
4
|
"description": "CLI to scaffold new Quark projects with Next.js, Prisma, BullMQ, and Railway deployment",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"quark",
|
|
@@ -36,6 +36,9 @@
|
|
|
36
36
|
"templates",
|
|
37
37
|
"README.md"
|
|
38
38
|
],
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"esbuild": "^0.28.2"
|
|
41
|
+
},
|
|
39
42
|
"dependencies": {
|
|
40
43
|
"chalk": "^6.0.0",
|
|
41
44
|
"commander": "^15.0.0",
|
|
@@ -2,6 +2,48 @@ import { execa } from "execa";
|
|
|
2
2
|
|
|
3
3
|
const RAILWAY = "railway";
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Every helper `railway/iac` exports that can appear in a generated body.
|
|
7
|
+
*
|
|
8
|
+
* The import list is derived from the helpers the body actually calls, rather
|
|
9
|
+
* than hardcoded. A helper that is called but not imported is a ReferenceError
|
|
10
|
+
* when the CLI evaluates the file, and that evaluation only happens later, in
|
|
11
|
+
* the user's `railway config apply` — so nothing in this repo ever surfaced it.
|
|
12
|
+
* Deriving the list makes the two impossible to drift apart.
|
|
13
|
+
*/
|
|
14
|
+
const IAC_HELPERS = [
|
|
15
|
+
"bucket",
|
|
16
|
+
"database",
|
|
17
|
+
"defineRailway",
|
|
18
|
+
"group",
|
|
19
|
+
"image",
|
|
20
|
+
"postgres",
|
|
21
|
+
"preserve",
|
|
22
|
+
"project",
|
|
23
|
+
"redis",
|
|
24
|
+
"service",
|
|
25
|
+
"volume",
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Builds a Railway variable reference as a TypeScript *string literal*.
|
|
30
|
+
*
|
|
31
|
+
* Railway's `${{Service.VAR}}` syntax is only reference syntax inside a quoted
|
|
32
|
+
* string. Emitted bare it is not a valid expression — `${{` parses as an object
|
|
33
|
+
* literal that opens and never closes — so the file fails to parse before
|
|
34
|
+
* Railway ever sees it. The value must therefore carry its own quotes.
|
|
35
|
+
*
|
|
36
|
+
* Only for values embedded in generated TypeScript. A value passed to
|
|
37
|
+
* `railway variable set` on a command line wants the bare, unquoted form.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} serviceName - Railway service the variable lives on
|
|
40
|
+
* @param {string} variableName - Variable name on that service
|
|
41
|
+
* @returns {string} A quoted reference, e.g. `"${{Postgres.DATABASE_URL}}"`
|
|
42
|
+
*/
|
|
43
|
+
export function iacRef(serviceName, variableName) {
|
|
44
|
+
return `"$\{{${serviceName}.${variableName}}}"`;
|
|
45
|
+
}
|
|
46
|
+
|
|
5
47
|
/**
|
|
6
48
|
* Escapes a string for safe embedding in a TypeScript string literal.
|
|
7
49
|
* Handles backslashes, quotes, newlines, and template literal interpolation.
|
|
@@ -53,6 +95,9 @@ function toIdentifier(value) {
|
|
|
53
95
|
* @param {object} options.variableRefs - Map of variable key → Railway reference string
|
|
54
96
|
* @param {object} options.serviceVars - Map of service name → extra env vars
|
|
55
97
|
* @param {string} [options.projectName] - Railway project name (defaults to "quark-app")
|
|
98
|
+
* @returns {Promise<{content: string, changed: boolean, previous: string|null}>}
|
|
99
|
+
* `changed` is false when the file on disk already matched, in which case no
|
|
100
|
+
* write happened. `previous` is the content that was replaced, or null.
|
|
56
101
|
*/
|
|
57
102
|
export async function generateIacFile({
|
|
58
103
|
iacPath,
|
|
@@ -65,9 +110,6 @@ export async function generateIacFile({
|
|
|
65
110
|
const { default: fs } = await import("fs-extra");
|
|
66
111
|
const { dirname } = await import("node:path");
|
|
67
112
|
|
|
68
|
-
const imports = new Set();
|
|
69
|
-
imports.add(`import { defineRailway, project, service } from "railway/iac";`);
|
|
70
|
-
|
|
71
113
|
const resourceLines = [];
|
|
72
114
|
|
|
73
115
|
for (const svc of services) {
|
|
@@ -88,13 +130,24 @@ export async function generateIacFile({
|
|
|
88
130
|
|
|
89
131
|
const preDeploy = "pnpm db:migrate:deploy";
|
|
90
132
|
|
|
133
|
+
// Each entry is one complete `key: value` field, and the object body is
|
|
134
|
+
// rendered by joining them. Concatenating pre-rendered blocks instead
|
|
135
|
+
// means each block has to guess whether it owns the separator between it
|
|
136
|
+
// and its neighbour — and when two blocks both assume they do, the file
|
|
137
|
+
// ships with `healthcheckTimeout: 120,,`, which does not parse. A field
|
|
138
|
+
// list makes that unrepresentable: there is exactly one join.
|
|
139
|
+
const fields = [
|
|
140
|
+
`build: "${escapeTsString(buildCmd)}"`,
|
|
141
|
+
`start: "${escapeTsString(startCmd)}"`,
|
|
142
|
+
`preDeploy: "${escapeTsString(preDeploy)}"`,
|
|
143
|
+
];
|
|
144
|
+
|
|
91
145
|
// Healthcheck (web only)
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
: "";
|
|
146
|
+
if (svc.kind === "web") {
|
|
147
|
+
fields.push('healthcheck: "/api/health"', "healthcheckTimeout: 120");
|
|
148
|
+
}
|
|
96
149
|
|
|
97
|
-
//
|
|
150
|
+
// Env vars — escape all string values
|
|
98
151
|
const extraVars = serviceVars[svc.name] || {};
|
|
99
152
|
const envEntries = [
|
|
100
153
|
...Object.entries(variableRefs),
|
|
@@ -102,33 +155,61 @@ export async function generateIacFile({
|
|
|
102
155
|
...Object.entries(extraVars),
|
|
103
156
|
];
|
|
104
157
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
158
|
+
if (envEntries.length > 0) {
|
|
159
|
+
const envLines = envEntries
|
|
160
|
+
.map(([k, v]) => `\t\t\t${escapeTsString(k)}: ${v},`)
|
|
161
|
+
.join("\n");
|
|
162
|
+
fields.push(`env: {\n${envLines}\n\t\t}`);
|
|
163
|
+
}
|
|
109
164
|
|
|
110
|
-
resourceLines.push(
|
|
111
|
-
\t\
|
|
112
|
-
|
|
113
|
-
\t\tpreDeploy: "${escapeTsString(preDeploy)}",${healthcheck}${envBlock}
|
|
114
|
-
\t});`);
|
|
165
|
+
resourceLines.push(
|
|
166
|
+
`\tconst ${svcIdentifier} = service("${svcLabel}", {\n\t\t${fields.join(",\n\t\t")},\n\t});`,
|
|
167
|
+
);
|
|
115
168
|
}
|
|
116
169
|
|
|
117
170
|
const resources = services.map((s) => toIdentifier(s.name)).join(", ");
|
|
118
171
|
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
export default defineRailway(() => {
|
|
172
|
+
const body = `export default defineRailway(() => {
|
|
122
173
|
${resourceLines.join("\n\n")}
|
|
123
174
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
175
|
+
return project("${escapeTsString(projectName)}", {
|
|
176
|
+
resources: [${resources}],
|
|
177
|
+
});
|
|
127
178
|
});
|
|
128
179
|
`;
|
|
129
180
|
|
|
181
|
+
// Derive the import list from the body so a helper can never be called
|
|
182
|
+
// without being in scope. See IAC_HELPERS.
|
|
183
|
+
const used = IAC_HELPERS.filter((helper) =>
|
|
184
|
+
new RegExp(`\\b${helper}\\s*\\(`).test(body),
|
|
185
|
+
).sort();
|
|
186
|
+
|
|
187
|
+
const content = `import { ${used.join(", ")} } from "railway/iac";
|
|
188
|
+
|
|
189
|
+
${body}`;
|
|
190
|
+
|
|
130
191
|
await fs.ensureDir(dirname(iacPath));
|
|
131
|
-
|
|
192
|
+
|
|
193
|
+
// `.railway/railway.ts` is a tracked file that every scaffold ships with a
|
|
194
|
+
// hand-written copy of. Overwriting it silently is how a deploy once
|
|
195
|
+
// replaced a committed file with generated output and the user only found
|
|
196
|
+
// out from `git status` afterwards. So: skip the write when nothing changed,
|
|
197
|
+
// and hand the previous content back when something did, so the caller can
|
|
198
|
+
// show the diff. The file is tracked, so `git checkout` remains the escape
|
|
199
|
+
// hatch — this only makes the change visible at the moment it happens.
|
|
200
|
+
let previous = null;
|
|
201
|
+
try {
|
|
202
|
+
previous = await fs.readFile(iacPath, "utf8");
|
|
203
|
+
} catch {
|
|
204
|
+
previous = null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const changed = previous !== content;
|
|
208
|
+
if (changed) {
|
|
209
|
+
await fs.writeFile(iacPath, content, "utf8");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return { content, changed, previous };
|
|
132
213
|
}
|
|
133
214
|
|
|
134
215
|
/**
|
|
@@ -153,18 +234,68 @@ export async function applyIacConfig({ cwd, timeout = 120_000 } = {}) {
|
|
|
153
234
|
}
|
|
154
235
|
}
|
|
155
236
|
|
|
237
|
+
/**
|
|
238
|
+
* Checks whether the `railway` package already resolves from `cwd`.
|
|
239
|
+
*
|
|
240
|
+
* Walks up from `cwd` the way Node's resolver does, so a hoisted install in a
|
|
241
|
+
* pnpm workspace root is found from a package directory too.
|
|
242
|
+
*
|
|
243
|
+
* @param {object} options
|
|
244
|
+
* @param {string} options.cwd - Project root directory
|
|
245
|
+
* @returns {Promise<boolean>}
|
|
246
|
+
*/
|
|
247
|
+
export async function hasRailwaySdk({ cwd } = {}) {
|
|
248
|
+
const { default: fs } = await import("fs-extra");
|
|
249
|
+
const { dirname, join, parse, resolve } = await import("node:path");
|
|
250
|
+
|
|
251
|
+
const root = parse(resolve(cwd)).root;
|
|
252
|
+
let dir = resolve(cwd);
|
|
253
|
+
|
|
254
|
+
while (dir !== root) {
|
|
255
|
+
if (
|
|
256
|
+
await fs.pathExists(join(dir, "node_modules", "railway", "package.json"))
|
|
257
|
+
) {
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
dir = dirname(dir);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
|
|
156
266
|
/**
|
|
157
267
|
* Installs the Railway SDK (required for TypeScript IaC evaluation).
|
|
158
268
|
*
|
|
269
|
+
* No-op when the package already resolves, so a repeat deploy does not touch
|
|
270
|
+
* package.json or the lockfile again.
|
|
271
|
+
*
|
|
272
|
+
* `-w` is passed explicitly because every scaffolded project is a pnpm
|
|
273
|
+
* workspace and the SDK is added at the root. Current pnpm versions accept the
|
|
274
|
+
* bare form, but older ones reject it with `ERR_PNPM_ADDING_TO_ROOT` and name
|
|
275
|
+
* the same command as the remedy, so being explicit is the only version this
|
|
276
|
+
* has to reason about.
|
|
277
|
+
*
|
|
159
278
|
* @param {object} options
|
|
160
279
|
* @param {string} options.cwd - Project root directory
|
|
161
280
|
* @param {string} [options.packageManager="pnpm"] - Package manager to use
|
|
281
|
+
* @returns {Promise<{installed: boolean, alreadyPresent: boolean}>}
|
|
162
282
|
*/
|
|
163
283
|
export async function installRailwaySdk({ cwd, packageManager = "pnpm" } = {}) {
|
|
164
|
-
await
|
|
284
|
+
if (await hasRailwaySdk({ cwd })) {
|
|
285
|
+
return { installed: false, alreadyPresent: true };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const args = ["add", "-D"];
|
|
289
|
+
if (packageManager === "pnpm") {
|
|
290
|
+
args.push("-w");
|
|
291
|
+
}
|
|
292
|
+
args.push("railway");
|
|
293
|
+
|
|
294
|
+
await execa(packageManager, args, {
|
|
165
295
|
cwd,
|
|
166
296
|
timeout: 60_000,
|
|
167
297
|
});
|
|
298
|
+
return { installed: true, alreadyPresent: false };
|
|
168
299
|
}
|
|
169
300
|
|
|
170
301
|
/**
|
|
@@ -3,6 +3,8 @@ export {
|
|
|
3
3
|
escapeTsString,
|
|
4
4
|
generateIacFile,
|
|
5
5
|
getDeploymentStatus,
|
|
6
|
+
hasRailwaySdk,
|
|
7
|
+
iacRef,
|
|
6
8
|
installRailwaySdk,
|
|
7
9
|
} from "./iac.js";
|
|
8
10
|
export {
|
|
@@ -21,7 +23,9 @@ export {
|
|
|
21
23
|
listProjects,
|
|
22
24
|
RAILWAY,
|
|
23
25
|
RailwayError,
|
|
26
|
+
readLinkedProject,
|
|
24
27
|
removeServiceDomain,
|
|
28
|
+
resolveProjectName,
|
|
25
29
|
setProjectVariables,
|
|
26
30
|
tryLinkProject,
|
|
27
31
|
} from "./railway.js";
|
|
@@ -42,13 +42,8 @@ export async function checkRailwayLogin() {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
export async function isProjectLinked(cwd) {
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
const entries = await fs.readdir(railwayDir);
|
|
48
|
-
return entries.some((e) => e.endsWith(".json"));
|
|
49
|
-
} catch {
|
|
50
|
-
return false;
|
|
51
|
-
}
|
|
45
|
+
const { projectId } = await readLinkedProject({ cwd });
|
|
46
|
+
return projectId !== null;
|
|
52
47
|
}
|
|
53
48
|
|
|
54
49
|
export async function tryLinkProject(projectId, { cwd } = {}) {
|
|
@@ -76,6 +71,124 @@ export async function listProjects({ cwd } = {}) {
|
|
|
76
71
|
}
|
|
77
72
|
}
|
|
78
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Reads the Railway project this directory is linked to.
|
|
76
|
+
*
|
|
77
|
+
* Two layouts are in play, because the CLI moved:
|
|
78
|
+
*
|
|
79
|
+
* - Railway CLI 3.x wrote `.railway/config.json` inside the project directory,
|
|
80
|
+
* holding only `projectId` — no name to be found in it.
|
|
81
|
+
* - Railway CLI 5.x (verified against 5.62.1) keeps every link in
|
|
82
|
+
* `~/.railway/config.json`, in a `projects` map keyed by absolute directory,
|
|
83
|
+
* with `project` (the id) and `name` alongside it. A linked project
|
|
84
|
+
* directory contains `.railway/railway.ts` and nothing else.
|
|
85
|
+
*
|
|
86
|
+
* Both are read, per-project first. The `~/.railway` file also holds the user's
|
|
87
|
+
* OAuth tokens; only the `projects` map is read and nothing else from it is
|
|
88
|
+
* ever returned or logged.
|
|
89
|
+
*
|
|
90
|
+
* @param {object} options
|
|
91
|
+
* @param {string} options.cwd - Project root directory
|
|
92
|
+
* @returns {Promise<{projectId: string|null, projectName: string|null}>}
|
|
93
|
+
*/
|
|
94
|
+
export async function readLinkedProject({ cwd } = {}) {
|
|
95
|
+
const fromProjectDir = await readProjectDirLink(cwd);
|
|
96
|
+
if (fromProjectDir.projectId) return fromProjectDir;
|
|
97
|
+
|
|
98
|
+
return readUserConfigLink(cwd);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Railway CLI 3.x layout: `.railway/config.json` in the project directory. */
|
|
102
|
+
async function readProjectDirLink(cwd) {
|
|
103
|
+
const railwayDir = path.join(cwd, ".railway");
|
|
104
|
+
try {
|
|
105
|
+
const entries = await fs.readdir(railwayDir);
|
|
106
|
+
for (const entry of entries.filter((e) => e.endsWith(".json"))) {
|
|
107
|
+
try {
|
|
108
|
+
const parsed = JSON.parse(
|
|
109
|
+
await fs.readFile(path.join(railwayDir, entry), "utf8"),
|
|
110
|
+
);
|
|
111
|
+
const projectId = parsed?.projectId ?? null;
|
|
112
|
+
const projectName = parsed?.projectName ?? parsed?.name ?? null;
|
|
113
|
+
if (projectId || projectName) return { projectId, projectName };
|
|
114
|
+
} catch {
|
|
115
|
+
// Not the file we were after, or unparseable — try the next.
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
} catch {
|
|
119
|
+
// No .railway directory, or not readable.
|
|
120
|
+
}
|
|
121
|
+
return { projectId: null, projectName: null };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Railway CLI 5.x layout: `~/.railway/config.json`, `projects` keyed by
|
|
126
|
+
* absolute directory path.
|
|
127
|
+
*/
|
|
128
|
+
async function readUserConfigLink(cwd) {
|
|
129
|
+
const empty = { projectId: null, projectName: null };
|
|
130
|
+
|
|
131
|
+
const home = process.env.HOME ?? process.env.USERPROFILE;
|
|
132
|
+
if (!home) return empty;
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
const parsed = JSON.parse(
|
|
136
|
+
await fs.readFile(path.join(home, ".railway", "config.json"), "utf8"),
|
|
137
|
+
);
|
|
138
|
+
const projects = parsed?.projects;
|
|
139
|
+
if (!projects || typeof projects !== "object") return empty;
|
|
140
|
+
|
|
141
|
+
const target = path.resolve(cwd);
|
|
142
|
+
const entry =
|
|
143
|
+
projects[target] ??
|
|
144
|
+
Object.values(projects).find(
|
|
145
|
+
(v) =>
|
|
146
|
+
typeof v?.projectPath === "string" &&
|
|
147
|
+
path.resolve(v.projectPath) === target,
|
|
148
|
+
);
|
|
149
|
+
if (!entry) return empty;
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
projectId: entry.project ?? null,
|
|
153
|
+
projectName: entry.name ?? null,
|
|
154
|
+
};
|
|
155
|
+
} catch {
|
|
156
|
+
// No user config, unparseable, or unreadable — treat as unlinked.
|
|
157
|
+
return empty;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Resolves a project id to its name via the API, for when the link record
|
|
163
|
+
* carries only an id.
|
|
164
|
+
*
|
|
165
|
+
* @param {string} projectId
|
|
166
|
+
* @param {object} options
|
|
167
|
+
* @param {string} options.cwd - Project root directory
|
|
168
|
+
* @returns {Promise<string|null>}
|
|
169
|
+
*/
|
|
170
|
+
export async function resolveProjectName(projectId, { cwd } = {}) {
|
|
171
|
+
if (!projectId) return null;
|
|
172
|
+
const match = (await listProjects({ cwd })).find((p) => p.id === projectId);
|
|
173
|
+
return match?.name ?? null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Best-effort name for a linked project: the link record when it carries one,
|
|
178
|
+
* otherwise resolved from its id.
|
|
179
|
+
*
|
|
180
|
+
* @param {object} options
|
|
181
|
+
* @param {string} options.cwd - Project root directory
|
|
182
|
+
* @returns {Promise<{projectId: string|null, projectName: string|null}>}
|
|
183
|
+
*/
|
|
184
|
+
async function linkedProjectWithName({ cwd }) {
|
|
185
|
+
const linked = await readLinkedProject({ cwd });
|
|
186
|
+
if (linked.projectName || !linked.projectId) return linked;
|
|
187
|
+
|
|
188
|
+
const projectName = await resolveProjectName(linked.projectId, { cwd });
|
|
189
|
+
return { projectId: linked.projectId, projectName };
|
|
190
|
+
}
|
|
191
|
+
|
|
79
192
|
export async function ensureRailwayProject({
|
|
80
193
|
projectName,
|
|
81
194
|
projectId,
|
|
@@ -86,11 +199,26 @@ export async function ensureRailwayProject({
|
|
|
86
199
|
if (alreadyLinked) {
|
|
87
200
|
try {
|
|
88
201
|
await execa(RAILWAY, ["status"], { cwd, timeout: 15_000 });
|
|
89
|
-
|
|
202
|
+
// Report the linked name rather than null: callers label generated
|
|
203
|
+
// IaC and app metadata from it, and falling back to the local
|
|
204
|
+
// directory name is how a project called `quark-site` ended up
|
|
205
|
+
// advertising itself as whatever directory it was deployed from.
|
|
206
|
+
const linked = await linkedProjectWithName({ cwd });
|
|
207
|
+
return {
|
|
208
|
+
created: false,
|
|
209
|
+
linked: true,
|
|
210
|
+
projectName: linked.projectName,
|
|
211
|
+
};
|
|
90
212
|
} catch {
|
|
91
|
-
// Stale link -
|
|
92
|
-
|
|
93
|
-
|
|
213
|
+
// Stale link - clear it so we don't create an orphan project.
|
|
214
|
+
// `railway unlink` is what actually removes the link; it lives in
|
|
215
|
+
// ~/.railway on CLI 5.x, so deleting .railway/ here would only
|
|
216
|
+
// destroy the IaC file while leaving the stale link in place.
|
|
217
|
+
try {
|
|
218
|
+
await execa(RAILWAY, ["unlink"], { cwd, timeout: 15_000 });
|
|
219
|
+
} catch {
|
|
220
|
+
// Nothing to unlink, or it failed — fall through either way.
|
|
221
|
+
}
|
|
94
222
|
}
|
|
95
223
|
}
|
|
96
224
|
|
|
@@ -99,7 +227,8 @@ export async function ensureRailwayProject({
|
|
|
99
227
|
cwd,
|
|
100
228
|
timeout: 30_000,
|
|
101
229
|
});
|
|
102
|
-
|
|
230
|
+
const projectName = await resolveProjectName(projectId, { cwd });
|
|
231
|
+
return { created: false, linked: true, projectName };
|
|
103
232
|
}
|
|
104
233
|
|
|
105
234
|
if (!projectName) {
|
package/src/deploy/deploy.js
CHANGED
|
@@ -12,6 +12,8 @@ import {
|
|
|
12
12
|
getDeploymentStatus,
|
|
13
13
|
getExistingVariable,
|
|
14
14
|
getServiceUrl,
|
|
15
|
+
hasRailwaySdk,
|
|
16
|
+
iacRef,
|
|
15
17
|
installRailwaySdk,
|
|
16
18
|
removeServiceDomain,
|
|
17
19
|
setProjectVariables,
|
|
@@ -99,13 +101,17 @@ export async function deployToRailway(options = {}) {
|
|
|
99
101
|
|
|
100
102
|
// --- Step 4: Ensure Railway project ---
|
|
101
103
|
console.log(chalk.cyan(" Ensuring Railway project..."));
|
|
104
|
+
let resolvedProjectName = projectName || null;
|
|
102
105
|
try {
|
|
103
106
|
const project = await ensureRailwayProject({ projectName, projectId, cwd });
|
|
107
|
+
// Prefer the name Railway actually resolved. The local directory name is
|
|
108
|
+
// only a fallback for naming a brand new project — it must not become the
|
|
109
|
+
// project's identity just because the deploy ran from a differently-named
|
|
110
|
+
// checkout.
|
|
111
|
+
resolvedProjectName = project.projectName || resolvedProjectName;
|
|
104
112
|
if (project.created) {
|
|
105
113
|
console.log(
|
|
106
|
-
chalk.green(
|
|
107
|
-
` ✔ Created Railway project "${project.projectName || projectName || ""}"`,
|
|
108
|
-
),
|
|
114
|
+
chalk.green(` ✔ Created Railway project "${resolvedProjectName}"`),
|
|
109
115
|
);
|
|
110
116
|
} else {
|
|
111
117
|
console.log(chalk.green(" ✔ Railway project linked"));
|
|
@@ -141,7 +147,7 @@ export async function deployToRailway(options = {}) {
|
|
|
141
147
|
}
|
|
142
148
|
|
|
143
149
|
// --- Step 6: Preserve existing secrets across deploys ---
|
|
144
|
-
const projectLabel = path.basename(cwd);
|
|
150
|
+
const projectLabel = resolvedProjectName || path.basename(cwd);
|
|
145
151
|
let authSecret;
|
|
146
152
|
let nextAuthSecret;
|
|
147
153
|
|
|
@@ -170,14 +176,18 @@ export async function deployToRailway(options = {}) {
|
|
|
170
176
|
console.log(chalk.cyan(" Generating IaC configuration..."));
|
|
171
177
|
const iacPath = `${cwd}/.railway/railway.ts`;
|
|
172
178
|
|
|
173
|
-
await generateIacFile({
|
|
179
|
+
const iacResult = await generateIacFile({
|
|
174
180
|
iacPath,
|
|
175
181
|
services: discovery.services,
|
|
176
182
|
// Pass raw name — generateIacFile handles escaping internally
|
|
177
|
-
projectName:
|
|
183
|
+
projectName: projectLabel,
|
|
178
184
|
variableRefs: {
|
|
179
|
-
|
|
180
|
-
|
|
185
|
+
// Quoted: these land inside generated TypeScript as object values,
|
|
186
|
+
// where `${{...}}` must be a string literal to parse at all. The
|
|
187
|
+
// equivalent bare form is still used in step 9, where the value is a
|
|
188
|
+
// command-line argument rather than an expression.
|
|
189
|
+
DATABASE_URL: iacRef(pgServiceName, "DATABASE_URL"),
|
|
190
|
+
REDIS_URL: iacRef(redisServiceName, "REDIS_URL"),
|
|
181
191
|
NODE_ENV: '"production"',
|
|
182
192
|
APP_NAME: `"${escapeTsString(projectLabel)}"`,
|
|
183
193
|
APP_DESCRIPTION: `"${escapeTsString(`${projectLabel} - Quark application`)}"`,
|
|
@@ -197,23 +207,34 @@ export async function deployToRailway(options = {}) {
|
|
|
197
207
|
},
|
|
198
208
|
},
|
|
199
209
|
});
|
|
210
|
+
if (iacResult.changed) {
|
|
211
|
+
console.log(
|
|
212
|
+
chalk.yellow(
|
|
213
|
+
" · .railway/railway.ts rewritten — review with `git diff .railway/railway.ts`",
|
|
214
|
+
),
|
|
215
|
+
);
|
|
216
|
+
}
|
|
200
217
|
console.log(chalk.green(` ✔ IaC file generated at .railway/railway.ts`));
|
|
201
218
|
|
|
202
219
|
// --- Step 8: Install Railway SDK (required for IaC evaluation) ---
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
console.log(chalk.
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
220
|
+
if (await hasRailwaySdk({ cwd })) {
|
|
221
|
+
console.log(chalk.dim(" · Railway SDK already installed"));
|
|
222
|
+
} else {
|
|
223
|
+
console.log(chalk.cyan(" Installing Railway SDK..."));
|
|
224
|
+
try {
|
|
225
|
+
await installRailwaySdk({ cwd });
|
|
226
|
+
console.log(chalk.green(" ✔ Railway SDK installed"));
|
|
227
|
+
} catch (error) {
|
|
228
|
+
// SDK is mandatory — railway config apply cannot evaluate .railway/railway.ts without it
|
|
229
|
+
console.error(
|
|
230
|
+
chalk.red(` ✖ Failed to install Railway SDK: ${error.message}`),
|
|
231
|
+
);
|
|
232
|
+
throw new Error(
|
|
233
|
+
`Railway SDK installation failed. The generated IaC file requires the "railway" package.\n` +
|
|
234
|
+
`Install it manually: pnpm add -D -w railway\n` +
|
|
235
|
+
`Original error: ${error.message}`,
|
|
236
|
+
);
|
|
237
|
+
}
|
|
217
238
|
}
|
|
218
239
|
|
|
219
240
|
// --- Step 9: Set dynamic variable references (infrastructure wiring) ---
|
package/src/deploy/index.js
CHANGED
|
@@ -12,13 +12,17 @@ export {
|
|
|
12
12
|
getDeploymentStatus,
|
|
13
13
|
getExistingVariable,
|
|
14
14
|
getServiceUrl,
|
|
15
|
+
hasRailwaySdk,
|
|
16
|
+
iacRef,
|
|
15
17
|
installRailwaySdk,
|
|
16
18
|
isProjectLinked,
|
|
17
19
|
listProjects,
|
|
18
20
|
RAILWAY,
|
|
19
21
|
RAILWAY_DIAGNOSTIC_CODES,
|
|
20
22
|
RailwayError,
|
|
23
|
+
readLinkedProject,
|
|
21
24
|
removeServiceDomain,
|
|
25
|
+
resolveProjectName,
|
|
22
26
|
setProjectVariables,
|
|
23
27
|
tryLinkProject,
|
|
24
28
|
} from "./adapters/index.js";
|