@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,411 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const fs = require('fs');
5
+ const http = require('http');
6
+ const os = require('os');
7
+ const path = require('path');
8
+ const { spawnSync } = require('child_process');
9
+
10
+ function normalizeStage(stage) {
11
+ const s = String(stage || '')
12
+ .trim()
13
+ .toLowerCase();
14
+ if (!s) return 'prod';
15
+ if (s === 'production') return 'prod';
16
+ if (s === 'staging') return 'qa';
17
+ return s;
18
+ }
19
+
20
+ function resolveScalyDomain(stage) {
21
+ const s = normalizeStage(stage);
22
+ if (s === 'prod') return 'scalyapps.io';
23
+ return `${s}.scalyapps.io`;
24
+ }
25
+
26
+ function getConfigDir() {
27
+ const base =
28
+ process.env.SCALY_CONFIG_DIR ||
29
+ process.env.XDG_CONFIG_HOME ||
30
+ path.join(os.homedir(), '.config');
31
+ return path.join(base, 'scaly');
32
+ }
33
+
34
+ function getAuthStorePath() {
35
+ return path.join(getConfigDir(), 'auth.json');
36
+ }
37
+
38
+ function decodeBase64Url(input) {
39
+ const padLen = (4 - (input.length % 4)) % 4;
40
+ const padded =
41
+ input.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat(padLen);
42
+ return Buffer.from(padded, 'base64').toString('utf8');
43
+ }
44
+
45
+ function tryGetJwtExpMs(token) {
46
+ if (!token || typeof token !== 'string') return null;
47
+ const parts = token.split('.');
48
+ if (parts.length !== 3) return null;
49
+ try {
50
+ const payloadJson = decodeBase64Url(parts[1] || '');
51
+ const payload = JSON.parse(payloadJson);
52
+ const exp = payload && payload.exp;
53
+ return typeof exp === 'number' ? exp * 1000 : null;
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ function safeJsonParse(text) {
60
+ try {
61
+ return JSON.parse(text);
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ function readAuthStore() {
68
+ const p = process.env.SCALY_AUTH_STORE_PATH || getAuthStorePath();
69
+ try {
70
+ const text = fs.readFileSync(p, 'utf8');
71
+ const obj = safeJsonParse(text);
72
+ if (!obj || typeof obj !== 'object') return null;
73
+ if (!obj.access_token || typeof obj.access_token !== 'string') return null;
74
+ return { path: p, session: obj };
75
+ } catch {
76
+ return null;
77
+ }
78
+ }
79
+
80
+ function ensureFilePerms0600(p) {
81
+ try {
82
+ fs.chmodSync(p, 0o600);
83
+ } catch {}
84
+ }
85
+
86
+ function writeAuthStore(session) {
87
+ const dir = getConfigDir();
88
+ const p = process.env.SCALY_AUTH_STORE_PATH || getAuthStorePath();
89
+ fs.mkdirSync(dir, { recursive: true });
90
+ fs.writeFileSync(p, JSON.stringify(session, null, 2));
91
+ ensureFilePerms0600(p);
92
+ return p;
93
+ }
94
+
95
+ function clearAuthStore() {
96
+ const p = process.env.SCALY_AUTH_STORE_PATH || getAuthStorePath();
97
+ try {
98
+ fs.unlinkSync(p);
99
+ return true;
100
+ } catch {
101
+ return false;
102
+ }
103
+ }
104
+
105
+ function openUrl(url) {
106
+ const plat = process.platform;
107
+ const trySpawn = (cmd, args) => {
108
+ const res = spawnSync(cmd, args, { stdio: 'ignore', shell: false });
109
+ return res && res.status === 0;
110
+ };
111
+ if (plat === 'darwin') return trySpawn('open', [url]);
112
+ if (plat === 'win32') return trySpawn('cmd', ['/c', 'start', '', url]);
113
+ return trySpawn('xdg-open', [url]);
114
+ }
115
+
116
+ function base64UrlEncode(buf) {
117
+ return Buffer.from(buf)
118
+ .toString('base64')
119
+ .replace(/\+/g, '-')
120
+ .replace(/\//g, '_')
121
+ .replace(/=+$/g, '');
122
+ }
123
+
124
+ function createPkcePair() {
125
+ const verifier = base64UrlEncode(crypto.randomBytes(32));
126
+ const challenge = base64UrlEncode(
127
+ crypto.createHash('sha256').update(verifier).digest()
128
+ );
129
+ return { verifier, challenge };
130
+ }
131
+
132
+ async function fetchScalyOidcConfig({ stage, domainOverride }) {
133
+ const axios = require('axios');
134
+ const stageNorm = normalizeStage(stage);
135
+ const domain = domainOverride || resolveScalyDomain(stageNorm);
136
+ const url = `https://${domain}/.well-known/scaly-oidc.json`;
137
+ const res = await axios.get(url, {
138
+ timeout: 15_000,
139
+ validateStatus: () => true
140
+ });
141
+ if (res.status !== 200 || !res.data || typeof res.data !== 'object') {
142
+ const e = new Error(`Failed to fetch ${url} (HTTP ${res.status})`);
143
+ e.code = 'SCALY_OIDC_CONFIG_FETCH_FAILED';
144
+ throw e;
145
+ }
146
+ return { domain, stage: stageNorm, config: res.data };
147
+ }
148
+
149
+ function pick(obj, keys) {
150
+ const out = {};
151
+ for (const k of keys) {
152
+ if (obj && Object.prototype.hasOwnProperty.call(obj, k)) out[k] = obj[k];
153
+ }
154
+ return out;
155
+ }
156
+
157
+ async function oauthPkceLogin({
158
+ authorizationEndpoint,
159
+ tokenEndpoint,
160
+ clientId,
161
+ redirectUri,
162
+ scopes,
163
+ noOpen
164
+ }) {
165
+ const axios = require('axios');
166
+ const { verifier, challenge } = createPkcePair();
167
+ const state = base64UrlEncode(crypto.randomBytes(16));
168
+
169
+ const authUrl = new URL(authorizationEndpoint);
170
+ authUrl.searchParams.set('response_type', 'code');
171
+ authUrl.searchParams.set('client_id', clientId);
172
+ authUrl.searchParams.set('redirect_uri', redirectUri);
173
+ authUrl.searchParams.set('scope', scopes.join(' '));
174
+ authUrl.searchParams.set('state', state);
175
+ authUrl.searchParams.set('code_challenge', challenge);
176
+ authUrl.searchParams.set('code_challenge_method', 'S256');
177
+
178
+ const redirect = new URL(redirectUri);
179
+ const expectedPath = redirect.pathname || '/';
180
+ const expectedPort = Number(redirect.port || '80');
181
+
182
+ const code = await new Promise((resolve, reject) => {
183
+ const server = http.createServer((req, res) => {
184
+ try {
185
+ const reqUrl = new URL(req.url || '/', redirectUri);
186
+ if (reqUrl.pathname !== expectedPath) {
187
+ res.statusCode = 404;
188
+ res.end('Not found');
189
+ return;
190
+ }
191
+ const gotState = reqUrl.searchParams.get('state');
192
+ const gotCode = reqUrl.searchParams.get('code');
193
+ const gotErr = reqUrl.searchParams.get('error');
194
+ if (gotErr) {
195
+ res.statusCode = 400;
196
+ res.end(`Login failed: ${gotErr}`);
197
+ reject(new Error(`OAuth error: ${gotErr}`));
198
+ server.close();
199
+ return;
200
+ }
201
+ if (!gotCode || gotState !== state) {
202
+ res.statusCode = 400;
203
+ res.end('Invalid OAuth callback');
204
+ reject(new Error('Invalid OAuth callback (missing code or state)'));
205
+ server.close();
206
+ return;
207
+ }
208
+ res.statusCode = 200;
209
+ res.setHeader('content-type', 'text/plain');
210
+ res.end('Scaly CLI login complete. You can close this tab.');
211
+ resolve(gotCode);
212
+ server.close();
213
+ } catch (e) {
214
+ reject(e);
215
+ try {
216
+ server.close();
217
+ } catch {}
218
+ }
219
+ });
220
+
221
+ server.listen(expectedPort, redirect.hostname, () => {
222
+ if (noOpen) {
223
+ console.log(authUrl.toString());
224
+ } else {
225
+ const opened = openUrl(authUrl.toString());
226
+ if (!opened) console.log(authUrl.toString());
227
+ }
228
+ });
229
+
230
+ server.on('error', (e) => reject(e));
231
+ });
232
+
233
+ const tokenRes = await axios.post(
234
+ tokenEndpoint,
235
+ new URLSearchParams({
236
+ grant_type: 'authorization_code',
237
+ client_id: clientId,
238
+ code,
239
+ redirect_uri: redirectUri,
240
+ code_verifier: verifier
241
+ }).toString(),
242
+ {
243
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
244
+ timeout: 30_000,
245
+ validateStatus: () => true
246
+ }
247
+ );
248
+
249
+ if (tokenRes.status !== 200 || !tokenRes.data) {
250
+ const e = new Error(`Token exchange failed (HTTP ${tokenRes.status})`);
251
+ e.code = 'SCALY_OIDC_TOKEN_EXCHANGE_FAILED';
252
+ e.details = tokenRes.data;
253
+ throw e;
254
+ }
255
+
256
+ const data = tokenRes.data;
257
+ const accessToken = data.access_token;
258
+ if (!accessToken)
259
+ throw new Error('Token exchange did not return access_token');
260
+
261
+ const expiresInSec =
262
+ typeof data.expires_in === 'number'
263
+ ? data.expires_in
264
+ : Number(data.expires_in || 0) || null;
265
+ const jwtExpMs = tryGetJwtExpMs(accessToken);
266
+ const expiresAt =
267
+ jwtExpMs || (expiresInSec ? Date.now() + expiresInSec * 1000 : null);
268
+
269
+ return {
270
+ access_token: accessToken,
271
+ refresh_token: data.refresh_token || null,
272
+ id_token: data.id_token || null,
273
+ token_type: data.token_type || 'Bearer',
274
+ expires_at: expiresAt
275
+ };
276
+ }
277
+
278
+ async function runAuthLogin(flags) {
279
+ const stage = normalizeStage(
280
+ flags.stage || process.env.SCALY_STAGE || 'prod'
281
+ );
282
+ const domainOverride = flags.domain || null;
283
+ const noOpen = String(flags['no-open'] || '').toLowerCase() === 'true';
284
+
285
+ const { domain, config } = await fetchScalyOidcConfig({
286
+ stage,
287
+ domainOverride
288
+ });
289
+
290
+ const oauth = config.oauth || {};
291
+ const clientId = config.client_id;
292
+ const redirectUri = config.redirect_uri;
293
+ const scopes = Array.isArray(config.scopes)
294
+ ? config.scopes
295
+ : ['openid', 'profile', 'email'];
296
+
297
+ if (
298
+ !clientId ||
299
+ !redirectUri ||
300
+ !oauth.authorization_endpoint ||
301
+ !oauth.token_endpoint
302
+ ) {
303
+ const e = new Error(
304
+ 'Incomplete OIDC config from Scaly. Missing client_id/redirect_uri/oauth endpoints.'
305
+ );
306
+ e.code = 'SCALY_OIDC_CONFIG_INVALID';
307
+ e.details = pick(config, ['client_id', 'redirect_uri', 'oauth']);
308
+ throw e;
309
+ }
310
+
311
+ const tokens = await oauthPkceLogin({
312
+ authorizationEndpoint: oauth.authorization_endpoint,
313
+ tokenEndpoint: oauth.token_endpoint,
314
+ clientId,
315
+ redirectUri,
316
+ scopes,
317
+ noOpen
318
+ });
319
+
320
+ const session = {
321
+ version: 1,
322
+ stage,
323
+ domain,
324
+ client_id: clientId,
325
+ scopes,
326
+ issued_at: Date.now(),
327
+ expires_at: tokens.expires_at,
328
+ access_token: tokens.access_token,
329
+ refresh_token: tokens.refresh_token,
330
+ id_token: tokens.id_token
331
+ };
332
+
333
+ const p = writeAuthStore(session);
334
+ console.log(`Saved Scaly session to ${p}`);
335
+ return 0;
336
+ }
337
+
338
+ function runAuthStatus(flags) {
339
+ const found = readAuthStore();
340
+ const json = String(flags.json || '').toLowerCase() === 'true';
341
+ if (!found) {
342
+ if (json) console.log(JSON.stringify({ authenticated: false }));
343
+ else console.log('Not authenticated. Run: scaly auth login');
344
+ return 1;
345
+ }
346
+
347
+ const s = found.session;
348
+ const expMs =
349
+ typeof s.expires_at === 'number'
350
+ ? s.expires_at
351
+ : tryGetJwtExpMs(s.access_token) || null;
352
+ const expiresInSec = expMs
353
+ ? Math.max(0, Math.floor((expMs - Date.now()) / 1000))
354
+ : null;
355
+
356
+ const out = {
357
+ authenticated: true,
358
+ stage: s.stage || null,
359
+ domain: s.domain || null,
360
+ expires_in_seconds: expiresInSec,
361
+ expired_at:
362
+ expMs && expMs <= Date.now() ? new Date(expMs).toISOString() : null,
363
+ store_path: found.path
364
+ };
365
+
366
+ if (json) console.log(JSON.stringify(out));
367
+ else {
368
+ console.log(
369
+ `Authenticated (${out.stage || 'unknown'}). Expires in ${
370
+ out.expires_in_seconds === null
371
+ ? 'unknown'
372
+ : `${out.expires_in_seconds}s`
373
+ }.`
374
+ );
375
+ }
376
+
377
+ return 0;
378
+ }
379
+
380
+ function runAuthLogout(flags) {
381
+ const json = String(flags.json || '').toLowerCase() === 'true';
382
+ const ok = clearAuthStore();
383
+ if (json) console.log(JSON.stringify({ ok }));
384
+ else console.log(ok ? 'Logged out.' : 'No auth session found.');
385
+ return ok ? 0 : 1;
386
+ }
387
+
388
+ async function runAuth(sub, flags) {
389
+ const action = String(sub || '')
390
+ .trim()
391
+ .toLowerCase();
392
+ if (!action || action === 'help' || action === '--help' || action === '-h') {
393
+ console.log(
394
+ `Usage:\n scaly auth login [--stage prod|dev|qa] [--no-open]\n scaly auth status [--json]\n scaly auth logout [--json]\n`
395
+ );
396
+ return 0;
397
+ }
398
+ if (action === 'login') return await runAuthLogin(flags);
399
+ if (action === 'status') return runAuthStatus(flags);
400
+ if (action === 'logout') return runAuthLogout(flags);
401
+ console.error(`Unknown auth command: ${action}`);
402
+ return 2;
403
+ }
404
+
405
+ module.exports = {
406
+ normalizeStage,
407
+ resolveScalyDomain,
408
+ getAuthStorePath,
409
+ readAuthStore,
410
+ runAuth
411
+ };
@@ -0,0 +1,137 @@
1
+ 'use strict';
2
+
3
+ const api = require('./scaly-api');
4
+
5
+ const GET_APP = `
6
+ query GetApp($where: AppWhereUniqueInput!) {
7
+ getApp(where: $where) {
8
+ id
9
+ name
10
+ accountId
11
+ stackId
12
+ }
13
+ }
14
+ `;
15
+
16
+ const GET_APP_GIT_SOURCE = `
17
+ query GetAppGitSource($appId: String!) {
18
+ getAppGitSource(appId: $appId) {
19
+ id
20
+ provider
21
+ repoFullName
22
+ branch
23
+ path
24
+ autoDeploy
25
+ }
26
+ }
27
+ `;
28
+
29
+ const TRIGGER_GIT_DEPLOY = `
30
+ mutation TriggerGitDeploy($appId: String!) {
31
+ triggerGitDeploy(appId: $appId) {
32
+ id
33
+ status
34
+ triggeredAt
35
+ }
36
+ }
37
+ `;
38
+
39
+ const LIST_GIT_DEPLOYMENTS = `
40
+ query ListGitDeployments($appId: String!, $limit: Int) {
41
+ listGitDeployments(appId: $appId, limit: $limit) {
42
+ id
43
+ status
44
+ trigger
45
+ branch
46
+ commitSha
47
+ commitMessage
48
+ commitAuthor
49
+ triggeredAt
50
+ startedAt
51
+ finishedAt
52
+ errorMessage
53
+ deploymentId
54
+ }
55
+ }
56
+ `;
57
+
58
+ const RESTART_STACK_SERVICES = `
59
+ mutation RestartStackServices($stackId: String!) {
60
+ restartStackServices(stackId: $stackId) {
61
+ id
62
+ status
63
+ step
64
+ progressPct
65
+ errorCode
66
+ errorMessage
67
+ remediationHint
68
+ queuedAt
69
+ startedAt
70
+ finishedAt
71
+ updatedAt
72
+ }
73
+ }
74
+ `;
75
+
76
+ const GET_DEPLOYMENT = `
77
+ query GetDeployment($where: DeploymentWhereUniqueInput!) {
78
+ getDeployment(where: $where) {
79
+ id
80
+ status
81
+ step
82
+ progressPct
83
+ errorCode
84
+ errorMessage
85
+ remediationHint
86
+ queuedAt
87
+ startedAt
88
+ finishedAt
89
+ updatedAt
90
+ }
91
+ }
92
+ `;
93
+
94
+ async function getAppBasic(appId) {
95
+ const data = await api.graphqlRequest(GET_APP, { where: { id: appId } });
96
+ return data?.getApp || null;
97
+ }
98
+
99
+ async function getAppGitSource(appId) {
100
+ try {
101
+ const data = await api.graphqlRequest(GET_APP_GIT_SOURCE, { appId });
102
+ return data?.getAppGitSource || null;
103
+ } catch {
104
+ return null;
105
+ }
106
+ }
107
+
108
+ async function triggerGitDeploy(appId) {
109
+ const data = await api.graphqlRequest(TRIGGER_GIT_DEPLOY, { appId });
110
+ return data?.triggerGitDeploy || null;
111
+ }
112
+
113
+ async function listGitDeployments(appId, limit = 10) {
114
+ const data = await api.graphqlRequest(LIST_GIT_DEPLOYMENTS, { appId, limit });
115
+ return data?.listGitDeployments || [];
116
+ }
117
+
118
+ async function restartStackServices(stackId) {
119
+ const data = await api.graphqlRequest(RESTART_STACK_SERVICES, { stackId });
120
+ return data?.restartStackServices || null;
121
+ }
122
+
123
+ async function getDeployment(deploymentId) {
124
+ const data = await api.graphqlRequest(GET_DEPLOYMENT, {
125
+ where: { id: deploymentId }
126
+ });
127
+ return data?.getDeployment || null;
128
+ }
129
+
130
+ module.exports = {
131
+ getAppBasic,
132
+ getAppGitSource,
133
+ triggerGitDeploy,
134
+ listGitDeployments,
135
+ restartStackServices,
136
+ getDeployment
137
+ };
@@ -0,0 +1,110 @@
1
+ 'use strict';
2
+
3
+ const api = require('./scaly-api');
4
+
5
+ const GET_APP = `
6
+ query GetApp($where: AppWhereUniqueInput!) {
7
+ getApp(where: $where) {
8
+ id
9
+ name
10
+ accountId
11
+ stackId
12
+ }
13
+ }
14
+ `;
15
+
16
+ const GET_UNIFIED_LOGS = `
17
+ query GetUnifiedLogs($where: UnifiedLogsWhereInput!) {
18
+ getUnifiedLogs(where: $where) {
19
+ truncated
20
+ nextToken
21
+ cursorStart
22
+ cursorEnd
23
+ diagnosticsBundleUrl
24
+ events {
25
+ id
26
+ ts
27
+ message
28
+ level
29
+ source
30
+ appId
31
+ appName
32
+ stackId
33
+ stackName
34
+ deploymentId
35
+ logStreamName
36
+ ingestionTime
37
+ jsonPayload
38
+ }
39
+ }
40
+ }
41
+ `;
42
+
43
+ function parseTimeRangeToLookbackHours(timeRange) {
44
+ const m = String(timeRange || '')
45
+ .trim()
46
+ .match(/^(\d+)([smhd])$/i);
47
+ if (!m) return null;
48
+ const value = Number(m[1]);
49
+ const unit = m[2].toLowerCase();
50
+ if (!Number.isFinite(value) || value <= 0) return null;
51
+ switch (unit) {
52
+ case 's':
53
+ return value / 3600;
54
+ case 'm':
55
+ return value / 60;
56
+ case 'h':
57
+ return value;
58
+ case 'd':
59
+ return value * 24;
60
+ default:
61
+ return null;
62
+ }
63
+ }
64
+
65
+ function mapLevel(level) {
66
+ const l = String(level || 'all').toLowerCase();
67
+ if (l === 'all') return undefined;
68
+ if (l === 'error') return ['Error'];
69
+ if (l === 'warn') return ['Warn'];
70
+ if (l === 'info') return ['Info'];
71
+ if (l === 'debug') return ['Debug'];
72
+ return undefined;
73
+ }
74
+
75
+ async function getAppBasic(appId) {
76
+ const data = await api.graphqlRequest(GET_APP, { where: { id: appId } });
77
+ return data?.getApp || null;
78
+ }
79
+
80
+ async function getUnifiedLogs({
81
+ accountId,
82
+ appIds,
83
+ lookbackHours,
84
+ liveFromTs,
85
+ limit,
86
+ q,
87
+ level,
88
+ pageToken
89
+ }) {
90
+ const where = {
91
+ accountId,
92
+ appIds,
93
+ lookbackHours,
94
+ liveFromTs,
95
+ limit,
96
+ q,
97
+ pageToken
98
+ };
99
+ const levels = mapLevel(level);
100
+ if (levels) where.levels = levels;
101
+
102
+ const data = await api.graphqlRequest(GET_UNIFIED_LOGS, { where });
103
+ return data?.getUnifiedLogs || null;
104
+ }
105
+
106
+ module.exports = {
107
+ parseTimeRangeToLookbackHours,
108
+ getAppBasic,
109
+ getUnifiedLogs
110
+ };