@occam-scaly/scaly-cli 0.1.0

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.
@@ -0,0 +1,331 @@
1
+ 'use strict';
2
+
3
+ const axios = require('axios');
4
+
5
+ const STAGE_ENDPOINTS = {
6
+ prod: 'https://api.scaly.cloud/graphql',
7
+ dev: 'https://api-dev.scaly.cloud/graphql',
8
+ staging: 'https://api-staging.scaly.cloud/graphql'
9
+ };
10
+
11
+ function resolveApiEndpoint() {
12
+ return (
13
+ process.env.SCALY_API_URL ||
14
+ process.env.API_ENDPOINT ||
15
+ STAGE_ENDPOINTS[process.env.SCALY_STAGE || 'prod'] ||
16
+ STAGE_ENDPOINTS.prod
17
+ );
18
+ }
19
+
20
+ function resolveAuth() {
21
+ const bearer =
22
+ process.env.SCALY_API_BEARER ||
23
+ process.env.API_BEARER_TOKEN ||
24
+ process.env.API_BEARER;
25
+ if (bearer) {
26
+ return { mode: 'bearer', token: bearer };
27
+ }
28
+
29
+ const scalyApiKey = process.env.SCALY_API_KEY;
30
+ const appSyncApiKey = process.env.SCALY_APPSYNC_KEY;
31
+ if (scalyApiKey && appSyncApiKey) {
32
+ return { mode: 'keys', scalyApiKey, appSyncApiKey };
33
+ }
34
+
35
+ return null;
36
+ }
37
+
38
+ function buildHeaders(auth) {
39
+ if (!auth) return {};
40
+ if (auth.mode === 'bearer') {
41
+ return { authorization: `Bearer ${auth.token}` };
42
+ }
43
+ return {
44
+ 'x-api-key': auth.appSyncApiKey,
45
+ 'x-scaly-api-key': auth.scalyApiKey
46
+ };
47
+ }
48
+
49
+ async function graphqlRequest(query, variables) {
50
+ const endpoint = resolveApiEndpoint();
51
+ const auth = resolveAuth();
52
+ if (!auth) {
53
+ const e = new Error(
54
+ 'Missing auth. Provide SCALY_API_BEARER (or run `scaly login`) OR set SCALY_API_KEY + SCALY_APPSYNC_KEY.'
55
+ );
56
+ e.code = 'SCALY_AUTH_MISSING';
57
+ throw e;
58
+ }
59
+ const res = await axios.post(
60
+ endpoint,
61
+ { query, variables },
62
+ {
63
+ headers: {
64
+ 'content-type': 'application/json',
65
+ ...buildHeaders(auth)
66
+ },
67
+ timeout: 60_000
68
+ }
69
+ );
70
+ if (res.data?.errors?.length) {
71
+ const message =
72
+ res.data.errors?.[0]?.message || JSON.stringify(res.data.errors);
73
+ const e = new Error(`Scaly API error: ${message}`);
74
+ e.code = 'SCALY_API_ERROR';
75
+ e.details = res.data.errors;
76
+ throw e;
77
+ }
78
+ return res.data?.data;
79
+ }
80
+
81
+ const QUERIES = {
82
+ listAccountsBySlug: `
83
+ query ListAccountsBySlug($slug: String!) {
84
+ listAccounts(
85
+ where: {
86
+ AND: [
87
+ { slug: { equals: $slug } }
88
+ { isDeleted: { equals: false } }
89
+ ]
90
+ }
91
+ take: 5
92
+ ) {
93
+ id
94
+ name
95
+ slug
96
+ region
97
+ }
98
+ }
99
+ `,
100
+ listStacksByName: `
101
+ query ListStacksByName($name: String!) {
102
+ listStacks(
103
+ where: {
104
+ AND: [
105
+ { name: { equals: $name } }
106
+ { isDeleted: { equals: false } }
107
+ ]
108
+ }
109
+ take: 5
110
+ ) {
111
+ id
112
+ name
113
+ accountId
114
+ size
115
+ minIdle
116
+ status
117
+ }
118
+ }
119
+ `,
120
+ listAppsByName: `
121
+ query ListAppsByName($name: String!) {
122
+ listApps(
123
+ where: {
124
+ AND: [
125
+ { name: { equals: $name } }
126
+ { isDeleted: { equals: false } }
127
+ ]
128
+ }
129
+ take: 5
130
+ ) {
131
+ id
132
+ name
133
+ accountId
134
+ stackId
135
+ status
136
+ minIdle
137
+ }
138
+ }
139
+ `,
140
+ listAddOnsByName: `
141
+ query ListAddOnsByName($name: String!) {
142
+ listAddOns(
143
+ where: {
144
+ AND: [
145
+ { name: { equals: $name } }
146
+ { isDeleted: { equals: false } }
147
+ ]
148
+ }
149
+ take: 5
150
+ ) {
151
+ id
152
+ name
153
+ type
154
+ accountId
155
+ status
156
+ addOnDatabase { size type }
157
+ addOnCognito { userPoolName userPoolId }
158
+ addOnStorageS3 { bucket }
159
+ }
160
+ }
161
+ `,
162
+ listStacks: `
163
+ query ListStacks($take: Int) {
164
+ listStacks(
165
+ where: { isDeleted: { equals: false } }
166
+ take: $take
167
+ ) {
168
+ id
169
+ name
170
+ accountId
171
+ size
172
+ minIdle
173
+ status
174
+ }
175
+ }
176
+ `,
177
+ listApps: `
178
+ query ListApps($take: Int) {
179
+ listApps(
180
+ where: { isDeleted: { equals: false } }
181
+ take: $take
182
+ ) {
183
+ id
184
+ name
185
+ accountId
186
+ stackId
187
+ status
188
+ minIdle
189
+ }
190
+ }
191
+ `
192
+ };
193
+
194
+ const MUTATIONS = {
195
+ createStack: `
196
+ mutation CreateStack($data: StackCreateInput!) {
197
+ createStack(data: $data) { id name accountId size minIdle status }
198
+ }
199
+ `,
200
+ updateStack: `
201
+ mutation UpdateStack($where: StackWhereUniqueInput!, $data: StackUpdateInput) {
202
+ updateStack(where: $where, data: $data) { id name accountId size minIdle status }
203
+ }
204
+ `,
205
+ createApp: `
206
+ mutation CreateApp($data: AppCreateInput!) {
207
+ createApp(data: $data) { id name accountId stackId status minIdle }
208
+ }
209
+ `,
210
+ updateApp: `
211
+ mutation UpdateApp($where: AppWhereUniqueInput!, $data: AppUpdateInput) {
212
+ updateApp(where: $where, data: $data) { id name accountId stackId status minIdle }
213
+ }
214
+ `,
215
+ createAddOn: `
216
+ mutation CreateAddOn($data: AddOnCreateInput!) {
217
+ createAddOn(data: $data) { id name type accountId status }
218
+ }
219
+ `
220
+ };
221
+
222
+ async function findStackByName(name) {
223
+ const data = await graphqlRequest(QUERIES.listStacksByName, { name });
224
+ return data?.listStacks || [];
225
+ }
226
+
227
+ async function findAppByName(name) {
228
+ const data = await graphqlRequest(QUERIES.listAppsByName, { name });
229
+ return data?.listApps || [];
230
+ }
231
+
232
+ async function listStacks({ take = 50 } = {}) {
233
+ const data = await graphqlRequest(QUERIES.listStacks, { take });
234
+ return data?.listStacks || [];
235
+ }
236
+
237
+ async function listApps({ take = 100 } = {}) {
238
+ const data = await graphqlRequest(QUERIES.listApps, { take });
239
+ return data?.listApps || [];
240
+ }
241
+
242
+ async function findAccountBySlug(slug) {
243
+ try {
244
+ const data = await graphqlRequest(QUERIES.listAccountsBySlug, { slug });
245
+ return data?.listAccounts?.[0] || null;
246
+ } catch {
247
+ return null;
248
+ }
249
+ }
250
+
251
+ async function findAddOnByName(name) {
252
+ const data = await graphqlRequest(QUERIES.listAddOnsByName, { name });
253
+ return data?.listAddOns || [];
254
+ }
255
+
256
+ async function createStack({ accountId, name, size, minIdle }) {
257
+ const data = await graphqlRequest(MUTATIONS.createStack, {
258
+ data: {
259
+ name,
260
+ size,
261
+ minIdle: typeof minIdle === 'number' ? minIdle : undefined,
262
+ account: { connect: { id: accountId } }
263
+ }
264
+ });
265
+ return data?.createStack;
266
+ }
267
+
268
+ async function updateStack({ id, name, size, minIdle }) {
269
+ const data = await graphqlRequest(MUTATIONS.updateStack, {
270
+ where: { id },
271
+ data: {
272
+ name: name || undefined,
273
+ size: size || undefined,
274
+ minIdle: typeof minIdle === 'number' ? minIdle : undefined
275
+ }
276
+ });
277
+ return data?.updateStack;
278
+ }
279
+
280
+ async function createApp({ accountId, stackId, name, minIdle }) {
281
+ const data = await graphqlRequest(MUTATIONS.createApp, {
282
+ data: {
283
+ name,
284
+ minIdle: typeof minIdle === 'number' ? minIdle : undefined,
285
+ account: { connect: { id: accountId } },
286
+ stack: stackId ? { connect: { id: stackId } } : undefined
287
+ }
288
+ });
289
+ return data?.createApp;
290
+ }
291
+
292
+ async function updateApp({ id, stackId, name, minIdle }) {
293
+ const data = await graphqlRequest(MUTATIONS.updateApp, {
294
+ where: { id },
295
+ data: {
296
+ name: name || undefined,
297
+ minIdle: typeof minIdle === 'number' ? minIdle : undefined,
298
+ stack: stackId ? { connect: { id: stackId } } : undefined
299
+ }
300
+ });
301
+ return data?.updateApp;
302
+ }
303
+
304
+ async function createAddOn({ accountId, name, type, database }) {
305
+ const data = await graphqlRequest(MUTATIONS.createAddOn, {
306
+ data: {
307
+ name,
308
+ type,
309
+ account: { connect: { id: accountId } },
310
+ addOnDatabase: database ? { create: database } : undefined
311
+ }
312
+ });
313
+ return data?.createAddOn;
314
+ }
315
+
316
+ module.exports = {
317
+ resolveApiEndpoint,
318
+ resolveAuth,
319
+ graphqlRequest,
320
+ findAccountBySlug,
321
+ findStackByName,
322
+ findAppByName,
323
+ findAddOnByName,
324
+ listStacks,
325
+ listApps,
326
+ createStack,
327
+ updateStack,
328
+ createApp,
329
+ updateApp,
330
+ createAddOn
331
+ };
@@ -0,0 +1,85 @@
1
+ 'use strict';
2
+
3
+ const api = require('./scaly-api');
4
+
5
+ async function applyPlan({ config, plan, autoApprove = false }) {
6
+ const actionable = (plan.operations || []).filter(
7
+ (o) => o.action === 'create' || o.action === 'update'
8
+ );
9
+
10
+ const results = [];
11
+
12
+ let createdStack = null;
13
+ let stackId = null;
14
+ const stackOp = (plan.operations || []).find((o) => o.kind === 'stack');
15
+ if (stackOp?.current?.id) stackId = stackOp.current.id;
16
+
17
+ for (const op of actionable) {
18
+ if (op.kind === 'stack' && op.action === 'create') {
19
+ const res = await api.createStack({
20
+ accountId: op.desired.accountId,
21
+ name: op.desired.name,
22
+ size: op.desired.size,
23
+ minIdle: op.desired.minIdle
24
+ });
25
+ createdStack = res;
26
+ stackId = res?.id || stackId;
27
+ results.push({ op_id: op.id, ok: true, result: res });
28
+ continue;
29
+ }
30
+
31
+ if (op.kind === 'stack' && op.action === 'update') {
32
+ const res = await api.updateStack({
33
+ id: op.resource.id,
34
+ name: op.desired.name,
35
+ size: op.desired.size,
36
+ minIdle: op.desired.minIdle
37
+ });
38
+ stackId = res?.id || stackId;
39
+ results.push({ op_id: op.id, ok: true, result: res });
40
+ continue;
41
+ }
42
+
43
+ if (op.kind === 'app' && op.action === 'create') {
44
+ if (!stackId) {
45
+ const stacks = await api.findStackByName(config.stack.name);
46
+ stackId = stacks?.[0]?.id || null;
47
+ }
48
+ const res = await api.createApp({
49
+ accountId: op.desired.accountId,
50
+ stackId,
51
+ name: op.desired.name
52
+ });
53
+ results.push({ op_id: op.id, ok: true, result: res });
54
+ continue;
55
+ }
56
+
57
+ if (op.kind === 'addon' && op.action === 'create') {
58
+ const res = await api.createAddOn({
59
+ accountId: op.desired.accountId,
60
+ name: op.desired.name,
61
+ type: op.desired.type,
62
+ database: op.desired.database || undefined
63
+ });
64
+ results.push({ op_id: op.id, ok: true, result: res });
65
+ continue;
66
+ }
67
+
68
+ results.push({
69
+ op_id: op.id,
70
+ ok: false,
71
+ error: { message: `Unsupported operation: ${op.kind}:${op.action}` }
72
+ });
73
+ }
74
+
75
+ const ok = results.every((r) => r.ok);
76
+ return {
77
+ ok,
78
+ auto_approve: !!autoApprove,
79
+ applied: actionable.length,
80
+ results,
81
+ created_stack_id: createdStack?.id || null
82
+ };
83
+ }
84
+
85
+ module.exports = { applyPlan };