@timekast/factory 1.28.1 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/backlog.js +52 -15
- package/dist/commands/doctor.js +210 -29
- package/dist/commands/env.js +154 -18
- package/dist/commands/invite-admin.js +62 -15
- package/dist/commands/provision.js +5815 -763
- package/dist/commands/status.js +12 -2
- package/dist/commands/ticket.js +4 -4
- package/dist/commands/update.js +97 -12
- package/dist/commands/vault.js +2627 -0
- package/dist/index.js +97 -33
- package/dist/lib/backlog-mapping.js +111 -18
- package/dist/lib/backlog-parse.js +1 -1
- package/dist/lib/claude-md.js +320 -0
- package/dist/lib/cloudflare-api.js +213 -37
- package/dist/lib/constants.js +70 -6
- package/dist/lib/env-file.js +45 -7
- package/dist/lib/env-wizard.js +66 -2
- package/dist/lib/initial-migration.js +3 -2
- package/dist/lib/neon-api.js +145 -10
- package/dist/lib/package-json.js +37 -16
- package/dist/lib/preflight.js +92 -38
- package/dist/lib/provision-preflight.js +158 -100
- package/dist/lib/provision-state.js +652 -20
- package/dist/lib/publish-engine.js +1 -1
- package/dist/lib/rail.js +400 -0
- package/dist/lib/railway-api.js +851 -0
- package/dist/lib/vault-adopt.js +1495 -0
- package/dist/lib/vault-api.js +1106 -0
- package/dist/lib/vault-config.js +21 -0
- package/dist/lib/vault-deploys.js +898 -0
- package/dist/lib/vercel-env.js +251 -31
- package/dist/lib/wrapper-wiring.js +160 -0
- package/dist/tui/env-wizard-flow.js +17 -3
- package/dist/tui/menu.js +4 -22
- package/dist/tui/provision-flow.js +68 -13
- package/package.json +1 -1
- package/dist/commands/secrets.js +0 -71
- package/dist/lib/env-merge.js +0 -238
|
@@ -0,0 +1,851 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Railway API client (RWAY-002) — the ONLY place where the CLI talks to Railway's public GraphQL
|
|
3
|
+
* API. Railway's own CLI does not serve agents (it aborts asking for `railway setup agent`), so
|
|
4
|
+
* everything goes through `https://backboard.railway.com/graphql/v2` with `RAILWAY_TOKEN`, the org's
|
|
5
|
+
* TEAM token, read from the rail with `rail.ts` (`fx-secrets-vault §5`) — never from `process.env`
|
|
6
|
+
* nor from a file.
|
|
7
|
+
*
|
|
8
|
+
* WHAT IT FINDS BY: a Railway project is found by the REPO IT HAS CONNECTED (`source.repo` of a
|
|
9
|
+
* service instance, `owner/name`, case-insensitive) — {@link RailwayClient.findByConnectedRepo}.
|
|
10
|
+
* Names do not match repos in practice (`constela` ↔ `TimeKast/constela_front`), and one repo can be
|
|
11
|
+
* connected to several projects (one per client deploy). The single lookup by exact name is the
|
|
12
|
+
* RESUME of an alta the kit itself started, inside the deduced workspace, before the repo is
|
|
13
|
+
* connected ({@link RailwayClient.createProject} with `adoptByName`, and
|
|
14
|
+
* {@link RailwayClient.findProjectInWorkspaceByName}); it never adopts a stranger's project: without
|
|
15
|
+
* the caller's explicit `adoptByName`, or when the project does not have the kit's shape, it stops
|
|
16
|
+
* with {@link RailwayNameTakenError}, which points at `factory provision --adopt --force --target=railway`.
|
|
17
|
+
*
|
|
18
|
+
* 🔴 WRITES NO VARIABLES. In Railway the only writer of variables is the vault's sync
|
|
19
|
+
* (`fx-secrets-vault §7`, one writer per destination). This module exposes no mutation that writes
|
|
20
|
+
* them, and the service creation never forwards a `variables` field, even if a caller forces one in.
|
|
21
|
+
* Reads exist only for the inventory, the drift (names + hashes) and the wait for names.
|
|
22
|
+
*
|
|
23
|
+
* 🔴 NO-ECHO — the token never reaches stdout/stderr (this module writes to neither), an error, a
|
|
24
|
+
* URL or a child's argv: it travels only in the Authorization header built by the transport. A
|
|
25
|
+
* GraphQL error message is forwarded only after checking it carries no fragment of the token (its
|
|
26
|
+
* full value, first 8 or last 4 characters); if it does, the message is dropped. No error carries a
|
|
27
|
+
* `cause`, and the transport's own failures are rebuilt without their message.
|
|
28
|
+
*
|
|
29
|
+
* 🔴 FAIL-CLOSED UNDER TESTS — the same marker as `vault-api.ts` (`VAULT_TEST_ISOLATION_KEY`, set on
|
|
30
|
+
* `globalThis` by `cli/tests/setup/isolate-home-path.ts`): while present, a client without an
|
|
31
|
+
* injected transport THROWS on its first call, before reading the token or touching the network.
|
|
32
|
+
* `globalThis`, not `process.env.VITEST`: a CLI spawned by a test inherits the env var and would turn
|
|
33
|
+
* a real run into a refusal.
|
|
34
|
+
*
|
|
35
|
+
* The schema this client assumes (mutation inputs, the deployment-state enum, the fields it reads)
|
|
36
|
+
* is pinned by a dated introspection extract, `cli/tests/fixtures/railway-schema-2026-09-23.json`;
|
|
37
|
+
* the unit test validates every document below against it. That proves consistency against a
|
|
38
|
+
* photograph, not against the live API — the live schema is the rollout's to confirm.
|
|
39
|
+
*/
|
|
40
|
+
import { CLIError } from './cli-error.js';
|
|
41
|
+
import { readRailSecret } from './rail.js';
|
|
42
|
+
import { VAULT_TEST_ISOLATION_KEY } from './vault-api.js';
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Constants
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
/** Railway's public GraphQL endpoint (`fx-secrets-vault §5`: its CLI does not serve agents). */
|
|
47
|
+
export const RAILWAY_GRAPHQL_URL = 'https://backboard.railway.com/graphql/v2';
|
|
48
|
+
/** Name of the rail key that carries the org's Railway team token. */
|
|
49
|
+
export const RAILWAY_TOKEN_KEY = 'RAILWAY_TOKEN';
|
|
50
|
+
/** Time budget (ms) per API call. */
|
|
51
|
+
export const RAILWAY_API_TIMEOUT_MS = 30_000;
|
|
52
|
+
/** Hard stop on the project pagination — far past any team. */
|
|
53
|
+
export const RAILWAY_MAX_PAGES = 50;
|
|
54
|
+
/** How many recent deployments a commit is looked for in. */
|
|
55
|
+
export const RAILWAY_DEPLOYMENTS_PAGE = 20;
|
|
56
|
+
/** The provider a branch trigger is created with (the kit only connects GitHub repos). */
|
|
57
|
+
export const RAILWAY_TRIGGER_PROVIDER = 'github';
|
|
58
|
+
/**
|
|
59
|
+
* The name of the ONE service the kit creates in a Railway project (`provision --target=railway`).
|
|
60
|
+
* Part of the kit's shape, read differently by its two users: `--destroy`'s proof of ownership
|
|
61
|
+
* requires the recorded service to be `web` with ≥1 instance and no source (or some service to
|
|
62
|
+
* deploy `origin`); resuming an own alta by name ({@link CreateProjectOptions.adoptByName}) is
|
|
63
|
+
* looser — any service it finds must be `web`, but zero services, or `web` with zero instances, is
|
|
64
|
+
* accepted. See {@link CreateProjectOptions} for why the two differ.
|
|
65
|
+
*/
|
|
66
|
+
export const RAILWAY_SERVICE_NAME = 'web';
|
|
67
|
+
/** Shortest commit prefix accepted to find a deployment (shorter prefixes collide). */
|
|
68
|
+
const MIN_COMMIT_PREFIX = 7;
|
|
69
|
+
/** Longest GraphQL error message forwarded in an error. */
|
|
70
|
+
const MAX_FORWARDED_MESSAGE = 300;
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// GraphQL documents — every one the client sends (validated against the dated fixture)
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
const PAGE_INFO = 'pageInfo { hasNextPage endCursor }';
|
|
75
|
+
const PROJECT_FIELDS = 'id name workspaceId ' +
|
|
76
|
+
'services { edges { node { id name ' +
|
|
77
|
+
`serviceInstances { edges { node { environmentId source { repo image } } } ${PAGE_INFO} } ` +
|
|
78
|
+
`} } ${PAGE_INFO} } ` +
|
|
79
|
+
`environments { edges { node { id name } } ${PAGE_INFO} }`;
|
|
80
|
+
const CUSTOM_DOMAIN_FIELDS = 'id domain targetPort status { dnsRecords { requiredValue zone } verificationDnsHost verificationToken verified }';
|
|
81
|
+
/** Every GraphQL document the client sends, by operation. The unit test walks this table. */
|
|
82
|
+
export const RAILWAY_DOCUMENTS = {
|
|
83
|
+
projects: `query ($after: String) { projects(after: $after) { edges { node { ${PROJECT_FIELDS} } } ${PAGE_INFO} } }`,
|
|
84
|
+
project: `query ($id: String!) { project(id: $id) { ${PROJECT_FIELDS} } }`,
|
|
85
|
+
projectCreate: 'mutation ($input: ProjectCreateInput!) { projectCreate(input: $input) { id name } }',
|
|
86
|
+
projectDelete: 'mutation ($id: String!) { projectDelete(id: $id) }',
|
|
87
|
+
environmentRename: 'mutation ($id: String!, $input: EnvironmentRenameInput!) { environmentRename(id: $id, input: $input) { id name } }',
|
|
88
|
+
environmentCreate: 'mutation ($input: EnvironmentCreateInput!) { environmentCreate(input: $input) { id name } }',
|
|
89
|
+
environmentDelete: 'mutation ($id: String!) { environmentDelete(id: $id) }',
|
|
90
|
+
serviceCreate: 'mutation ($input: ServiceCreateInput!) { serviceCreate(input: $input) { id name } }',
|
|
91
|
+
serviceConnect: 'mutation ($id: String!, $input: ServiceConnectInput!) { serviceConnect(id: $id, input: $input) { id } }',
|
|
92
|
+
serviceTriggers: `query ($id: String!) { service(id: $id) { id repoTriggers { edges { node { id branch environmentId } } ${PAGE_INFO} } } }`,
|
|
93
|
+
deploymentTriggerCreate: 'mutation ($input: DeploymentTriggerCreateInput!) { deploymentTriggerCreate(input: $input) { id branch environmentId } }',
|
|
94
|
+
deploymentTriggerUpdate: 'mutation ($id: String!, $input: DeploymentTriggerUpdateInput!) { deploymentTriggerUpdate(id: $id, input: $input) { id branch environmentId } }',
|
|
95
|
+
serviceInstanceUpdate: 'mutation ($serviceId: String!, $environmentId: String, $input: ServiceInstanceUpdateInput!) { serviceInstanceUpdate(serviceId: $serviceId, environmentId: $environmentId, input: $input) }',
|
|
96
|
+
serviceInstanceAutoDeployUpdate: 'mutation ($input: ServiceInstanceAutoDeployUpdateInput!) { serviceInstanceAutoDeployUpdate(input: $input) { enabled } }',
|
|
97
|
+
variables: 'query ($projectId: String!, $environmentId: String!, $serviceId: String) { variables(projectId: $projectId, environmentId: $environmentId, serviceId: $serviceId) }',
|
|
98
|
+
domains: `query ($projectId: String!, $environmentId: String!, $serviceId: String!) { domains(projectId: $projectId, environmentId: $environmentId, serviceId: $serviceId) { serviceDomains { id domain targetPort } customDomains { ${CUSTOM_DOMAIN_FIELDS} } } }`,
|
|
99
|
+
serviceDomainCreate: 'mutation ($input: ServiceDomainCreateInput!) { serviceDomainCreate(input: $input) { id domain targetPort } }',
|
|
100
|
+
customDomainCreate: `mutation ($input: CustomDomainCreateInput!) { customDomainCreate(input: $input) { ${CUSTOM_DOMAIN_FIELDS} } }`,
|
|
101
|
+
customDomainDelete: 'mutation ($id: String!) { customDomainDelete(id: $id) }',
|
|
102
|
+
deployments: 'query ($first: Int, $input: DeploymentListInput!) { deployments(first: $first, input: $input) { edges { node { id status createdAt meta } } } }',
|
|
103
|
+
deployment: 'query ($id: String!) { deployment(id: $id) { id status createdAt meta } }',
|
|
104
|
+
serviceInstanceDeployV2: 'mutation ($serviceId: String!, $environmentId: String!, $commitSha: String) { serviceInstanceDeployV2(serviceId: $serviceId, environmentId: $environmentId, commitSha: $commitSha) }',
|
|
105
|
+
};
|
|
106
|
+
/**
|
|
107
|
+
* Classification of every value of Railway's `DeploymentStatus` enum (dated fixture). `ended` is
|
|
108
|
+
* terminal and NOT a success (removed/skipped). `SLEEPING` is a deployment that went live and idles.
|
|
109
|
+
*/
|
|
110
|
+
export const DEPLOYMENT_STATE_CLASS = {
|
|
111
|
+
INITIALIZING: 'pending',
|
|
112
|
+
QUEUED: 'pending',
|
|
113
|
+
WAITING: 'pending',
|
|
114
|
+
NEEDS_APPROVAL: 'pending',
|
|
115
|
+
BUILDING: 'pending',
|
|
116
|
+
DEPLOYING: 'pending',
|
|
117
|
+
SUCCESS: 'success',
|
|
118
|
+
SLEEPING: 'success',
|
|
119
|
+
FAILED: 'failed',
|
|
120
|
+
CRASHED: 'failed',
|
|
121
|
+
REMOVING: 'ended',
|
|
122
|
+
REMOVED: 'ended',
|
|
123
|
+
SKIPPED: 'ended',
|
|
124
|
+
};
|
|
125
|
+
/**
|
|
126
|
+
* Class of a deployment state. A state the dated fixture does not know (Railway added a value) is
|
|
127
|
+
* `unobservable` — named by the caller as "cannot observe", NEVER taken as success.
|
|
128
|
+
*/
|
|
129
|
+
export function classifyDeploymentState(status) {
|
|
130
|
+
return DEPLOYMENT_STATE_CLASS[status] ?? 'unobservable';
|
|
131
|
+
}
|
|
132
|
+
/** Base of the client's failures. A `CLIError`: the top-level handler prints only the message. */
|
|
133
|
+
export class RailwayApiError extends CLIError {
|
|
134
|
+
kind;
|
|
135
|
+
constructor(kind, message) {
|
|
136
|
+
super(message);
|
|
137
|
+
this.name = 'RailwayApiError';
|
|
138
|
+
this.kind = kind;
|
|
139
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/** 401/403 on the API: the token is invalid or has no access. Names the key, never its value. */
|
|
143
|
+
export class RailwayTokenError extends RailwayApiError {
|
|
144
|
+
constructor(operation) {
|
|
145
|
+
super('token-invalid', `Railway rechazó \`${RAILWAY_TOKEN_KEY}\` al ${operation}: el token es inválido o no tiene acceso. ` +
|
|
146
|
+
'Pruébalo como indica fx-secrets-vault §5 (una consulta de `projects`, nunca `me`) y, si está muerto, ' +
|
|
147
|
+
'un admin lo rota en `rail-timekast`.');
|
|
148
|
+
this.name = 'RailwayTokenError';
|
|
149
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/** An existing project has the name and adopting it by name is not allowed (or not safe). */
|
|
153
|
+
export class RailwayNameTakenError extends RailwayApiError {
|
|
154
|
+
projectName;
|
|
155
|
+
projectId;
|
|
156
|
+
constructor(projectName, projectId, reason) {
|
|
157
|
+
super('name-taken', `Ya existe en Railway un proyecto \`${projectName}\` (${projectId}) y no lo adopto por su nombre: ` +
|
|
158
|
+
`${reason}. Un nombre no prueba de quién es un proyecto. Si es el de este repo, adóptalo con ` +
|
|
159
|
+
'`factory provision --adopt --force --target=railway` (el `--force` rehace el estado que ya ' +
|
|
160
|
+
'existe); si no, cambia el slug. No creé ni modifiqué nada.');
|
|
161
|
+
this.name = 'RailwayNameTakenError';
|
|
162
|
+
this.projectName = projectName;
|
|
163
|
+
this.projectId = projectId;
|
|
164
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/** Under the CLI's Vitest setup without an injected transport: refuses before touching anything. */
|
|
168
|
+
export class RailwaySeamMissingError extends RailwayApiError {
|
|
169
|
+
constructor() {
|
|
170
|
+
super('seam-missing', 'railway-api: falta el seam `transport` bajo Vitest. Un test NUNCA habla con Railway real: ' +
|
|
171
|
+
'pasa `transport` a createRailwayClient (tests/unit/railway-api.test.ts tiene el patrón).');
|
|
172
|
+
this.name = 'RailwaySeamMissingError';
|
|
173
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
// Pure helpers
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
/**
|
|
180
|
+
* `owner/name` of a GitHub repo given as `owner/name`, an HTTPS URL or an SSH remote, without
|
|
181
|
+
* `.git` or a trailing slash; `null` when it is none of those. Case is preserved (compare with
|
|
182
|
+
* {@link sameRepo}). Identity only — it never decides a destination (Decision 13).
|
|
183
|
+
*/
|
|
184
|
+
export function normalizeRepoIdentity(input) {
|
|
185
|
+
const trimmed = input.trim();
|
|
186
|
+
const url = /^(?:https:\/\/|ssh:\/\/git@|git@)github\.com[/:]([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/.exec(trimmed);
|
|
187
|
+
const plain = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?$/.exec(trimmed);
|
|
188
|
+
const match = url ?? plain;
|
|
189
|
+
return match ? `${match[1]}/${match[2]}` : null;
|
|
190
|
+
}
|
|
191
|
+
/** Whether two repo identities are the same repo (case-insensitive, any accepted spelling). */
|
|
192
|
+
export function sameRepo(a, b) {
|
|
193
|
+
const left = normalizeRepoIdentity(a);
|
|
194
|
+
const right = normalizeRepoIdentity(b);
|
|
195
|
+
return left !== null && right !== null && left.toLowerCase() === right.toLowerCase();
|
|
196
|
+
}
|
|
197
|
+
/** The distinct projects of a connected-repo search, in order of appearance. */
|
|
198
|
+
export function connectedProjects(matches) {
|
|
199
|
+
const seen = new Map();
|
|
200
|
+
for (const m of matches)
|
|
201
|
+
if (!seen.has(m.project.id))
|
|
202
|
+
seen.set(m.project.id, m.project);
|
|
203
|
+
return [...seen.values()];
|
|
204
|
+
}
|
|
205
|
+
/** The real transport over `fetch`: the token goes in a header, the query in the body. */
|
|
206
|
+
export function railwayFetchTransport(fetchImpl = fetch, timeoutMs = RAILWAY_API_TIMEOUT_MS) {
|
|
207
|
+
return async ({ query, variables, token }) => {
|
|
208
|
+
const response = await fetchImpl(RAILWAY_GRAPHQL_URL, {
|
|
209
|
+
method: 'POST',
|
|
210
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
211
|
+
body: JSON.stringify({ query, variables }),
|
|
212
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
213
|
+
});
|
|
214
|
+
let body = null;
|
|
215
|
+
try {
|
|
216
|
+
body = await response.json();
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
body = null;
|
|
220
|
+
}
|
|
221
|
+
return { status: response.status, body };
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
function isUnderTestIsolation() {
|
|
225
|
+
return globalThis[VAULT_TEST_ISOLATION_KEY] !== undefined;
|
|
226
|
+
}
|
|
227
|
+
/** A GraphQL error's message, only when it carries no fragment of the token. */
|
|
228
|
+
function safeMessage(error, token) {
|
|
229
|
+
const raw = error &&
|
|
230
|
+
typeof error === 'object' &&
|
|
231
|
+
typeof error.message === 'string'
|
|
232
|
+
? error.message
|
|
233
|
+
: '';
|
|
234
|
+
const message = raw.replace(/[\u0000-\u001f\u007f]+/g, ' ').trim();
|
|
235
|
+
const fragments = token.length >= 12 ? [token, token.slice(0, 8), token.slice(-4)] : [token];
|
|
236
|
+
if (token !== '' && fragments.some((f) => message.includes(f))) {
|
|
237
|
+
return '(mensaje omitido: traía un fragmento de la credencial)';
|
|
238
|
+
}
|
|
239
|
+
if (message === '')
|
|
240
|
+
return '(sin mensaje)';
|
|
241
|
+
return message.length > MAX_FORWARDED_MESSAGE
|
|
242
|
+
? `${message.slice(0, MAX_FORWARDED_MESSAGE)}…`
|
|
243
|
+
: message;
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* The nodes of a Railway connection — FAIL-CLOSED: a connection without `edges` is "not consulted",
|
|
247
|
+
* never an empty list, and one that says it has more pages is refused (a partial list is not the list).
|
|
248
|
+
*/
|
|
249
|
+
function nodes(connection, what) {
|
|
250
|
+
if (connection === null ||
|
|
251
|
+
typeof connection !== 'object' ||
|
|
252
|
+
Array.isArray(connection) ||
|
|
253
|
+
!Array.isArray(connection.edges)) {
|
|
254
|
+
throw new RailwayApiError('incomplete', `Railway respondió sin la lista de ${what}: no la doy por vacía. Repite; si persiste, prueba el ` +
|
|
255
|
+
'token como indica fx-secrets-vault §5.');
|
|
256
|
+
}
|
|
257
|
+
if (connection.pageInfo?.hasNextPage === true) {
|
|
258
|
+
throw new RailwayApiError('incomplete', `Railway devolvió la lista de ${what} en varias páginas y sólo leí la primera: no decido sobre ` +
|
|
259
|
+
'una lista incompleta. Repórtalo al equipo del Factory.');
|
|
260
|
+
}
|
|
261
|
+
return connection.edges.flatMap((edge) => (edge?.node ? [edge.node] : []));
|
|
262
|
+
}
|
|
263
|
+
function named(raw) {
|
|
264
|
+
return typeof raw.id === 'string' && typeof raw.name === 'string' && raw.id !== ''
|
|
265
|
+
? { id: raw.id, name: raw.name }
|
|
266
|
+
: null;
|
|
267
|
+
}
|
|
268
|
+
function parseProject(raw) {
|
|
269
|
+
const base = named(raw);
|
|
270
|
+
if (!base)
|
|
271
|
+
return null;
|
|
272
|
+
const services = [];
|
|
273
|
+
for (const s of nodes(raw.services, `servicios de \`${base.name}\``)) {
|
|
274
|
+
const service = named(s);
|
|
275
|
+
if (!service)
|
|
276
|
+
continue;
|
|
277
|
+
// Not answered → `null` ("unknown"), never "no instances": the repo search refuses it.
|
|
278
|
+
const instances = s.serviceInstances === undefined
|
|
279
|
+
? null
|
|
280
|
+
: nodes(s.serviceInstances, `instancias de \`${service.name}\``).flatMap((i) => typeof i.environmentId === 'string'
|
|
281
|
+
? [
|
|
282
|
+
{
|
|
283
|
+
environmentId: i.environmentId,
|
|
284
|
+
repo: typeof i.source?.repo === 'string' && i.source.repo !== ''
|
|
285
|
+
? i.source.repo
|
|
286
|
+
: null,
|
|
287
|
+
image: typeof i.source?.image === 'string' && i.source.image !== ''
|
|
288
|
+
? i.source.image
|
|
289
|
+
: null,
|
|
290
|
+
},
|
|
291
|
+
]
|
|
292
|
+
: []);
|
|
293
|
+
services.push({ ...service, instances });
|
|
294
|
+
}
|
|
295
|
+
const environments = nodes(raw.environments, `entornos de \`${base.name}\``).flatMap((e) => {
|
|
296
|
+
const env = named(e);
|
|
297
|
+
return env ? [env] : [];
|
|
298
|
+
});
|
|
299
|
+
return {
|
|
300
|
+
...base,
|
|
301
|
+
workspaceId: typeof raw.workspaceId === 'string' && raw.workspaceId !== '' ? raw.workspaceId : null,
|
|
302
|
+
services,
|
|
303
|
+
environments,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
function parseDeployment(raw) {
|
|
307
|
+
if (!raw || typeof raw !== 'object')
|
|
308
|
+
return null;
|
|
309
|
+
const d = raw;
|
|
310
|
+
if (typeof d.id !== 'string' || typeof d.status !== 'string')
|
|
311
|
+
return null;
|
|
312
|
+
const meta = d.meta && typeof d.meta === 'object' ? d.meta : {};
|
|
313
|
+
return {
|
|
314
|
+
id: d.id,
|
|
315
|
+
status: d.status,
|
|
316
|
+
createdAt: typeof d.createdAt === 'string' ? d.createdAt : null,
|
|
317
|
+
commitHash: typeof meta.commitHash === 'string' ? meta.commitHash : null,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
function parseCustomDomain(raw) {
|
|
321
|
+
if (!raw || typeof raw !== 'object')
|
|
322
|
+
return null;
|
|
323
|
+
const d = raw;
|
|
324
|
+
if (typeof d.id !== 'string' || typeof d.domain !== 'string')
|
|
325
|
+
return null;
|
|
326
|
+
const first = Array.isArray(d.status?.dnsRecords) ? d.status.dnsRecords[0] : undefined;
|
|
327
|
+
return {
|
|
328
|
+
id: d.id,
|
|
329
|
+
domain: d.domain,
|
|
330
|
+
targetPort: typeof d.targetPort === 'number' ? d.targetPort : null,
|
|
331
|
+
cnameTarget: typeof first?.requiredValue === 'string' ? first.requiredValue : null,
|
|
332
|
+
verificationHost: typeof d.status?.verificationDnsHost === 'string' ? d.status.verificationDnsHost : null,
|
|
333
|
+
verificationToken: typeof d.status?.verificationToken === 'string' ? d.status.verificationToken : null,
|
|
334
|
+
verified: d.status?.verified === true,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
function serviceLabel(scope) {
|
|
338
|
+
return `\`${scope.serviceName ?? scope.serviceId}\``;
|
|
339
|
+
}
|
|
340
|
+
// ---------------------------------------------------------------------------
|
|
341
|
+
// The client
|
|
342
|
+
// ---------------------------------------------------------------------------
|
|
343
|
+
/** Build a client. The token is read once, lazily, on the first call. */
|
|
344
|
+
export function createRailwayClient(deps = {}) {
|
|
345
|
+
let tokenPromise;
|
|
346
|
+
function transport() {
|
|
347
|
+
if (deps.transport)
|
|
348
|
+
return deps.transport;
|
|
349
|
+
if (isUnderTestIsolation())
|
|
350
|
+
throw new RailwaySeamMissingError();
|
|
351
|
+
return railwayFetchTransport();
|
|
352
|
+
}
|
|
353
|
+
function token() {
|
|
354
|
+
if (deps.token !== undefined)
|
|
355
|
+
return Promise.resolve(deps.token);
|
|
356
|
+
tokenPromise ??= (deps.rail ? deps.rail.get(RAILWAY_TOKEN_KEY) : readRailSecret(RAILWAY_TOKEN_KEY)).catch((error) => {
|
|
357
|
+
tokenPromise = undefined;
|
|
358
|
+
throw error;
|
|
359
|
+
});
|
|
360
|
+
return tokenPromise;
|
|
361
|
+
}
|
|
362
|
+
async function call(document, variables, operation) {
|
|
363
|
+
const send = transport(); // under Vitest without a seam: throws before the token or the network
|
|
364
|
+
const bearer = await token();
|
|
365
|
+
let response;
|
|
366
|
+
try {
|
|
367
|
+
response = await send({ query: RAILWAY_DOCUMENTS[document], variables, token: bearer });
|
|
368
|
+
}
|
|
369
|
+
catch {
|
|
370
|
+
// The transport's own error may carry anything (a URL, a header): rebuilt without it.
|
|
371
|
+
throw new RailwayApiError('network', `No pude hablar con Railway al ${operation}. Revisa tu conexión.`);
|
|
372
|
+
}
|
|
373
|
+
if (response.status === 401 || response.status === 403)
|
|
374
|
+
throw new RailwayTokenError(operation);
|
|
375
|
+
const body = response.body && typeof response.body === 'object'
|
|
376
|
+
? response.body
|
|
377
|
+
: {};
|
|
378
|
+
if (Array.isArray(body.errors) && body.errors.length > 0) {
|
|
379
|
+
const message = safeMessage(body.errors[0], bearer);
|
|
380
|
+
if (/not authori[sz]ed/i.test(message)) {
|
|
381
|
+
throw new RailwayApiError('no-access', `Railway dijo "Not Authorized" al ${operation}. Con \`${RAILWAY_TOKEN_KEY}\` (token de equipo) eso ` +
|
|
382
|
+
'significa que el recurso no es de la organización TimeKast —la cuenta propia de un cliente usa ' +
|
|
383
|
+
'otro token— o que el token no sirve: pruébalo con `projects` como indica fx-secrets-vault §5 ' +
|
|
384
|
+
'antes de darlo por muerto.');
|
|
385
|
+
}
|
|
386
|
+
if (/not found/i.test(message)) {
|
|
387
|
+
throw new RailwayApiError('not-found', `Railway no encontró lo que pedí al ${operation}: ${message}`);
|
|
388
|
+
}
|
|
389
|
+
throw new RailwayApiError('graphql', `Railway respondió con un error al ${operation}: ${message}`);
|
|
390
|
+
}
|
|
391
|
+
if (response.status < 200 ||
|
|
392
|
+
response.status >= 300 ||
|
|
393
|
+
body.data === null ||
|
|
394
|
+
typeof body.data !== 'object' ||
|
|
395
|
+
Array.isArray(body.data)) {
|
|
396
|
+
throw new RailwayApiError('unrecognized', `Railway no respondió bien al ${operation} (status ${response.status}). Si dice "Not Authorized", ` +
|
|
397
|
+
'prueba el token como indica fx-secrets-vault §5 antes de darlo por muerto.');
|
|
398
|
+
}
|
|
399
|
+
return body.data;
|
|
400
|
+
}
|
|
401
|
+
/** Like {@link call}, but a "not found" answer is `null`. */
|
|
402
|
+
async function callOrNull(document, variables, operation) {
|
|
403
|
+
try {
|
|
404
|
+
return await call(document, variables, operation);
|
|
405
|
+
}
|
|
406
|
+
catch (error) {
|
|
407
|
+
if (error instanceof RailwayApiError && error.kind === 'not-found')
|
|
408
|
+
return null;
|
|
409
|
+
throw error;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
async function listProjects() {
|
|
413
|
+
const projects = [];
|
|
414
|
+
let after = null;
|
|
415
|
+
// The top-level list is walked page by page; a nested list with more pages stops (`nodes`).
|
|
416
|
+
for (let page = 0; page < RAILWAY_MAX_PAGES; page += 1) {
|
|
417
|
+
const data = await call('projects', { after }, 'listar los proyectos');
|
|
418
|
+
const connection = data.projects;
|
|
419
|
+
const pageInfo = connection && typeof connection === 'object' ? connection.pageInfo : undefined;
|
|
420
|
+
const list = nodes({ ...connection, pageInfo: undefined }, 'proyectos');
|
|
421
|
+
for (const raw of list) {
|
|
422
|
+
const project = parseProject(raw);
|
|
423
|
+
if (project)
|
|
424
|
+
projects.push(project);
|
|
425
|
+
}
|
|
426
|
+
if (pageInfo?.hasNextPage !== true)
|
|
427
|
+
return projects;
|
|
428
|
+
if (!pageInfo.endCursor || pageInfo.endCursor === after)
|
|
429
|
+
break;
|
|
430
|
+
after = pageInfo.endCursor;
|
|
431
|
+
}
|
|
432
|
+
throw new RailwayApiError('incomplete', 'Railway no terminó de paginar la lista de proyectos: no decido sobre una lista incompleta. Repite.');
|
|
433
|
+
}
|
|
434
|
+
function workspaceOf(projects) {
|
|
435
|
+
if (projects.length === 0) {
|
|
436
|
+
throw new RailwayApiError('workspace-unknown', `\`${RAILWAY_TOKEN_KEY}\` no ve ningún proyecto de Railway: no puedo deducir en qué workspace crear ` +
|
|
437
|
+
'y no lo adivino. Revisa que el token sea el de equipo de TimeKast (fx-secrets-vault §5).');
|
|
438
|
+
}
|
|
439
|
+
const ids = [...new Set(projects.flatMap((p) => (p.workspaceId ? [p.workspaceId] : [])))];
|
|
440
|
+
if (ids.length === 0) {
|
|
441
|
+
throw new RailwayApiError('workspace-unknown', 'Railway no dijo el workspace de ninguno de los proyectos visibles: no puedo deducir en qué ' +
|
|
442
|
+
'workspace crear y no lo adivino.');
|
|
443
|
+
}
|
|
444
|
+
if (ids.length > 1) {
|
|
445
|
+
throw new RailwayApiError('workspace-ambiguous', `\`${RAILWAY_TOKEN_KEY}\` ve proyectos de más de un workspace (${ids.join(', ')}): no elijo uno a ` +
|
|
446
|
+
'ciegas. El token de equipo de TimeKast alcanza uno solo; revisa qué token hay en `rail-timekast`.');
|
|
447
|
+
}
|
|
448
|
+
return ids[0];
|
|
449
|
+
}
|
|
450
|
+
async function projectOrThrow(projectId, operation) {
|
|
451
|
+
const project = await getProject(projectId);
|
|
452
|
+
if (!project) {
|
|
453
|
+
throw new RailwayApiError('not-found', `Railway no tiene el proyecto \`${projectId}\` (al ${operation}), o vive en la cuenta propia de un ` +
|
|
454
|
+
'cliente, que usa otro token.');
|
|
455
|
+
}
|
|
456
|
+
return project;
|
|
457
|
+
}
|
|
458
|
+
async function getProject(id) {
|
|
459
|
+
const data = await callOrNull('project', { id }, `leer el proyecto \`${id}\``);
|
|
460
|
+
if (!data || data.project === null || data.project === undefined)
|
|
461
|
+
return null;
|
|
462
|
+
return parseProject(data.project);
|
|
463
|
+
}
|
|
464
|
+
async function listTriggers(args) {
|
|
465
|
+
const data = await call('serviceTriggers', { id: args.serviceId }, `leer los triggers del servicio \`${args.serviceId}\``);
|
|
466
|
+
const service = data.service;
|
|
467
|
+
if (!service || typeof service !== 'object') {
|
|
468
|
+
throw new RailwayApiError('not-found', `Railway no tiene el servicio \`${args.serviceId}\` al leer sus triggers.`);
|
|
469
|
+
}
|
|
470
|
+
return nodes(service.repoTriggers, `triggers del servicio \`${args.serviceId}\``).flatMap((t) => typeof t.id === 'string' && t.environmentId === args.environmentId
|
|
471
|
+
? [
|
|
472
|
+
{
|
|
473
|
+
id: t.id,
|
|
474
|
+
branch: typeof t.branch === 'string' ? t.branch : '',
|
|
475
|
+
environmentId: args.environmentId,
|
|
476
|
+
},
|
|
477
|
+
]
|
|
478
|
+
: []);
|
|
479
|
+
}
|
|
480
|
+
async function readRawVariables(scope) {
|
|
481
|
+
const data = await call('variables', {
|
|
482
|
+
projectId: scope.projectId,
|
|
483
|
+
environmentId: scope.environmentId,
|
|
484
|
+
serviceId: scope.serviceId,
|
|
485
|
+
}, `leer las variables de ${serviceLabel(scope)}`);
|
|
486
|
+
const raw = data.variables;
|
|
487
|
+
// Fail-closed: an answer without the variables is not "no variables" (an empty deploy).
|
|
488
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
489
|
+
throw new RailwayApiError('incomplete', `Railway respondió sin las variables de ${serviceLabel(scope)}: no las doy por vacías. Repite.`);
|
|
490
|
+
}
|
|
491
|
+
return raw;
|
|
492
|
+
}
|
|
493
|
+
async function readDomains(scope) {
|
|
494
|
+
const data = await call('domains', {
|
|
495
|
+
projectId: scope.projectId,
|
|
496
|
+
environmentId: scope.environmentId,
|
|
497
|
+
serviceId: scope.serviceId,
|
|
498
|
+
}, `leer los dominios de ${serviceLabel(scope)}`);
|
|
499
|
+
const domains = data.domains;
|
|
500
|
+
if (!domains ||
|
|
501
|
+
typeof domains !== 'object' ||
|
|
502
|
+
!Array.isArray(domains.serviceDomains) ||
|
|
503
|
+
!Array.isArray(domains.customDomains)) {
|
|
504
|
+
throw new RailwayApiError('incomplete', `Railway respondió sin la lista de dominios de ${serviceLabel(scope)}: no la doy por vacía. Repite.`);
|
|
505
|
+
}
|
|
506
|
+
return {
|
|
507
|
+
serviceDomains: domains.serviceDomains.flatMap((raw) => {
|
|
508
|
+
const d = raw;
|
|
509
|
+
return d && typeof d.id === 'string' && typeof d.domain === 'string'
|
|
510
|
+
? [
|
|
511
|
+
{
|
|
512
|
+
id: d.id,
|
|
513
|
+
domain: d.domain,
|
|
514
|
+
targetPort: typeof d.targetPort === 'number' ? d.targetPort : null,
|
|
515
|
+
},
|
|
516
|
+
]
|
|
517
|
+
: [];
|
|
518
|
+
}),
|
|
519
|
+
customDomains: domains.customDomains.flatMap((raw) => {
|
|
520
|
+
const d = parseCustomDomain(raw);
|
|
521
|
+
return d ? [d] : [];
|
|
522
|
+
}),
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
async function listDeployments(scope) {
|
|
526
|
+
const data = await call('deployments', {
|
|
527
|
+
first: RAILWAY_DEPLOYMENTS_PAGE,
|
|
528
|
+
input: {
|
|
529
|
+
projectId: scope.projectId,
|
|
530
|
+
environmentId: scope.environmentId,
|
|
531
|
+
serviceId: scope.serviceId,
|
|
532
|
+
},
|
|
533
|
+
}, `listar los deployments de ${serviceLabel(scope)}`);
|
|
534
|
+
return nodes(data.deployments, `deployments de ${serviceLabel(scope)}`).flatMap((raw) => {
|
|
535
|
+
const d = parseDeployment(raw);
|
|
536
|
+
return d ? [d] : [];
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
const client = {
|
|
540
|
+
async verifyToken() {
|
|
541
|
+
// `projects`, NEVER `me`: a live team token answers "Not Authorized" to `me`.
|
|
542
|
+
const data = await call('projects', { after: null }, 'probar el token');
|
|
543
|
+
nodes({ ...data.projects, pageInfo: undefined }, 'proyectos');
|
|
544
|
+
},
|
|
545
|
+
listProjects,
|
|
546
|
+
async resolveWorkspaceId() {
|
|
547
|
+
return workspaceOf(await listProjects());
|
|
548
|
+
},
|
|
549
|
+
async findByConnectedRepo(repo) {
|
|
550
|
+
const wanted = normalizeRepoIdentity(repo);
|
|
551
|
+
if (!wanted) {
|
|
552
|
+
throw new CLIError(`\`${repo}\` no es un repo de GitHub (\`owner/nombre\`): no puedo buscar en Railway por repo conectado.`);
|
|
553
|
+
}
|
|
554
|
+
const matches = [];
|
|
555
|
+
for (const project of await listProjects()) {
|
|
556
|
+
for (const service of project.services) {
|
|
557
|
+
if (service.instances === null) {
|
|
558
|
+
throw new RailwayApiError('incomplete', `Railway respondió sin las instancias del servicio \`${service.name}\` de \`${project.name}\`: ` +
|
|
559
|
+
'no sé a qué repo está conectado y no lo doy por desconectado. Repite.');
|
|
560
|
+
}
|
|
561
|
+
const connectedEnvironmentIds = service.instances
|
|
562
|
+
.filter((i) => i.repo !== null && sameRepo(i.repo, wanted))
|
|
563
|
+
.map((i) => i.environmentId);
|
|
564
|
+
if (connectedEnvironmentIds.length === 0)
|
|
565
|
+
continue;
|
|
566
|
+
matches.push({
|
|
567
|
+
project: { id: project.id, name: project.name, workspaceId: project.workspaceId },
|
|
568
|
+
service: { id: service.id, name: service.name },
|
|
569
|
+
environments: project.environments,
|
|
570
|
+
connectedEnvironmentIds,
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
return matches;
|
|
575
|
+
},
|
|
576
|
+
getProject,
|
|
577
|
+
async findProjectInWorkspaceByName(name) {
|
|
578
|
+
const projects = await listProjects();
|
|
579
|
+
const workspaceId = workspaceOf(projects);
|
|
580
|
+
const same = projects.filter((p) => p.workspaceId === workspaceId && p.name === name);
|
|
581
|
+
if (same.length > 1) {
|
|
582
|
+
throw new RailwayApiError('name-taken', `Hay ${same.length} proyectos de Railway llamados \`${name}\` en el workspace: ` +
|
|
583
|
+
`${same.map((p) => p.id).join(', ')}. No elijo uno a ciegas. No hice nada.`);
|
|
584
|
+
}
|
|
585
|
+
return same[0] ?? null;
|
|
586
|
+
},
|
|
587
|
+
async createProject(args, options = {}) {
|
|
588
|
+
const projects = await listProjects();
|
|
589
|
+
const workspaceId = workspaceOf(projects);
|
|
590
|
+
const same = projects.filter((p) => p.workspaceId === workspaceId && p.name === args.name);
|
|
591
|
+
const [existing] = same;
|
|
592
|
+
if (existing) {
|
|
593
|
+
if (same.length > 1) {
|
|
594
|
+
throw new RailwayNameTakenError(args.name, same.map((p) => p.id).join(', '), 'hay varios con ese nombre');
|
|
595
|
+
}
|
|
596
|
+
if (!options.adoptByName) {
|
|
597
|
+
throw new RailwayNameTakenError(existing.name, existing.id, 'esta corrida no retoma un alta propia');
|
|
598
|
+
}
|
|
599
|
+
const repo = options.adoptByName.repo;
|
|
600
|
+
if (existing.services.length > 1) {
|
|
601
|
+
throw new RailwayNameTakenError(existing.name, existing.id, `tiene ${existing.services.length} servicios y un alta del kit deja uno`);
|
|
602
|
+
}
|
|
603
|
+
for (const service of existing.services) {
|
|
604
|
+
if (service.name !== RAILWAY_SERVICE_NAME) {
|
|
605
|
+
throw new RailwayNameTakenError(existing.name, existing.id, `su servicio se llama \`${service.name}\` y el del kit es \`${RAILWAY_SERVICE_NAME}\``);
|
|
606
|
+
}
|
|
607
|
+
if (service.instances === null) {
|
|
608
|
+
throw new RailwayNameTakenError(existing.name, existing.id, `Railway no dijo a qué repo está conectado su servicio \`${service.name}\``);
|
|
609
|
+
}
|
|
610
|
+
const image = service.instances.find((i) => i.image !== null);
|
|
611
|
+
if (image) {
|
|
612
|
+
throw new RailwayNameTakenError(existing.name, existing.id, `su servicio \`${service.name}\` corre una imagen (\`${image.image}\`), no un repo`);
|
|
613
|
+
}
|
|
614
|
+
const foreign = service.instances.find((i) => i.repo !== null && !sameRepo(i.repo, repo));
|
|
615
|
+
if (foreign) {
|
|
616
|
+
throw new RailwayNameTakenError(existing.name, existing.id, `su servicio \`${service.name}\` está conectado a otro repo (\`${foreign.repo}\`)`);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
return { id: existing.id, created: false };
|
|
620
|
+
}
|
|
621
|
+
const data = await call('projectCreate', { input: { name: args.name, workspaceId } }, `crear el proyecto \`${args.name}\``);
|
|
622
|
+
const created = data.projectCreate;
|
|
623
|
+
if (!created || typeof created.id !== 'string') {
|
|
624
|
+
throw new RailwayApiError('unrecognized', `Railway no devolvió el id del proyecto \`${args.name}\` al crearlo. Revisa el dashboard antes de repetir.`);
|
|
625
|
+
}
|
|
626
|
+
return { id: created.id, created: true };
|
|
627
|
+
},
|
|
628
|
+
async deleteProject(id) {
|
|
629
|
+
await call('projectDelete', { id }, `borrar el proyecto \`${id}\``);
|
|
630
|
+
},
|
|
631
|
+
async renameEnvironment({ projectId, from, to }) {
|
|
632
|
+
const project = await projectOrThrow(projectId, `renombrar el entorno \`${from}\``);
|
|
633
|
+
const already = project.environments.find((e) => e.name === to);
|
|
634
|
+
if (already)
|
|
635
|
+
return { id: already.id, renamed: false };
|
|
636
|
+
const source = project.environments.find((e) => e.name === from);
|
|
637
|
+
if (!source) {
|
|
638
|
+
throw new RailwayApiError('not-found', `El proyecto \`${project.name}\` de Railway no tiene un entorno \`${from}\` ni \`${to}\`. Tiene: ` +
|
|
639
|
+
`${project.environments.map((e) => `\`${e.name}\``).join(', ') || '(ninguno)'}.`);
|
|
640
|
+
}
|
|
641
|
+
await call('environmentRename', { id: source.id, input: { name: to } }, `renombrar el entorno \`${from}\` a \`${to}\``);
|
|
642
|
+
return { id: source.id, renamed: true };
|
|
643
|
+
},
|
|
644
|
+
async ensureEnvironment({ projectId, name, sourceEnvironmentId, skipInitialDeploys }) {
|
|
645
|
+
const project = await projectOrThrow(projectId, `crear el entorno \`${name}\``);
|
|
646
|
+
const existing = project.environments.find((e) => e.name === name);
|
|
647
|
+
if (existing)
|
|
648
|
+
return { id: existing.id, created: false };
|
|
649
|
+
const input = { name, projectId };
|
|
650
|
+
if (sourceEnvironmentId !== undefined)
|
|
651
|
+
input.sourceEnvironmentId = sourceEnvironmentId;
|
|
652
|
+
if (skipInitialDeploys !== undefined)
|
|
653
|
+
input.skipInitialDeploys = skipInitialDeploys;
|
|
654
|
+
const data = await call('environmentCreate', { input }, `crear el entorno \`${name}\``);
|
|
655
|
+
const created = data.environmentCreate;
|
|
656
|
+
if (!created || typeof created.id !== 'string') {
|
|
657
|
+
throw new RailwayApiError('unrecognized', `Railway no devolvió el id del entorno \`${name}\` al crearlo.`);
|
|
658
|
+
}
|
|
659
|
+
return { id: created.id, created: true };
|
|
660
|
+
},
|
|
661
|
+
async deleteEnvironment(id) {
|
|
662
|
+
await call('environmentDelete', { id }, `borrar el entorno \`${id}\``);
|
|
663
|
+
},
|
|
664
|
+
async ensureService(args) {
|
|
665
|
+
const project = await projectOrThrow(args.projectId, `crear el servicio \`${args.name}\``);
|
|
666
|
+
const existing = project.services.find((s) => s.name === args.name);
|
|
667
|
+
if (existing)
|
|
668
|
+
return { id: existing.id, created: false };
|
|
669
|
+
// Built field by field: whatever else a caller forces into `args` never travels.
|
|
670
|
+
const input = { projectId: args.projectId, name: args.name };
|
|
671
|
+
if (args.source && 'repo' in args.source && typeof args.source.repo === 'string') {
|
|
672
|
+
input.source = { repo: args.source.repo };
|
|
673
|
+
}
|
|
674
|
+
else if (args.source && 'image' in args.source && typeof args.source.image === 'string') {
|
|
675
|
+
input.source = { image: args.source.image };
|
|
676
|
+
}
|
|
677
|
+
if (typeof args.branch === 'string')
|
|
678
|
+
input.branch = args.branch;
|
|
679
|
+
const data = await call('serviceCreate', { input }, `crear el servicio \`${args.name}\``);
|
|
680
|
+
const created = data.serviceCreate;
|
|
681
|
+
if (!created || typeof created.id !== 'string') {
|
|
682
|
+
throw new RailwayApiError('unrecognized', `Railway no devolvió el id del servicio \`${args.name}\` al crearlo.`);
|
|
683
|
+
}
|
|
684
|
+
return { id: created.id, created: true };
|
|
685
|
+
},
|
|
686
|
+
async connectService({ serviceId, repo, branch }) {
|
|
687
|
+
const input = { repo };
|
|
688
|
+
if (branch !== undefined)
|
|
689
|
+
input.branch = branch;
|
|
690
|
+
await call('serviceConnect', { id: serviceId, input }, `conectar \`${repo}\` al servicio \`${serviceId}\``);
|
|
691
|
+
},
|
|
692
|
+
listTriggers,
|
|
693
|
+
async ensureBranchTrigger({ projectId, environmentId, serviceId, branch, repository }) {
|
|
694
|
+
const [existing] = await listTriggers({ serviceId, environmentId });
|
|
695
|
+
if (existing) {
|
|
696
|
+
if (existing.branch === branch)
|
|
697
|
+
return { id: existing.id, created: false, updated: false };
|
|
698
|
+
await call('deploymentTriggerUpdate', { id: existing.id, input: { branch } }, `cambiar la rama del servicio \`${serviceId}\` a \`${branch}\``);
|
|
699
|
+
return { id: existing.id, created: false, updated: true };
|
|
700
|
+
}
|
|
701
|
+
const data = await call('deploymentTriggerCreate', {
|
|
702
|
+
input: {
|
|
703
|
+
branch,
|
|
704
|
+
environmentId,
|
|
705
|
+
projectId,
|
|
706
|
+
provider: RAILWAY_TRIGGER_PROVIDER,
|
|
707
|
+
repository,
|
|
708
|
+
serviceId,
|
|
709
|
+
},
|
|
710
|
+
}, `crear el trigger de \`${branch}\` del servicio \`${serviceId}\``);
|
|
711
|
+
const created = data.deploymentTriggerCreate;
|
|
712
|
+
if (!created || typeof created.id !== 'string') {
|
|
713
|
+
throw new RailwayApiError('unrecognized', `Railway no devolvió el id del trigger de \`${branch}\` al crearlo.`);
|
|
714
|
+
}
|
|
715
|
+
return { id: created.id, created: true, updated: false };
|
|
716
|
+
},
|
|
717
|
+
async enableAutoDeploy(scope) {
|
|
718
|
+
// The mutation is NOT idempotent (it aborts when the pair already has its trigger): ask first.
|
|
719
|
+
const existing = await listTriggers({
|
|
720
|
+
serviceId: scope.serviceId,
|
|
721
|
+
environmentId: scope.environmentId,
|
|
722
|
+
});
|
|
723
|
+
if (existing.length > 0)
|
|
724
|
+
return { created: false };
|
|
725
|
+
await call('serviceInstanceAutoDeployUpdate', {
|
|
726
|
+
input: {
|
|
727
|
+
projectId: scope.projectId,
|
|
728
|
+
environmentId: scope.environmentId,
|
|
729
|
+
serviceId: scope.serviceId,
|
|
730
|
+
enabled: true,
|
|
731
|
+
},
|
|
732
|
+
}, `encender el auto-deploy de ${serviceLabel(scope)}`);
|
|
733
|
+
return { created: true };
|
|
734
|
+
},
|
|
735
|
+
async updateServiceInstance({ serviceId, environmentId, settings }) {
|
|
736
|
+
const input = {};
|
|
737
|
+
for (const key of [
|
|
738
|
+
'region',
|
|
739
|
+
'healthcheckPath',
|
|
740
|
+
'preDeployCommand',
|
|
741
|
+
'startCommand',
|
|
742
|
+
'buildCommand',
|
|
743
|
+
]) {
|
|
744
|
+
if (typeof settings[key] === 'string')
|
|
745
|
+
input[key] = settings[key];
|
|
746
|
+
}
|
|
747
|
+
if (Object.keys(input).length === 0)
|
|
748
|
+
return false;
|
|
749
|
+
await call('serviceInstanceUpdate', { serviceId, environmentId, input }, `ajustar el servicio \`${serviceId}\` en su entorno`);
|
|
750
|
+
return true;
|
|
751
|
+
},
|
|
752
|
+
async readVariableNames(scope) {
|
|
753
|
+
return Object.keys(await readRawVariables(scope)).sort();
|
|
754
|
+
},
|
|
755
|
+
async readVariableHashes(scope, hash) {
|
|
756
|
+
const hashes = new Map();
|
|
757
|
+
for (const [key, value] of Object.entries(await readRawVariables(scope))) {
|
|
758
|
+
if (typeof value === 'string')
|
|
759
|
+
hashes.set(key, hash(value));
|
|
760
|
+
}
|
|
761
|
+
return hashes;
|
|
762
|
+
},
|
|
763
|
+
async readVariableValues(scope) {
|
|
764
|
+
const values = new Map();
|
|
765
|
+
for (const [key, value] of Object.entries(await readRawVariables(scope))) {
|
|
766
|
+
if (typeof value === 'string')
|
|
767
|
+
values.set(key, value);
|
|
768
|
+
}
|
|
769
|
+
return values;
|
|
770
|
+
},
|
|
771
|
+
async ensureServiceDomain(scope) {
|
|
772
|
+
const { serviceDomains } = await readDomains(scope);
|
|
773
|
+
const [existing] = serviceDomains;
|
|
774
|
+
if (existing)
|
|
775
|
+
return { id: existing.id, domain: existing.domain, created: false };
|
|
776
|
+
const data = await call('serviceDomainCreate', {
|
|
777
|
+
input: {
|
|
778
|
+
environmentId: scope.environmentId,
|
|
779
|
+
serviceId: scope.serviceId,
|
|
780
|
+
targetPort: scope.targetPort,
|
|
781
|
+
},
|
|
782
|
+
}, `crear el dominio de Railway de ${serviceLabel(scope)}`);
|
|
783
|
+
const created = data.serviceDomainCreate;
|
|
784
|
+
if (!created || typeof created.id !== 'string' || typeof created.domain !== 'string') {
|
|
785
|
+
throw new RailwayApiError('unrecognized', `Railway no devolvió el dominio de ${serviceLabel(scope)} al crearlo.`);
|
|
786
|
+
}
|
|
787
|
+
return { id: created.id, domain: created.domain, created: true };
|
|
788
|
+
},
|
|
789
|
+
async ensureCustomDomain(scope) {
|
|
790
|
+
const host = scope.domain.trim().toLowerCase();
|
|
791
|
+
const { customDomains } = await readDomains(scope);
|
|
792
|
+
const existing = customDomains.find((d) => d.domain.toLowerCase() === host);
|
|
793
|
+
if (existing)
|
|
794
|
+
return { ...existing, created: false };
|
|
795
|
+
const data = await call('customDomainCreate', {
|
|
796
|
+
input: {
|
|
797
|
+
domain: host,
|
|
798
|
+
environmentId: scope.environmentId,
|
|
799
|
+
projectId: scope.projectId,
|
|
800
|
+
serviceId: scope.serviceId,
|
|
801
|
+
targetPort: scope.targetPort,
|
|
802
|
+
},
|
|
803
|
+
}, `crear el dominio \`${host}\` en ${serviceLabel(scope)}`);
|
|
804
|
+
const created = parseCustomDomain(data.customDomainCreate);
|
|
805
|
+
if (!created) {
|
|
806
|
+
throw new RailwayApiError('unrecognized', `Railway no devolvió el dominio \`${host}\` al crearlo.`);
|
|
807
|
+
}
|
|
808
|
+
return { ...created, created: true };
|
|
809
|
+
},
|
|
810
|
+
async getCustomDomain(scope, domain) {
|
|
811
|
+
const host = domain.trim().toLowerCase();
|
|
812
|
+
const { customDomains } = await readDomains(scope);
|
|
813
|
+
return customDomains.find((d) => d.domain.toLowerCase() === host) ?? null;
|
|
814
|
+
},
|
|
815
|
+
async listCustomDomains(scope) {
|
|
816
|
+
return (await readDomains(scope)).customDomains;
|
|
817
|
+
},
|
|
818
|
+
async deleteCustomDomain(id) {
|
|
819
|
+
await call('customDomainDelete', { id }, `borrar el dominio \`${id}\``);
|
|
820
|
+
},
|
|
821
|
+
listDeployments,
|
|
822
|
+
async findDeploymentByCommit(scope, commit) {
|
|
823
|
+
const prefix = commit.trim().toLowerCase();
|
|
824
|
+
if (prefix.length < MIN_COMMIT_PREFIX) {
|
|
825
|
+
throw new CLIError(`El commit \`${commit}\` es muy corto para buscar su deployment (mínimo ${MIN_COMMIT_PREFIX} caracteres).`);
|
|
826
|
+
}
|
|
827
|
+
const list = await listDeployments(scope);
|
|
828
|
+
return (list.find((d) => d.commitHash !== null && d.commitHash.toLowerCase().startsWith(prefix)) ??
|
|
829
|
+
null);
|
|
830
|
+
},
|
|
831
|
+
async getDeployment(id) {
|
|
832
|
+
const data = await callOrNull('deployment', { id }, `leer el deployment \`${id}\``);
|
|
833
|
+
if (!data)
|
|
834
|
+
return null;
|
|
835
|
+
return parseDeployment(data.deployment);
|
|
836
|
+
},
|
|
837
|
+
async deployServiceInstance(scope, commit) {
|
|
838
|
+
const sha = commit.trim().toLowerCase();
|
|
839
|
+
if (sha.length < MIN_COMMIT_PREFIX) {
|
|
840
|
+
throw new CLIError(`El commit \`${commit}\` es muy corto para desplegarlo (mínimo ${MIN_COMMIT_PREFIX} caracteres).`);
|
|
841
|
+
}
|
|
842
|
+
const data = await call('serviceInstanceDeployV2', { serviceId: scope.serviceId, environmentId: scope.environmentId, commitSha: sha }, `desplegar ${serviceLabel(scope)}`);
|
|
843
|
+
const id = data.serviceInstanceDeployV2;
|
|
844
|
+
if (typeof id !== 'string' || id === '') {
|
|
845
|
+
throw new RailwayApiError('unrecognized', `Railway no devolvió el id del deployment de ${serviceLabel(scope)}. Revisa el dashboard antes de repetir.`);
|
|
846
|
+
}
|
|
847
|
+
return id;
|
|
848
|
+
},
|
|
849
|
+
};
|
|
850
|
+
return client;
|
|
851
|
+
}
|