@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.
package/bin/scaly.js ADDED
@@ -0,0 +1,3083 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const { spawnSync } = require('child_process');
5
+ const path = require('path');
6
+
7
+ function run(cmd, args, opts = {}) {
8
+ const res = spawnSync(cmd, args, { stdio: 'inherit', shell: false, ...opts });
9
+ if (res.error) throw res.error;
10
+ return res.status || 0;
11
+ }
12
+
13
+ function inVault() {
14
+ return (
15
+ Boolean(process.env.AWS_VAULT) || Boolean(process.env.AWS_ACCESS_KEY_ID)
16
+ );
17
+ }
18
+
19
+ function withVault(args) {
20
+ // Default to scaly-dev unless AWS_VAULT already active
21
+ if (inVault()) return args;
22
+ const profile = process.env.SCALY_PROFILE || 'scaly-dev';
23
+ return ['aws-vault', 'exec', profile, '--', ...args];
24
+ }
25
+
26
+ function shellQuote(arg) {
27
+ if (/^[A-Za-z0-9_/:.=+-]+$/.test(arg)) {
28
+ return arg;
29
+ }
30
+ return `'${arg.replace(/'/g, "'\\''")}'`;
31
+ }
32
+
33
+ function normalizeCommand(cmd) {
34
+ if (Array.isArray(cmd)) {
35
+ return cmd.map(shellQuote).join(' ');
36
+ }
37
+ return cmd;
38
+ }
39
+
40
+ function isProbablyJwt(token) {
41
+ // Fast heuristic: three dot-separated base64url-ish segments.
42
+ // This is intentionally loose; we just want to distinguish JWTs from API keys.
43
+ return typeof token === 'string' && token.split('.').length === 3;
44
+ }
45
+
46
+ function withDeployerEnv(inner) {
47
+ // Load deployer env if present so helpers can reach API without extra flags
48
+ const command = normalizeCommand(inner);
49
+ const pre = [
50
+ 'bash',
51
+ '-lc',
52
+ `set -a && [ -f .env.dev.scaly-deployer.local ] && source .env.dev.scaly-deployer.local || true && set +a && ${command}`
53
+ ];
54
+ return inVault() ? pre : withVault(pre);
55
+ }
56
+
57
+ function printHelp() {
58
+ console.log(`
59
+ Scaly CLI (preview)
60
+
61
+ Usage:
62
+ scaly init [--force]
63
+ scaly plan [--env dev|prod] [--app <name>] [--json]
64
+ scaly apply [--env dev|prod] [--app <name>] --plan-hash <sha256:...> [--auto-approve] [--json]
65
+ scaly pull [--force] [--stack <id>] [--app <id>] [--json]
66
+
67
+ scaly auth login [--stage prod|dev|qa] [--no-open]
68
+ scaly auth status [--json]
69
+ scaly auth logout [--json]
70
+
71
+ scaly secrets list --app <appId> [--json]
72
+ scaly secrets set --app <appId> --name <KEY> [--from-env ENV] [--stdin] [--json]
73
+ scaly secrets delete --app <appId> (--name <KEY> | --id <secretId>) [--yes] [--json]
74
+ scaly secrets sync --app <appId> [--config-app <name>] [--env dev|prod] [--dry-run] [--apply] [--json]
75
+
76
+ scaly login --token <api-bearer> [--endpoint https://api.<stage>.scalyapps.io/graphql] | --clear
77
+ scaly db connect --addon <addOnId> [--ttl-minutes 60] [--local-port 5432] [--host 127.0.0.1] [--copy] [--show] [--json]
78
+ scaly db shell --addon <addOnId> [--ttl-minutes 60] [--local-port 5432] [--host 127.0.0.1]
79
+ scaly db schema dump --addon <addOnId> [--out .scaly/schema.sql] [--ttl-minutes 60]
80
+ scaly db migrate <sql-file> --addon <addOnId> [--ttl-minutes 60] [--yes]
81
+ scaly deploy --app <appId> [--watch] [--strategy auto|git|restart] [--json]
82
+ scaly logs --follow --app <appId> [--since 10m] [--level error|warn|info|debug|all] [--q <str>] [--duration-seconds N] [--max-lines N] [--json]
83
+ scaly accounts create --email <email> [--name <org>] [--region EU|US|CANADA|ASIA_PACIFIC]
84
+ scaly stacks create --account <id> --name <stackName> [--size Eco|Basic|...] [--min-idle N]
85
+ scaly apps create --account <id> [--stack <id>] --name <appName> [--template <tpl>]
86
+ scaly logs stack --id <stackId> [--since 30m] [--limit 300] [--json]
87
+ scaly logs account --id <accountId> [--since 30m] [--limit 300] [--json]
88
+ scaly logs tail stack <stackId>
89
+ scaly logs tail app <appId>
90
+ scaly e2e eu-smoke --template fastapi-hello|flask-hello|streamlit-hello
91
+ scaly insights diagnose-app --account <acctId> --stack <stackId> --app <name> [--json]
92
+
93
+ Notes:
94
+ - Wraps existing npm QA helpers and auto-loads .env.dev.scaly-deployer.local
95
+ - If no AWS creds in env, runs under 'aws-vault exec scaly-dev'
96
+ - DB tunnel requires a short-lived Scaly session token. Run: scaly auth login
97
+ `);
98
+ }
99
+
100
+ function main(argv) {
101
+ const [_node, _bin, cmd, sub, ...rest] = argv;
102
+ const args = sub ? [sub, ...rest] : rest;
103
+
104
+ const parseFlags = (arr) => {
105
+ const out = {};
106
+ for (let i = 0; i < arr.length; i++) {
107
+ const a = arr[i];
108
+ if (a.startsWith('--')) {
109
+ const key = a.slice(2);
110
+ const val =
111
+ arr[i + 1] && !arr[i + 1].startsWith('--') ? arr[++i] : 'true';
112
+ out[key] = val;
113
+ } else if (!out._) {
114
+ out._ = [a];
115
+ } else {
116
+ out._.push(a);
117
+ }
118
+ }
119
+ return out;
120
+ };
121
+
122
+ if (!cmd || cmd === '-h' || cmd === '--help') return printHelp();
123
+
124
+ if (cmd === 'login') {
125
+ return runLogin(sub, rest).then((code) => process.exit(code));
126
+ }
127
+
128
+ if (cmd === 'auth') {
129
+ const flags = parseFlags(args);
130
+ const { runAuth } = require('../lib/scaly-auth');
131
+ return runAuth(sub, flags).then((code) => process.exit(code));
132
+ }
133
+
134
+ if (cmd === 'secrets') {
135
+ return runSecrets(sub, rest).then((code) => process.exit(code));
136
+ }
137
+
138
+ if (cmd === 'deploy') {
139
+ return runDeploy([sub, ...rest].filter(Boolean)).then((code) =>
140
+ process.exit(code)
141
+ );
142
+ }
143
+
144
+ if (cmd === 'init') {
145
+ return runProjectInit(args).then((code) => process.exit(code));
146
+ }
147
+
148
+ if (cmd === 'plan') {
149
+ return runProjectPlan(args).then((code) => process.exit(code));
150
+ }
151
+
152
+ if (cmd === 'apply') {
153
+ return runProjectApply(args).then((code) => process.exit(code));
154
+ }
155
+
156
+ if (cmd === 'pull') {
157
+ return runProjectPull(args).then((code) => process.exit(code));
158
+ }
159
+
160
+ if (cmd === 'db' && sub === 'connect') {
161
+ return runDbConnect(rest).then((code) => process.exit(code));
162
+ }
163
+ if (cmd === 'db' && sub === 'shell') {
164
+ return runDbShell(rest).then((code) => process.exit(code));
165
+ }
166
+ if (cmd === 'db' && sub === 'schema' && rest[0] === 'dump') {
167
+ return runDbSchemaDump(rest.slice(1)).then((code) => process.exit(code));
168
+ }
169
+ if (cmd === 'db' && sub === 'migrate') {
170
+ return runDbMigrate(rest).then((code) => process.exit(code));
171
+ }
172
+
173
+ if (cmd === 'accounts' && sub === 'create') {
174
+ const f = parseFlags(rest);
175
+ if (!f.email) {
176
+ console.error('--email is required');
177
+ process.exit(2);
178
+ }
179
+ const regionArg = f.region ? ['--region', f.region] : [];
180
+ const nameArg = f.name ? ['--name', f.name] : [];
181
+ const args = withDeployerEnv([
182
+ 'npm',
183
+ 'run',
184
+ 'qa:create-account',
185
+ '--',
186
+ '--email',
187
+ f.email,
188
+ ...nameArg,
189
+ ...regionArg
190
+ ]);
191
+ process.exit(run(args[0], args.slice(1)));
192
+ }
193
+
194
+ if (cmd === 'apps' && sub === 'update') {
195
+ return runAppsUpdate(rest).then((code) => process.exit(code));
196
+ }
197
+
198
+ if (cmd === 'apps' && sub === 'delete') {
199
+ return runAppsDelete(rest).then((code) => process.exit(code));
200
+ }
201
+
202
+ if (cmd === 'apps' && sub === 'delete-by-prefix') {
203
+ return runAppsDeleteByPrefix(rest).then((code) => process.exit(code));
204
+ }
205
+
206
+ if (cmd === 'stacks' && sub === 'create') {
207
+ const f = parseFlags(rest);
208
+ if (!f.account) {
209
+ console.error('--account is required');
210
+ process.exit(2);
211
+ }
212
+ const nameArg = f.name ? ['--name', f.name] : [];
213
+ const sizeArg = f.size ? ['--size', f.size] : [];
214
+ const minIdleArg = f['min-idle'] ? ['--min-idle', f['min-idle']] : [];
215
+ const args = withDeployerEnv([
216
+ 'npm',
217
+ 'run',
218
+ 'qa:create-stack',
219
+ '--',
220
+ '--account',
221
+ f.account,
222
+ ...nameArg,
223
+ ...sizeArg,
224
+ ...minIdleArg
225
+ ]);
226
+ process.exit(run(args[0], args.slice(1)));
227
+ }
228
+
229
+ if (cmd === 'stacks' && sub === 'update') {
230
+ return runStacksUpdate(rest).then((code) => process.exit(code));
231
+ }
232
+
233
+ if (cmd === 'stacks' && sub === 'delete') {
234
+ return runStacksDelete(rest).then((code) => process.exit(code));
235
+ }
236
+
237
+ if (cmd === 'stacks' && sub === 'delete-by-prefix') {
238
+ return runStacksDeleteByPrefix(rest).then((code) => process.exit(code));
239
+ }
240
+
241
+ if (cmd === 'cleanup' && sub === 'purge') {
242
+ return runCleanupPurge(rest).then((code) => process.exit(code));
243
+ }
244
+
245
+ if (cmd === 'apps' && sub === 'create') {
246
+ const f = parseFlags(rest);
247
+ if (!f.account) {
248
+ console.error('--account is required');
249
+ process.exit(2);
250
+ }
251
+ const stackArg = f.stack ? ['--stack', f.stack] : [];
252
+ const nameArg = f.name ? ['--name', f.name] : [];
253
+ const tplArg = f.template ? ['--template', f.template] : [];
254
+ const args = withDeployerEnv([
255
+ 'npm',
256
+ 'run',
257
+ 'qa:create-app',
258
+ '--',
259
+ '--account',
260
+ f.account,
261
+ ...stackArg,
262
+ ...nameArg,
263
+ ...tplArg
264
+ ]);
265
+ process.exit(run(args[0], args.slice(1)));
266
+ }
267
+
268
+ if (cmd === 'logs' && sub === 'tail') {
269
+ const kind = rest[0];
270
+ const id = rest[1];
271
+ if (!kind || !id) {
272
+ console.error('Usage: scaly logs tail <stack|app> <id>');
273
+ process.exit(2);
274
+ }
275
+ const script = kind === 'stack' ? 'qa:tail-stack' : 'qa:tail-app';
276
+ const args = withDeployerEnv(['npm', 'run', script, '--', id]);
277
+ process.exit(run(args[0], args.slice(1)));
278
+ }
279
+
280
+ if (
281
+ cmd === 'logs' &&
282
+ (sub === 'follow' || sub === '--follow' || rest.includes('--follow'))
283
+ ) {
284
+ const combined = sub && sub.startsWith('--') ? [sub, ...rest] : rest;
285
+ return runLogsFollow(combined).then((code) => process.exit(code));
286
+ }
287
+
288
+ if (cmd === 'logs' && sub === 'stack') {
289
+ return runLogsGql('stack', rest).then((code) => process.exit(code));
290
+ }
291
+ if (cmd === 'logs' && sub === 'account') {
292
+ return runLogsGql('account', rest).then((code) => process.exit(code));
293
+ }
294
+
295
+ if (
296
+ cmd === 'e2e' &&
297
+ (sub === 'eu-smoke' || sub === 'eu-smoke-node' || sub === 'eu-smoke-r')
298
+ ) {
299
+ return runEuSmokeFromArgs(sub, rest).then((code) => process.exit(code));
300
+ }
301
+
302
+ if (cmd === 'insights' && sub === 'diagnose-app') {
303
+ return runDiagnoseApp(rest).then((code) => process.exit(code));
304
+ }
305
+
306
+ if (cmd === 'auth' && sub === 'aws') {
307
+ return runAuthAws(rest).then((code) => process.exit(code));
308
+ }
309
+
310
+ if (cmd === 'templates' && sub === 'matrix') {
311
+ return runTemplatesMatrix(rest).then((code) => process.exit(code));
312
+ }
313
+
314
+ printHelp();
315
+ }
316
+
317
+ async function runEuSmokeFromArgs(which, rest) {
318
+ const f = (function parseFlags(arr) {
319
+ const out = {};
320
+ for (let i = 0; i < arr.length; i++) {
321
+ const a = arr[i];
322
+ if (a.startsWith('--')) {
323
+ const key = a.slice(2);
324
+ const val =
325
+ arr[i + 1] && !arr[i + 1].startsWith('--') ? arr[++i] : 'true';
326
+ out[key] = val;
327
+ } else if (!out._) {
328
+ out._ = [a];
329
+ } else {
330
+ out._.push(a);
331
+ }
332
+ }
333
+ return out;
334
+ })(rest);
335
+
336
+ // Ensure we run under aws-vault if creds not present
337
+ if (!inVault()) {
338
+ const repl = withVault(['node', __filename, 'e2e', which, ...rest]);
339
+ return run(repl[0], repl.slice(1));
340
+ }
341
+
342
+ // Default template per variant
343
+ if (!f.template) {
344
+ if (which === 'eu-smoke-node') f.template = 'react-nest-hello';
345
+ else if (which === 'eu-smoke-r') f.template = 'shiny-r-hello';
346
+ else f.template = 'fastapi-hello';
347
+ }
348
+ return runEuSmoke(f);
349
+ }
350
+
351
+ async function runEuSmoke(f) {
352
+ const template = f.template || 'fastapi-hello';
353
+ const name = f.name || `qa-${template}-${Date.now().toString(36)}`;
354
+ const accountId = f.account;
355
+ const stackId = f.stack;
356
+ if (!accountId || !stackId) {
357
+ console.error('--account and --stack are required for e2e eu-smoke');
358
+ return 2;
359
+ }
360
+
361
+ // Load deployer env if present
362
+ await sourceLocalEnv();
363
+
364
+ const { getToken, apiPost, deriveDomain, regionToAws } =
365
+ await importHelpers();
366
+ try {
367
+ const token = await getToken();
368
+
369
+ // 1) Create app and queue template deploy
370
+ const createRes = await apiPost(token, CREATE_APP_MUTATION, {
371
+ data: {
372
+ account: { connect: { id: accountId } },
373
+ name,
374
+ minIdle: 0,
375
+ stack: { connect: { id: stackId } }
376
+ }
377
+ });
378
+ const app = createRes?.createApp;
379
+ if (!app?.id) throw new Error('createApp did not return id');
380
+
381
+ console.log(`[smoke] created app ${app.name} (${app.id})`);
382
+ await apiPost(token, DEPLOY_TEMPLATE_APP_MUTATION, {
383
+ appId: app.id,
384
+ templateId: template
385
+ });
386
+
387
+ // 2) Wait for deployment to complete
388
+ const depList = await apiPost(token, LIST_DEPLOYMENTS_QUERY, {
389
+ appId: app.id
390
+ });
391
+ let deploymentId = depList?.listDeployments?.[0]?.id;
392
+ if (!deploymentId)
393
+ throw new Error('no deployment id after queuing template');
394
+
395
+ const deadline =
396
+ Date.now() + (Number(f['timeout-minutes']) || 20) * 60 * 1000;
397
+ const pollMs = (Number(f['poll-seconds']) || 10) * 1000;
398
+ let status = '';
399
+ while (Date.now() < deadline) {
400
+ const dep = await apiPost(token, GET_DEPLOYMENT_QUERY, {
401
+ id: deploymentId
402
+ });
403
+ const d = dep?.getDeployment;
404
+ if (d && d.status !== status) {
405
+ status = d.status;
406
+ console.log(
407
+ `[smoke] deployment ${deploymentId} status=${status} step=${d.step || 'n/a'}`
408
+ );
409
+ }
410
+ if (d && ['Successful', 'Failed', 'Cancelled'].includes(d.status)) break;
411
+ await wait(pollMs);
412
+ }
413
+ const finished = await apiPost(token, GET_DEPLOYMENT_QUERY, {
414
+ id: deploymentId
415
+ });
416
+ const finalDep = finished?.getDeployment;
417
+ if (!finalDep || finalDep.status !== 'Successful') {
418
+ console.error(
419
+ '[smoke] deployment did not succeed',
420
+ JSON.stringify(finalDep || {}, null, 2)
421
+ );
422
+ return 1;
423
+ }
424
+
425
+ // 3) Resolve URL
426
+ const stackRes = await apiPost(token, GET_STACK_QUERY, {
427
+ where: { id: stackId }
428
+ });
429
+ const stack = stackRes?.getStack;
430
+ if (!stack?.name) throw new Error('could not resolve stack name');
431
+ const domain = deriveDomain();
432
+ const accountPrefix = String(accountId).split('-')[0];
433
+ const url = `https://${accountPrefix}-${stack.name}.${domain}/${name}/`;
434
+
435
+ // 4) HTTP 200 check with backoff
436
+ const tries = Number(f.tries) > 0 ? Number(f.tries) : 12;
437
+ const delayMs = Number(f['delay-ms']) > 0 ? Number(f['delay-ms']) : 5000;
438
+ const httpOk = await checkHttp200(url, { tries, delayMs });
439
+
440
+ // 5) CloudWatch logs check in EU region (or account region if available)
441
+ const regionEnum = stack?.account?.region || 'EU';
442
+ const logRegion = regionToAws(regionEnum) || 'eu-west-1';
443
+ const logs = await checkCloudwatchLogs(accountId, app.id, logRegion);
444
+
445
+ const summary = {
446
+ ok: Boolean(httpOk) && logs.found,
447
+ url,
448
+ http: httpOk,
449
+ logs,
450
+ app: { id: app.id, name: app.name },
451
+ stack: { id: stackId, name: stack.name },
452
+ accountId
453
+ };
454
+ if (f.json && String(f.json).toLowerCase() !== 'false') {
455
+ console.log(JSON.stringify(summary));
456
+ } else {
457
+ console.log('SMOKE_RESULT:', JSON.stringify(summary, null, 2));
458
+ }
459
+ return summary.ok ? 0 : 1;
460
+ } catch (err) {
461
+ console.error('[smoke] error', (err && err.stack) || err);
462
+ return 1;
463
+ }
464
+ }
465
+
466
+ async function sourceLocalEnv() {
467
+ const fs = require('fs');
468
+ const p = '.env.dev.scaly-deployer.local';
469
+ if (!fs.existsSync(p)) return;
470
+ const text = fs.readFileSync(p, 'utf8');
471
+ for (const line of text.split(/\r?\n/)) {
472
+ if (!line || line.trim().startsWith('#')) continue;
473
+ const m = line.match(/^([A-Za-z0-9_]+)=(.*)$/);
474
+ if (!m) continue;
475
+ const key = m[1];
476
+ let val = m[2];
477
+ // Strip surrounding quotes if present
478
+ if (
479
+ (val.startsWith('"') && val.endsWith('"')) ||
480
+ (val.startsWith("'") && val.endsWith("'"))
481
+ ) {
482
+ val = val.slice(1, -1);
483
+ }
484
+ if (!(key in process.env)) process.env[key] = val;
485
+ }
486
+ }
487
+
488
+ async function importHelpers() {
489
+ const {
490
+ SecretsManagerClient,
491
+ GetSecretValueCommand
492
+ } = require('@aws-sdk/client-secrets-manager');
493
+ const {
494
+ CloudWatchLogsClient,
495
+ DescribeLogStreamsCommand
496
+ } = require('@aws-sdk/client-cloudwatch-logs');
497
+ const {
498
+ ECSClient,
499
+ ListClustersCommand,
500
+ ListServicesCommand,
501
+ DescribeServicesCommand,
502
+ ListTasksCommand,
503
+ DescribeTasksCommand
504
+ } = require('@aws-sdk/client-ecs');
505
+ const {
506
+ ElasticLoadBalancingV2Client,
507
+ DescribeTargetGroupsCommand,
508
+ DescribeTargetHealthCommand
509
+ } = require('@aws-sdk/client-elastic-load-balancing-v2');
510
+ const axios = require('axios');
511
+
512
+ const API = () => process.env.API_ENDPOINT;
513
+ const deriveDomain = () => {
514
+ try {
515
+ const u = new URL(API());
516
+ return u.host.replace(/^api\./, '');
517
+ } catch {
518
+ return process.env.SCALY_DOMAIN || 'dev.scalyapps.io';
519
+ }
520
+ };
521
+ const regionToAws = (r) =>
522
+ ({
523
+ CANADA: 'ca-central-1',
524
+ US: 'us-east-1',
525
+ EU: 'eu-west-1',
526
+ ASIA_PACIFIC: 'ap-southeast-1'
527
+ })[String(r || '').toUpperCase()] || null;
528
+
529
+ async function getToken() {
530
+ // Prefer stored bearer token or env override to avoid aws-vault
531
+ const t = getStoredBearer();
532
+ if (t) return t;
533
+ if (process.env.SCALY_API_BEARER) return process.env.SCALY_API_BEARER;
534
+ if (!process.env.COGNITO_SECRET) throw new Error('COGNITO_SECRET not set');
535
+ const region = process.env.COGNITO_SECRET_REGION || 'ca-central-1';
536
+ const sm = new SecretsManagerClient({ region });
537
+ const res = await sm.send(
538
+ new GetSecretValueCommand({ SecretId: process.env.COGNITO_SECRET })
539
+ );
540
+ const sec = JSON.parse(res.SecretString || '{}');
541
+ const resp = await axios.post(
542
+ `${sec.cognitoBaseUrl}/oauth2/token`,
543
+ new URLSearchParams({
544
+ grant_type: 'client_credentials',
545
+ client_id: sec.cognitoClientId,
546
+ client_secret: sec.cognitoClientSecret
547
+ }).toString(),
548
+ { headers: { 'content-type': 'application/x-www-form-urlencoded' } }
549
+ );
550
+ return resp.data.access_token;
551
+ }
552
+
553
+ async function apiPost(token, query, variables) {
554
+ const resp = await axios.post(
555
+ API(),
556
+ { query, variables },
557
+ { headers: { authorization: `Bearer ${token}` } }
558
+ );
559
+ if (resp.data && resp.data.errors)
560
+ throw new Error(JSON.stringify(resp.data.errors));
561
+ return resp.data.data;
562
+ }
563
+
564
+ function makeCreds(c) {
565
+ return {
566
+ accessKeyId: c.accessKeyId,
567
+ secretAccessKey: c.secretAccessKey,
568
+ sessionToken: c.sessionToken
569
+ };
570
+ }
571
+
572
+ function elbClient(region, creds) {
573
+ return new ElasticLoadBalancingV2Client({ region, credentials: creds });
574
+ }
575
+ function ecsClient(region, creds) {
576
+ return new ECSClient({ region, credentials: creds });
577
+ }
578
+
579
+ async function checkHttp200(url, { tries = 10, delayMs = 3000 } = {}) {
580
+ const axios = require('axios');
581
+ for (let i = 0; i < tries; i++) {
582
+ try {
583
+ const r = await axios.get(url, {
584
+ timeout: 5000,
585
+ validateStatus: () => true
586
+ });
587
+ if (r.status === 200) return { status: r.status };
588
+ console.log(
589
+ `[smoke] HTTP ${r.status} at attempt ${i + 1}; retrying in ${Math.round(delayMs / 1000)}s`
590
+ );
591
+ } catch (e) {
592
+ console.log(`[smoke] request failed at attempt ${i + 1}; retrying…`);
593
+ }
594
+ await wait(delayMs);
595
+ }
596
+ return null;
597
+ }
598
+
599
+ async function checkCloudwatchLogs(accountId, appId, region) {
600
+ try {
601
+ const cwl = new CloudWatchLogsClient({ region });
602
+ const group = `/scaly/account/${accountId}`;
603
+ const cmd = new DescribeLogStreamsCommand({
604
+ logGroupName: group,
605
+ logStreamNamePrefix: `${appId}-`,
606
+ orderBy: 'LastEventTime',
607
+ descending: true,
608
+ limit: 5
609
+ });
610
+ const res = await cwl.send(cmd);
611
+ const streams = res.logStreams || [];
612
+ const top = streams[0];
613
+ return {
614
+ region,
615
+ group,
616
+ found: streams.length > 0,
617
+ latestStream: top ? top.logStreamName : null,
618
+ lastEventTs: top ? top.lastEventTimestamp : null
619
+ };
620
+ } catch (e) {
621
+ return {
622
+ region,
623
+ group: `/scaly/account/${accountId}`,
624
+ found: false,
625
+ error: String(e)
626
+ };
627
+ }
628
+ }
629
+
630
+ return {
631
+ getToken,
632
+ apiPost,
633
+ deriveDomain,
634
+ regionToAws,
635
+ checkHttp200,
636
+ makeCreds,
637
+ elbClient,
638
+ ecsClient
639
+ };
640
+ }
641
+
642
+ function wait(ms) {
643
+ return new Promise((r) => setTimeout(r, ms));
644
+ }
645
+
646
+ // GraphQL used by smoke
647
+ const CREATE_APP_MUTATION = `
648
+ mutation CreateApp($data: AppCreateInput!) {
649
+ createApp(data: $data) { id name account { id } stackId }
650
+ }
651
+ `;
652
+ const DEPLOY_TEMPLATE_APP_MUTATION = `
653
+ mutation DeployTemplateApp($appId: String!, $templateId: String!) {
654
+ deployTemplateApp(input: { appId: $appId, templateId: $templateId }) { appId templateId }
655
+ }
656
+ `;
657
+ const LIST_DEPLOYMENTS_QUERY = `
658
+ query AppDeployments($appId: String!) { listDeployments(where: { appId: { equals: $appId } }, orderBy: { createdAt: DESC }, take: 3) { id status step } }
659
+ `;
660
+ const GET_DEPLOYMENT_QUERY = `
661
+ query GetDeployment($id: String!) { getDeployment(where: { id: $id }) { id status step } }
662
+ `;
663
+ const GET_STACK_QUERY = `
664
+ query GetStack($where: StackWhereUniqueInput!) { getStack(where: $where) { id name account { id region } } }
665
+ `;
666
+
667
+ // --- Logs over GraphQL ---
668
+ const GET_SCALY_STACK_LOGS = `
669
+ query GetScalyStackLogs($where: StackLogsWhereInput!) {
670
+ getScalyStackLogs(where: $where) {
671
+ events { id timestamp message logStreamName }
672
+ truncated
673
+ }
674
+ }
675
+ `;
676
+ const GET_SCALY_ACCOUNT_LOGS = `
677
+ query GetScalyAccountLogs($where: StackLogsWhereInput!) {
678
+ getScalyAccountLogs(where: $where) {
679
+ events { id timestamp message logStreamName }
680
+ truncated
681
+ }
682
+ }
683
+ `;
684
+
685
+ // --- STS Broker ---
686
+ const ISSUE_AWS_CREDENTIALS_MUTATION = `
687
+ mutation IssueAwsCredentials($input: IssueAwsCredentialsInput!) {
688
+ issueAwsCredentials(input: $input) {
689
+ accessKeyId
690
+ secretAccessKey
691
+ sessionToken
692
+ expiration
693
+ region
694
+ assumedRoleArn
695
+ requestId
696
+ }
697
+ }
698
+ `;
699
+
700
+ // --- DB local access ---
701
+ const CREATE_DATABASE_PROXY_TOKEN_MUTATION = `
702
+ mutation CreateDatabaseProxyToken($addOnId: String!, $ttlMinutes: Int) {
703
+ createDatabaseProxyToken(where: { id: $addOnId }, ttlMinutes: $ttlMinutes) {
704
+ token
705
+ expiresAt
706
+ host
707
+ port
708
+ database
709
+ username
710
+ ttlMinutes
711
+ }
712
+ }
713
+ `;
714
+
715
+ // --- Apps update/delete helpers ---
716
+ const UPDATE_APP_MUTATION = `
717
+ mutation UpdateApp($where: AppWhereUniqueInput!, $data: AppUpdateInput) {
718
+ updateApp(where: $where, data: $data) { id name minIdle stackId }
719
+ }
720
+ `;
721
+ const DELETE_APP_MUTATION = `
722
+ mutation DeleteApp($where: AppWhereUniqueInput!) {
723
+ deleteApp(where: $where) { id name }
724
+ }
725
+ `;
726
+ const LIST_APPS_QUERY = `
727
+ query ListApps($where: AppWhereInput) {
728
+ listApps(where: $where) { id name accountId }
729
+ }
730
+ `;
731
+
732
+ // --- Stacks update/delete helpers ---
733
+ const UPDATE_STACK_MUTATION = `
734
+ mutation UpdateStack($where: StackWhereUniqueInput!, $data: StackUpdateInput) {
735
+ updateStack(where: $where, data: $data) { id name minIdle size }
736
+ }
737
+ `;
738
+ const DELETE_STACK_MUTATION = `
739
+ mutation DeleteStack($where: StackWhereUniqueInput!) {
740
+ deleteStack(where: $where) { id name }
741
+ }
742
+ `;
743
+ const LIST_STACKS_QUERY = `
744
+ query ListStacks($where: StackWhereInput) {
745
+ listStacks(where: $where) { id name accountId }
746
+ }
747
+ `;
748
+
749
+ async function runAppsUpdate(rest) {
750
+ if (!inVault()) {
751
+ const repl = withVault(['node', __filename, 'apps', 'update', ...rest]);
752
+ return run(repl[0], repl.slice(1));
753
+ }
754
+ const f = parseKv(rest);
755
+ if (!f.id && !(f.name && f.account)) {
756
+ console.error('apps update requires --id OR (--name and --account)');
757
+ return 2;
758
+ }
759
+ await sourceLocalEnv();
760
+ const { getToken, apiPost } = await importHelpers();
761
+ const token = await getToken();
762
+ let id = f.id;
763
+ if (!id) {
764
+ // resolve by account + name
765
+ const res = await apiPost(token, LIST_APPS_QUERY, {
766
+ where: { accountId: { equals: f.account }, name: { equals: f.name } }
767
+ });
768
+ const apps = (res && res.listApps) || [];
769
+ if (apps.length !== 1) {
770
+ console.error(
771
+ `[apps update] expected exactly 1 match, found ${apps.length}`
772
+ );
773
+ return 1;
774
+ }
775
+ id = apps[0].id;
776
+ }
777
+ const data = {};
778
+ if (f['min-idle']) data.minIdle = Number(f['min-idle']);
779
+ if (f.name && f.id) data.name = f.name; // allow rename only with explicit id
780
+ if (Object.keys(data).length === 0) {
781
+ console.error('nothing to update; pass --min-idle or --name');
782
+ return 2;
783
+ }
784
+ const out = await apiPost(token, UPDATE_APP_MUTATION, {
785
+ where: { id },
786
+ data
787
+ });
788
+ console.log('apps.update:', JSON.stringify(out, null, 2));
789
+ return 0;
790
+ }
791
+
792
+ async function runAppsDelete(rest) {
793
+ if (!inVault()) {
794
+ const repl = withVault(['node', __filename, 'apps', 'delete', ...rest]);
795
+ return run(repl[0], repl.slice(1));
796
+ }
797
+ const f = parseKv(rest);
798
+ if (!f.id && !(f.name && f.account)) {
799
+ console.error('apps delete requires --id OR (--name and --account)');
800
+ return 2;
801
+ }
802
+ await sourceLocalEnv();
803
+ const { getToken, apiPost } = await importHelpers();
804
+ const token = await getToken();
805
+ let id = f.id;
806
+ if (!id) {
807
+ const res = await apiPost(token, LIST_APPS_QUERY, {
808
+ where: { accountId: { equals: f.account }, name: { equals: f.name } }
809
+ });
810
+ const apps = (res && res.listApps) || [];
811
+ if (apps.length !== 1) {
812
+ console.error(
813
+ `[apps delete] expected exactly 1 match, found ${apps.length}`
814
+ );
815
+ return 1;
816
+ }
817
+ id = apps[0].id;
818
+ }
819
+ const out = await apiPost(token, DELETE_APP_MUTATION, { where: { id } });
820
+ console.log('apps.delete:', JSON.stringify(out, null, 2));
821
+ return 0;
822
+ }
823
+
824
+ async function runAppsDeleteByPrefix(rest) {
825
+ if (!inVault()) {
826
+ const repl = withVault([
827
+ 'node',
828
+ __filename,
829
+ 'apps',
830
+ 'delete-by-prefix',
831
+ ...rest
832
+ ]);
833
+ return run(repl[0], repl.slice(1));
834
+ }
835
+ const f = parseKv(rest);
836
+ if (!f.account || !f.prefix) {
837
+ console.error('apps delete-by-prefix requires --account and --prefix');
838
+ return 2;
839
+ }
840
+ await sourceLocalEnv();
841
+ const { getToken, apiPost } = await importHelpers();
842
+ const token = await getToken();
843
+ const res = await apiPost(token, LIST_APPS_QUERY, {
844
+ where: { accountId: { equals: f.account }, name: { startsWith: f.prefix } }
845
+ });
846
+ const apps = (res && res.listApps) || [];
847
+ if (apps.length === 0) {
848
+ console.log('apps.delete-by-prefix: no matches');
849
+ return 0;
850
+ }
851
+ console.log(`[apps delete-by-prefix] deleting ${apps.length} apps…`);
852
+ for (const a of apps) {
853
+ await apiPost(token, DELETE_APP_MUTATION, { where: { id: a.id } });
854
+ console.log(` deleted ${a.name} (${a.id})`);
855
+ }
856
+ return 0;
857
+ }
858
+
859
+ async function runStacksUpdate(rest) {
860
+ if (!inVault()) {
861
+ const repl = withVault(['node', __filename, 'stacks', 'update', ...rest]);
862
+ return run(repl[0], repl.slice(1));
863
+ }
864
+ const f = parseKv(rest);
865
+ if (!f.id && !(f.name && f.account)) {
866
+ console.error('stacks update requires --id OR (--name and --account)');
867
+ return 2;
868
+ }
869
+ await sourceLocalEnv();
870
+ const { getToken, apiPost } = await importHelpers();
871
+ const token = await getToken();
872
+ let id = f.id;
873
+ if (!id) {
874
+ const res = await apiPost(token, LIST_STACKS_QUERY, {
875
+ where: { accountId: { equals: f.account }, name: { equals: f.name } }
876
+ });
877
+ const stacks = (res && res.listStacks) || [];
878
+ if (stacks.length !== 1) {
879
+ console.error(
880
+ `[stacks update] expected exactly 1 match, found ${stacks.length}`
881
+ );
882
+ return 1;
883
+ }
884
+ id = stacks[0].id;
885
+ }
886
+ const data = {};
887
+ if (f['min-idle']) data.minIdle = Number(f['min-idle']);
888
+ if (f.size) data.size = f.size;
889
+ if (f.name && f.id) data.name = f.name;
890
+ if (Object.keys(data).length === 0) {
891
+ console.error('nothing to update; pass --min-idle, --size or --name');
892
+ return 2;
893
+ }
894
+ const out = await apiPost(token, UPDATE_STACK_MUTATION, {
895
+ where: { id },
896
+ data
897
+ });
898
+ console.log('stacks.update:', JSON.stringify(out, null, 2));
899
+ return 0;
900
+ }
901
+
902
+ async function runStacksDelete(rest) {
903
+ if (!inVault()) {
904
+ const repl = withVault(['node', __filename, 'stacks', 'delete', ...rest]);
905
+ return run(repl[0], repl.slice(1));
906
+ }
907
+ const f = parseKv(rest);
908
+ if (!f.id && !(f.name && f.account)) {
909
+ console.error('stacks delete requires --id OR (--name and --account)');
910
+ return 2;
911
+ }
912
+ await sourceLocalEnv();
913
+ const { getToken, apiPost } = await importHelpers();
914
+ const token = await getToken();
915
+ let id = f.id;
916
+ if (!id) {
917
+ const res = await apiPost(token, LIST_STACKS_QUERY, {
918
+ where: { accountId: { equals: f.account }, name: { equals: f.name } }
919
+ });
920
+ const stacks = (res && res.listStacks) || [];
921
+ if (stacks.length !== 1) {
922
+ console.error(
923
+ `[stacks delete] expected exactly 1 match, found ${stacks.length}`
924
+ );
925
+ return 1;
926
+ }
927
+ id = stacks[0].id;
928
+ }
929
+ const out = await apiPost(token, DELETE_STACK_MUTATION, { where: { id } });
930
+ console.log('stacks.delete:', JSON.stringify(out, null, 2));
931
+ return 0;
932
+ }
933
+
934
+ async function runStacksDeleteByPrefix(rest) {
935
+ if (!inVault()) {
936
+ const repl = withVault([
937
+ 'node',
938
+ __filename,
939
+ 'stacks',
940
+ 'delete-by-prefix',
941
+ ...rest
942
+ ]);
943
+ return run(repl[0], repl.slice(1));
944
+ }
945
+ const f = parseKv(rest);
946
+ if (!f.account || !f.prefix) {
947
+ console.error('stacks delete-by-prefix requires --account and --prefix');
948
+ return 2;
949
+ }
950
+ await sourceLocalEnv();
951
+ const { getToken, apiPost } = await importHelpers();
952
+ const token = await getToken();
953
+ const res = await apiPost(token, LIST_STACKS_QUERY, {
954
+ where: { accountId: { equals: f.account }, name: { startsWith: f.prefix } }
955
+ });
956
+ const stacks = (res && res.listStacks) || [];
957
+ if (stacks.length === 0) {
958
+ console.log('stacks.delete-by-prefix: no matches');
959
+ return 0;
960
+ }
961
+ console.log(`[stacks delete-by-prefix] deleting ${stacks.length} stacks…`);
962
+ for (const s of stacks) {
963
+ await apiPost(token, DELETE_STACK_MUTATION, { where: { id: s.id } });
964
+ console.log(` deleted ${s.name} (${s.id})`);
965
+ }
966
+ return 0;
967
+ }
968
+
969
+ async function runCleanupPurge(rest) {
970
+ if (!inVault()) {
971
+ const repl = withVault(['node', __filename, 'cleanup', 'purge', ...rest]);
972
+ return run(repl[0], repl.slice(1));
973
+ }
974
+ const f = parseKv(rest);
975
+ const account = f.account;
976
+ if (!account) {
977
+ console.error('cleanup purge requires --account <id>');
978
+ return 2;
979
+ }
980
+ const prefixesArg = f.prefixes || f.prefix || '';
981
+ const prefixes = String(prefixesArg)
982
+ .split(',')
983
+ .map((s) => s.trim())
984
+ .filter(Boolean);
985
+ const dry = !!(
986
+ f['dry-run'] && String(f['dry-run']).toLowerCase() !== 'false'
987
+ );
988
+ const scaleAll = !!(
989
+ f['scale-all-stacks'] &&
990
+ String(f['scale-all-stacks']).toLowerCase() !== 'false'
991
+ );
992
+ const stackId = f.stack;
993
+ const stackName = f['stack-name'];
994
+
995
+ await sourceLocalEnv();
996
+ const { getToken, apiPost } = await importHelpers();
997
+ const token = await getToken();
998
+
999
+ // Collect apps by prefix
1000
+ let appsToDelete = [];
1001
+ if (prefixes.length > 0) {
1002
+ const where = { accountId: { equals: account } };
1003
+ if (prefixes.length === 1) where.name = { startsWith: prefixes[0] };
1004
+ else where.OR = prefixes.map((p) => ({ name: { startsWith: p } }));
1005
+ const res = await apiPost(token, LIST_APPS_QUERY, { where });
1006
+ appsToDelete = (res && res.listApps) || [];
1007
+ }
1008
+
1009
+ // Collect stacks to scale
1010
+ let stacksToScale = [];
1011
+ if (scaleAll) {
1012
+ const res = await apiPost(token, LIST_STACKS_QUERY, {
1013
+ where: { accountId: { equals: account } }
1014
+ });
1015
+ stacksToScale = (res && res.listStacks) || [];
1016
+ } else if (stackId || stackName) {
1017
+ if (stackId) stacksToScale = [{ id: stackId }];
1018
+ else {
1019
+ const res = await apiPost(token, LIST_STACKS_QUERY, {
1020
+ where: { accountId: { equals: account }, name: { equals: stackName } }
1021
+ });
1022
+ const stacks = (res && res.listStacks) || [];
1023
+ if (stacks.length !== 1) {
1024
+ console.error(
1025
+ `[cleanup purge] expected exactly 1 stack named ${stackName}, found ${stacks.length}`
1026
+ );
1027
+ return 1;
1028
+ }
1029
+ stacksToScale = [stacks[0]];
1030
+ }
1031
+ }
1032
+
1033
+ console.log(`[cleanup purge] account=${account}`);
1034
+ if (prefixes.length) console.log(` app prefixes: ${prefixes.join(', ')}`);
1035
+ console.log(` apps matched: ${appsToDelete.length}`);
1036
+ console.log(` stacks to scale: ${stacksToScale.length}`);
1037
+
1038
+ if (dry) {
1039
+ for (const a of appsToDelete)
1040
+ console.log(` [dry-run] would delete app ${a.name} (${a.id})`);
1041
+ for (const s of stacksToScale)
1042
+ console.log(` [dry-run] would scale stack ${s.id} to minIdle=0`);
1043
+ return 0;
1044
+ }
1045
+
1046
+ for (const a of appsToDelete) {
1047
+ await apiPost(token, DELETE_APP_MUTATION, { where: { id: a.id } });
1048
+ console.log(` deleted app ${a.name} (${a.id})`);
1049
+ }
1050
+
1051
+ for (const s of stacksToScale) {
1052
+ await apiPost(token, UPDATE_STACK_MUTATION, {
1053
+ where: { id: s.id },
1054
+ data: { minIdle: 0 }
1055
+ });
1056
+ console.log(` scaled stack ${s.id} to minIdle=0`);
1057
+ }
1058
+
1059
+ console.log(
1060
+ `[cleanup purge] done. deleted=${appsToDelete.length}, scaled=${stacksToScale.length}`
1061
+ );
1062
+ return 0;
1063
+ }
1064
+
1065
+ function parseKv(arr) {
1066
+ const out = {};
1067
+ for (let i = 0; i < arr.length; i++) {
1068
+ const a = arr[i];
1069
+ if (a.startsWith('--')) {
1070
+ const key = a.slice(2);
1071
+ const val =
1072
+ arr[i + 1] && !arr[i + 1].startsWith('--') ? arr[++i] : 'true';
1073
+ out[key] = val;
1074
+ } else if (!out._) {
1075
+ out._ = [a];
1076
+ } else {
1077
+ out._.push(a);
1078
+ }
1079
+ }
1080
+ return out;
1081
+ }
1082
+
1083
+ // -------------------------
1084
+ // Templates matrix runner
1085
+ // -------------------------
1086
+ async function runTemplatesMatrix(rest) {
1087
+ const f = parseKv(rest);
1088
+ await sourceLocalEnv();
1089
+ const { getToken, apiPost, deriveDomain, regionToAws, checkHttp200 } =
1090
+ await importHelpers();
1091
+
1092
+ const token = await getToken();
1093
+ const accountId = f.account || process.env.SCALY_ACCOUNT_ID;
1094
+ const stackId = f.stack || process.env.SCALY_STACK_ID;
1095
+ if (!accountId || !stackId) {
1096
+ console.error(
1097
+ 'templates matrix requires --account <id> and --stack <id> (or set SCALY_ACCOUNT_ID/SCALY_STACK_ID)'
1098
+ );
1099
+ return 2;
1100
+ }
1101
+
1102
+ const tplArg = f.templates || f.template || 'all';
1103
+ const ALL_TEMPLATES = [
1104
+ 'fastapi-hello',
1105
+ 'flask-hello',
1106
+ 'streamlit-hello',
1107
+ 'dash-hello',
1108
+ 'gradio-hello',
1109
+ 'shiny-r-hello',
1110
+ 'shiny-python-hello',
1111
+ 'react-nest-hello',
1112
+ 'mkdocs-hello',
1113
+ 'docusaurus-hello'
1114
+ ];
1115
+ const templates =
1116
+ tplArg === 'all'
1117
+ ? ALL_TEMPLATES
1118
+ : String(tplArg)
1119
+ .split(',')
1120
+ .map((s) => s.trim())
1121
+ .filter(Boolean);
1122
+ const keep = !!(f.keep && String(f.keep).toLowerCase() !== 'false');
1123
+ const autoFix = !!(
1124
+ f['auto-fix'] && String(f['auto-fix']).toLowerCase() !== 'false'
1125
+ );
1126
+ const tries = Number(f.tries) || 8;
1127
+ const delayMs = Number(f['delay-ms']) || 5000;
1128
+
1129
+ const stackRes = await apiPost(token, GET_STACK_QUERY, {
1130
+ where: { id: stackId }
1131
+ });
1132
+ const stack = stackRes?.getStack;
1133
+ if (!stack?.name) {
1134
+ console.error('Could not resolve stack by id');
1135
+ return 1;
1136
+ }
1137
+ const domain = deriveDomain();
1138
+ const accountPrefix = String(accountId).split('-')[0];
1139
+
1140
+ const results = [];
1141
+ for (const templateId of templates) {
1142
+ const name = `${templateId.replace(/[^a-z0-9-]/gi, '-')}-${Date.now().toString(36).slice(-5)}`;
1143
+ const url = `https://${accountPrefix}-${stack.name}.${domain}/${name}/`;
1144
+ const startedAt = new Date().toISOString();
1145
+ let appId = null;
1146
+ let deploymentId = null;
1147
+ let status = 'Unknown';
1148
+ let http = null;
1149
+ let attempts = 0;
1150
+ let notes = [];
1151
+ let autoFixApplied = false;
1152
+
1153
+ try {
1154
+ // Create app
1155
+ const createRes = await apiPost(token, CREATE_APP_MUTATION, {
1156
+ data: {
1157
+ account: { connect: { id: accountId } },
1158
+ name,
1159
+ minIdle: 0,
1160
+ stack: { connect: { id: stackId } }
1161
+ }
1162
+ });
1163
+ appId = createRes?.createApp?.id;
1164
+ if (!appId) throw new Error('createApp returned no id');
1165
+
1166
+ // Deploy template
1167
+ await apiPost(token, DEPLOY_TEMPLATE_APP_MUTATION, { appId, templateId });
1168
+ const dl = await apiPost(token, LIST_DEPLOYMENTS_QUERY, { appId });
1169
+ deploymentId = dl?.listDeployments?.[0]?.id;
1170
+
1171
+ // Wait terminal
1172
+ const deadline =
1173
+ Date.now() + (Number(f['timeout-minutes']) || 20) * 60 * 1000;
1174
+ let last = '';
1175
+ while (Date.now() < deadline) {
1176
+ const d = await apiPost(token, GET_DEPLOYMENT_QUERY, {
1177
+ id: deploymentId
1178
+ });
1179
+ const dep = d?.getDeployment;
1180
+ if (dep && dep.status !== last) {
1181
+ last = dep.status;
1182
+ }
1183
+ if (dep && ['Successful', 'Failed', 'Cancelled'].includes(dep.status)) {
1184
+ status = dep.status;
1185
+ break;
1186
+ }
1187
+ await wait(
1188
+ Number(f['poll-seconds']) ? Number(f['poll-seconds']) * 1000 : 10000
1189
+ );
1190
+ }
1191
+
1192
+ // HTTP probe
1193
+ attempts++;
1194
+ http = await checkHttp200(url, { tries, delayMs });
1195
+ if (!http && autoFix) {
1196
+ // Try a one-time redeploy of the template
1197
+ autoFixApplied = true;
1198
+ notes.push('Auto-fix: redeploy template once');
1199
+ await apiPost(token, DEPLOY_TEMPLATE_APP_MUTATION, {
1200
+ appId,
1201
+ templateId
1202
+ });
1203
+ await wait(10000);
1204
+ attempts++;
1205
+ http = await checkHttp200(url, { tries, delayMs });
1206
+ }
1207
+ } catch (e) {
1208
+ notes.push(String((e && e.message) || e));
1209
+ } finally {
1210
+ const finishedAt = new Date().toISOString();
1211
+ results.push({
1212
+ templateId,
1213
+ appId,
1214
+ deploymentId,
1215
+ name,
1216
+ url,
1217
+ status,
1218
+ http,
1219
+ attempts,
1220
+ autoFixApplied,
1221
+ notes,
1222
+ startedAt,
1223
+ finishedAt
1224
+ });
1225
+ if (!keep && appId) {
1226
+ try {
1227
+ await apiPost(token, DELETE_APP_MUTATION, { where: { id: appId } });
1228
+ } catch {}
1229
+ }
1230
+ }
1231
+ }
1232
+
1233
+ const summary = {
1234
+ accountId,
1235
+ stackId,
1236
+ stackName: stack.name,
1237
+ domain,
1238
+ total: results.length,
1239
+ passed: results.filter((r) => r.http && r.http.status === 200).length,
1240
+ failed: results.filter((r) => !(r.http && r.http.status === 200)).length,
1241
+ results
1242
+ };
1243
+
1244
+ if (f.json && String(f.json).toLowerCase() !== 'false') {
1245
+ console.log(JSON.stringify(summary));
1246
+ } else {
1247
+ console.log('MATRIX_RESULT:', JSON.stringify(summary, null, 2));
1248
+ }
1249
+ return summary.failed ? 1 : 0;
1250
+ }
1251
+
1252
+ // -------------------------
1253
+ // Login (store/clear bearer)
1254
+ // -------------------------
1255
+ async function runLogin(sub, rest) {
1256
+ const f = parseKv(rest);
1257
+ if (f.clear) {
1258
+ clearStoredBearer();
1259
+ console.log('Cleared stored bearer token');
1260
+ return 0;
1261
+ }
1262
+ const token = f.token || (f._ && f._[0]);
1263
+ const endpoint = f.endpoint || process.env.API_ENDPOINT;
1264
+ if (!token) {
1265
+ console.error('login requires --token <api-bearer>');
1266
+ return 2;
1267
+ }
1268
+ if (!endpoint) {
1269
+ console.error(
1270
+ 'login requires --endpoint <https://api...> (or set API_ENDPOINT)'
1271
+ );
1272
+ return 2;
1273
+ }
1274
+ saveStoredBearer({ token, endpoint });
1275
+ console.log(`Saved API bearer for ${endpoint}`);
1276
+ return 0;
1277
+ }
1278
+
1279
+ function getConfigPath() {
1280
+ const os = require('os');
1281
+ const fs = require('fs');
1282
+ const path = require('path');
1283
+ const base =
1284
+ process.env.SCALY_CONFIG_DIR ||
1285
+ process.env.XDG_CONFIG_HOME ||
1286
+ path.join(os.homedir(), '.config');
1287
+ const dir = path.join(base, 'scaly');
1288
+ const file = path.join(dir, 'credentials.json');
1289
+ return { dir, file, fs, path };
1290
+ }
1291
+
1292
+ function saveStoredBearer({ token, endpoint }) {
1293
+ const { dir, file, fs } = getConfigPath();
1294
+ try {
1295
+ fs.mkdirSync(dir, { recursive: true });
1296
+ } catch {}
1297
+ const data = { token, endpoint, savedAt: new Date().toISOString() };
1298
+ fs.writeFileSync(file, JSON.stringify(data, null, 2));
1299
+ }
1300
+
1301
+ function clearStoredBearer() {
1302
+ const { file, fs } = getConfigPath();
1303
+ try {
1304
+ fs.unlinkSync(file);
1305
+ } catch {}
1306
+ }
1307
+
1308
+ function loadStoredBearer() {
1309
+ const { file, fs } = getConfigPath();
1310
+ try {
1311
+ const text = fs.readFileSync(file, 'utf8');
1312
+ const obj = JSON.parse(text);
1313
+ return obj && obj.token && obj.endpoint ? obj : null;
1314
+ } catch {
1315
+ return null;
1316
+ }
1317
+ }
1318
+
1319
+ function getStoredBearer() {
1320
+ const obj = loadStoredBearer();
1321
+ if (obj) process.env.API_ENDPOINT = process.env.API_ENDPOINT || obj.endpoint;
1322
+ return obj ? obj.token : null;
1323
+ }
1324
+
1325
+ function parseBool(v, defaultValue = false) {
1326
+ if (v === undefined || v === null) return defaultValue;
1327
+ const s = String(v).trim().toLowerCase();
1328
+ if (s === '') return defaultValue;
1329
+ if (['1', 'true', 'yes', 'y', 'on'].includes(s)) return true;
1330
+ if (['0', 'false', 'no', 'n', 'off'].includes(s)) return false;
1331
+ return defaultValue;
1332
+ }
1333
+
1334
+ function promptYesNo(question) {
1335
+ const readline = require('readline');
1336
+ const rl = readline.createInterface({
1337
+ input: process.stdin,
1338
+ output: process.stdout
1339
+ });
1340
+ return new Promise((resolve) => {
1341
+ rl.question(question, (answer) => {
1342
+ rl.close();
1343
+ resolve(/^y(es)?$/i.test(String(answer || '').trim()));
1344
+ });
1345
+ });
1346
+ }
1347
+
1348
+ function promptHidden(question) {
1349
+ const readline = require('readline');
1350
+ const rl = readline.createInterface({
1351
+ input: process.stdin,
1352
+ output: process.stdout,
1353
+ terminal: true
1354
+ });
1355
+ // Mask input with '*' while typing.
1356
+ // eslint-disable-next-line no-underscore-dangle
1357
+ rl._writeToOutput = function _writeToOutput(stringToWrite) {
1358
+ if (rl.stdoutMuted) rl.output.write('*');
1359
+ else rl.output.write(stringToWrite);
1360
+ };
1361
+ rl.stdoutMuted = true;
1362
+ return new Promise((resolve) => {
1363
+ rl.question(question, (value) => {
1364
+ rl.stdoutMuted = false;
1365
+ rl.output.write('\n');
1366
+ rl.close();
1367
+ resolve(String(value || ''));
1368
+ });
1369
+ });
1370
+ }
1371
+
1372
+ function readStdinAll() {
1373
+ return new Promise((resolve, reject) => {
1374
+ let data = '';
1375
+ process.stdin.setEncoding('utf8');
1376
+ process.stdin.on('data', (chunk) => (data += chunk));
1377
+ process.stdin.on('end', () => resolve(data));
1378
+ process.stdin.on('error', reject);
1379
+ });
1380
+ }
1381
+
1382
+ function copyToClipboard(text) {
1383
+ const { spawnSync } = require('child_process');
1384
+ const plat = process.platform;
1385
+
1386
+ const tryCmd = (cmd, args = []) => {
1387
+ const res = spawnSync(cmd, args, {
1388
+ input: text,
1389
+ stdio: ['pipe', 'ignore', 'ignore'],
1390
+ shell: false
1391
+ });
1392
+ return res && res.status === 0;
1393
+ };
1394
+
1395
+ if (plat === 'darwin') {
1396
+ if (tryCmd('pbcopy')) return true;
1397
+ } else if (plat === 'win32') {
1398
+ if (tryCmd('clip')) return true;
1399
+ } else {
1400
+ if (tryCmd('wl-copy')) return true;
1401
+ if (tryCmd('xclip', ['-selection', 'clipboard'])) return true;
1402
+ if (tryCmd('xsel', ['--clipboard', '--input'])) return true;
1403
+ }
1404
+
1405
+ return false;
1406
+ }
1407
+
1408
+ // -------------------------
1409
+ // GraphQL Logs
1410
+ // -------------------------
1411
+ async function runLogsGql(kind, rest) {
1412
+ const f = parseKv(rest);
1413
+ const id = f.id || (f._ && f._[0]);
1414
+ if (!id) {
1415
+ console.error(
1416
+ `logs ${kind} requires --id <${kind === 'stack' ? 'stackId' : 'accountId'}>`
1417
+ );
1418
+ return 2;
1419
+ }
1420
+ const since = f.since || '30m';
1421
+ const limit = Number(f.limit) > 0 ? Number(f.limit) : 300;
1422
+ await sourceLocalEnv();
1423
+ const { getToken, apiPost } = await importHelpers();
1424
+ const token = await getToken();
1425
+ const startTime = sinceToIso(since);
1426
+ const where = { id, startTime };
1427
+ const query =
1428
+ kind === 'stack' ? GET_SCALY_STACK_LOGS : GET_SCALY_ACCOUNT_LOGS;
1429
+ const res = await apiPost(token, query, { where });
1430
+ const data =
1431
+ kind === 'stack' ? res.getScalyStackLogs : res.getScalyAccountLogs;
1432
+ const events = data && data.events ? data.events.slice(-limit) : [];
1433
+ if (f.json && String(f.json).toLowerCase() !== 'false') {
1434
+ console.log(
1435
+ JSON.stringify({
1436
+ id,
1437
+ kind,
1438
+ count: events.length,
1439
+ truncated: !!data?.truncated,
1440
+ events
1441
+ })
1442
+ );
1443
+ return 0;
1444
+ }
1445
+ for (const e of events) {
1446
+ console.log(`${e.timestamp} ${e.logStreamName || ''} ${e.message}`);
1447
+ }
1448
+ if (data?.truncated) console.log('[truncated]');
1449
+ return 0;
1450
+ }
1451
+
1452
+ function sinceToIso(s) {
1453
+ const now = Date.now();
1454
+ const m = String(s).match(/^(\d+)([smhd])$/i);
1455
+ let ms = 30 * 60 * 1000; // default 30m
1456
+ if (m) {
1457
+ const n = Number(m[1]);
1458
+ const u = m[2].toLowerCase();
1459
+ if (u === 's') ms = n * 1000;
1460
+ if (u === 'm') ms = n * 60 * 1000;
1461
+ if (u === 'h') ms = n * 60 * 60 * 1000;
1462
+ if (u === 'd') ms = n * 24 * 60 * 60 * 1000;
1463
+ }
1464
+ return new Date(now - ms).toISOString();
1465
+ }
1466
+
1467
+ // -------------------------
1468
+ // .scaly project config (Phase 3)
1469
+ // -------------------------
1470
+ async function runProjectInit(rest) {
1471
+ const fs = require('fs');
1472
+ const path = require('path');
1473
+ const { writeYamlFile } = require('../lib/scaly-project');
1474
+
1475
+ const f = parseKv(rest);
1476
+ const force = String(f.force || '').toLowerCase() === 'true';
1477
+
1478
+ const root = process.cwd();
1479
+ const project = path.basename(root).replace(/[^a-zA-Z0-9-_]/g, '') || 'app';
1480
+ const scalyDir = path.join(root, '.scaly');
1481
+ const configPath = path.join(scalyDir, 'config.yaml');
1482
+
1483
+ if (fs.existsSync(configPath) && !force) {
1484
+ console.error(
1485
+ `Refusing to overwrite existing ${configPath}. Re-run with --force to replace.`
1486
+ );
1487
+ return 2;
1488
+ }
1489
+
1490
+ const baseConfig = {
1491
+ version: '1',
1492
+ account: { slug: 'mycompany' },
1493
+ stack: {
1494
+ name: `${project}-stack`,
1495
+ size: 'Eco',
1496
+ region: 'CANADA',
1497
+ minIdle: 0
1498
+ },
1499
+ app: {
1500
+ name: project,
1501
+ framework: 'fastapi',
1502
+ env: { LOG_LEVEL: 'info' },
1503
+ secrets: []
1504
+ },
1505
+ addons: [],
1506
+ domains: [],
1507
+ jobs: []
1508
+ };
1509
+
1510
+ writeYamlFile(configPath, baseConfig);
1511
+
1512
+ const schemaPath = path.join(scalyDir, 'schema.sql');
1513
+ fs.writeFileSync(schemaPath, '', 'utf8');
1514
+
1515
+ const migrationsDir = path.join(scalyDir, 'migrations');
1516
+ fs.mkdirSync(migrationsDir, { recursive: true });
1517
+ const keep = path.join(migrationsDir, '.gitkeep');
1518
+ fs.writeFileSync(keep, '', 'utf8');
1519
+
1520
+ console.log(`Created ${configPath}`);
1521
+ console.log(`Created ${schemaPath} (empty)`);
1522
+ console.log(`Created ${migrationsDir}/`);
1523
+ return 0;
1524
+ }
1525
+
1526
+ async function runProjectPlan(rest) {
1527
+ const f = parseKv(rest);
1528
+ const env = f.env || null;
1529
+ const appName = f.app || null;
1530
+ const json = String(f.json || '').toLowerCase() === 'true';
1531
+
1532
+ // Allow existing `scaly login` flow to set API_ENDPOINT.
1533
+ try {
1534
+ const t = getStoredBearer();
1535
+ if (t && !process.env.SCALY_API_BEARER) process.env.SCALY_API_BEARER = t;
1536
+ } catch {}
1537
+
1538
+ const {
1539
+ loadScalyConfig,
1540
+ computeConfigHash,
1541
+ readLastApply
1542
+ } = require('../lib/scaly-project');
1543
+ const { buildPlan } = require('../lib/scaly-plan');
1544
+
1545
+ let loaded;
1546
+ try {
1547
+ loaded = loadScalyConfig({ cwd: process.cwd(), env });
1548
+ } catch (err) {
1549
+ console.error(String(err && err.message ? err.message : err));
1550
+ return 2;
1551
+ }
1552
+
1553
+ if (!loaded.validation.ok) {
1554
+ console.error('Invalid .scaly/config.yaml:');
1555
+ for (const e of loaded.validation.errors) console.error(`- ${e}`);
1556
+ return 2;
1557
+ }
1558
+
1559
+ const plan = await buildPlan({ config: loaded.config, env, appName });
1560
+ const config_hash = computeConfigHash({
1561
+ config: loaded.config,
1562
+ env: plan.env,
1563
+ appName
1564
+ });
1565
+ const lastApply = readLastApply(loaded.root);
1566
+ const drift =
1567
+ lastApply && lastApply.config_hash === config_hash
1568
+ ? (plan.operations || [])
1569
+ .filter((o) => o.action === 'create' || o.action === 'update')
1570
+ .flatMap((op) => {
1571
+ const diffs = Array.isArray(op.diff) ? op.diff : [];
1572
+ return diffs.map((d) => ({
1573
+ resource: op.kind,
1574
+ id: op.resource?.id || null,
1575
+ name: op.resource?.name || null,
1576
+ field: d.field,
1577
+ live: d.current ?? null,
1578
+ config: d.desired ?? null
1579
+ }));
1580
+ })
1581
+ : plan.drift || [];
1582
+
1583
+ const out = {
1584
+ ok: true,
1585
+ project_root: loaded.root,
1586
+ config_path: loaded.basePath,
1587
+ overlay_path: loaded.overlayPath,
1588
+ plan_hash: plan.plan_hash,
1589
+ env: plan.env,
1590
+ summary: plan.summary,
1591
+ drift,
1592
+ warnings: plan.warnings,
1593
+ operations: plan.operations
1594
+ };
1595
+
1596
+ if (json) {
1597
+ console.log(JSON.stringify(out));
1598
+ return 0;
1599
+ }
1600
+
1601
+ console.log(
1602
+ `Plan: create=${plan.summary.create} update=${plan.summary.update} noop=${plan.summary.noop}`
1603
+ );
1604
+ for (const op of plan.operations) {
1605
+ if (op.action === 'noop') continue;
1606
+ const name = op.resource?.name || op.resource?.id || op.id;
1607
+ console.log(`- ${op.action.toUpperCase()} ${op.kind} ${name}`);
1608
+ for (const d of op.diff || []) {
1609
+ console.log(
1610
+ ` - ${d.field}: ${JSON.stringify(d.current)} → ${JSON.stringify(d.desired)}`
1611
+ );
1612
+ }
1613
+ }
1614
+ for (const w of plan.warnings || []) console.log(`[warn] ${w}`);
1615
+ console.log('');
1616
+ console.log(`plan_hash: ${plan.plan_hash}`);
1617
+ console.log(
1618
+ `Run: scaly apply --plan-hash ${plan.plan_hash}${env ? ` --env ${env}` : ''}${appName ? ` --app ${appName}` : ''}`
1619
+ );
1620
+ return 0;
1621
+ }
1622
+
1623
+ async function runProjectApply(rest) {
1624
+ const readline = require('readline');
1625
+
1626
+ const f = parseKv(rest);
1627
+ const env = f.env || null;
1628
+ const appName = f.app || null;
1629
+ const json = String(f.json || '').toLowerCase() === 'true';
1630
+ const autoApprove = String(f['auto-approve'] || '').toLowerCase() === 'true';
1631
+ const planHash = f['plan-hash'] || f.plan_hash || f.planHash;
1632
+ if (!planHash) {
1633
+ console.error(
1634
+ 'apply requires --plan-hash <sha256:...> (run `scaly plan` first).'
1635
+ );
1636
+ return 2;
1637
+ }
1638
+
1639
+ try {
1640
+ const t = getStoredBearer();
1641
+ if (t && !process.env.SCALY_API_BEARER) process.env.SCALY_API_BEARER = t;
1642
+ } catch {}
1643
+
1644
+ const {
1645
+ loadScalyConfig,
1646
+ computeConfigHash,
1647
+ writeLastApply
1648
+ } = require('../lib/scaly-project');
1649
+ const { buildPlan } = require('../lib/scaly-plan');
1650
+ const { applyPlan } = require('../lib/scaly-apply');
1651
+
1652
+ let loaded;
1653
+ try {
1654
+ loaded = loadScalyConfig({ cwd: process.cwd(), env });
1655
+ } catch (err) {
1656
+ console.error(String(err && err.message ? err.message : err));
1657
+ return 2;
1658
+ }
1659
+ if (!loaded.validation.ok) {
1660
+ console.error('Invalid .scaly/config.yaml:');
1661
+ for (const e of loaded.validation.errors) console.error(`- ${e}`);
1662
+ return 2;
1663
+ }
1664
+
1665
+ const plan = await buildPlan({ config: loaded.config, env, appName });
1666
+ if (plan.plan_hash !== planHash) {
1667
+ console.error('Plan hash mismatch.');
1668
+ console.error(`Expected: ${plan.plan_hash}`);
1669
+ console.error(`Provided: ${planHash}`);
1670
+ console.error('Re-run `scaly plan` and use the printed hash.');
1671
+ return 2;
1672
+ }
1673
+
1674
+ const actionable = (plan.operations || []).filter(
1675
+ (o) => o.action === 'create' || o.action === 'update'
1676
+ );
1677
+ if (actionable.length === 0) {
1678
+ if (json)
1679
+ console.log(
1680
+ JSON.stringify({
1681
+ ok: true,
1682
+ plan_hash: plan.plan_hash,
1683
+ applied: 0,
1684
+ results: []
1685
+ })
1686
+ );
1687
+ else console.log('No changes to apply.');
1688
+ return 0;
1689
+ }
1690
+
1691
+ if (!autoApprove) {
1692
+ const rl = readline.createInterface({
1693
+ input: process.stdin,
1694
+ output: process.stdout
1695
+ });
1696
+ const answer = await new Promise((resolve) =>
1697
+ rl.question(`Apply ${actionable.length} changes? (y/N) `, resolve)
1698
+ );
1699
+ rl.close();
1700
+ if (!/^y(es)?$/i.test(String(answer || '').trim())) {
1701
+ console.error('Aborted.');
1702
+ return 1;
1703
+ }
1704
+ }
1705
+
1706
+ let res;
1707
+ try {
1708
+ res = await applyPlan({ config: loaded.config, plan, autoApprove });
1709
+ } catch (err) {
1710
+ const msg = String(err && err.message ? err.message : err);
1711
+ if (json)
1712
+ console.log(
1713
+ JSON.stringify({
1714
+ ok: false,
1715
+ plan_hash: plan.plan_hash,
1716
+ error: { message: msg }
1717
+ })
1718
+ );
1719
+ else console.error(msg);
1720
+ return 1;
1721
+ }
1722
+ const out = { ...res, plan_hash: plan.plan_hash };
1723
+
1724
+ if (res.ok) {
1725
+ const config_hash = computeConfigHash({
1726
+ config: loaded.config,
1727
+ env: plan.env,
1728
+ appName
1729
+ });
1730
+ writeLastApply(loaded.root, {
1731
+ version: 1,
1732
+ applied_at: new Date().toISOString(),
1733
+ env: plan.env || null,
1734
+ app: appName || null,
1735
+ plan_hash: plan.plan_hash,
1736
+ config_hash,
1737
+ applied: out.applied
1738
+ });
1739
+ }
1740
+
1741
+ if (json) {
1742
+ console.log(JSON.stringify(out));
1743
+ return res.ok ? 0 : 1;
1744
+ }
1745
+
1746
+ console.log(`Applied ${out.applied} changes.`);
1747
+ for (const r of out.results || []) {
1748
+ if (r.ok) console.log(`- OK ${r.op_id}`);
1749
+ else
1750
+ console.log(`- FAIL ${r.op_id}: ${r.error?.message || 'unknown error'}`);
1751
+ }
1752
+ return res.ok ? 0 : 1;
1753
+ }
1754
+
1755
+ async function runProjectPull(rest) {
1756
+ const fs = require('fs');
1757
+ const path = require('path');
1758
+ const api = require('../lib/scaly-api');
1759
+ const { writeYamlFile } = require('../lib/scaly-project');
1760
+
1761
+ const f = parseKv(rest);
1762
+ const json = String(f.json || '').toLowerCase() === 'true';
1763
+ const force = String(f.force || '').toLowerCase() === 'true';
1764
+ const stackId = f.stack || null;
1765
+ const appId = f.app || null;
1766
+
1767
+ try {
1768
+ const t = getStoredBearer();
1769
+ if (t && !process.env.SCALY_API_BEARER) process.env.SCALY_API_BEARER = t;
1770
+ } catch {}
1771
+
1772
+ const root = process.cwd();
1773
+ const scalyDir = path.join(root, '.scaly');
1774
+ const configPath = path.join(scalyDir, 'config.yaml');
1775
+
1776
+ if (fs.existsSync(configPath) && !force) {
1777
+ console.error(
1778
+ `Refusing to overwrite existing ${configPath}. Re-run with --force to replace.`
1779
+ );
1780
+ return 2;
1781
+ }
1782
+
1783
+ const stacks = await api.listStacks({ take: 50 });
1784
+ const chosenStack = stackId
1785
+ ? stacks.find((s) => s.id === stackId)
1786
+ : stacks.length === 1
1787
+ ? stacks[0]
1788
+ : null;
1789
+ if (!chosenStack) {
1790
+ console.error(
1791
+ stackId
1792
+ ? `Stack not found: ${stackId}`
1793
+ : `Multiple stacks found (${stacks.length}). Re-run with --stack <id>.`
1794
+ );
1795
+ return 2;
1796
+ }
1797
+
1798
+ const apps = await api.listApps({ take: 200 });
1799
+ const appsInStack = apps.filter((a) => a.stackId === chosenStack.id);
1800
+ const chosenApp = appId
1801
+ ? apps.find((a) => a.id === appId)
1802
+ : appsInStack.length === 1
1803
+ ? appsInStack[0]
1804
+ : null;
1805
+ if (!chosenApp) {
1806
+ console.error(
1807
+ appId
1808
+ ? `App not found: ${appId}`
1809
+ : `Multiple apps found in stack (${appsInStack.length}). Re-run with --app <id>.`
1810
+ );
1811
+ return 2;
1812
+ }
1813
+
1814
+ const config = {
1815
+ version: '1',
1816
+ account: { id: chosenStack.accountId },
1817
+ stack: {
1818
+ name: chosenStack.name,
1819
+ size: chosenStack.size,
1820
+ minIdle: chosenStack.minIdle
1821
+ },
1822
+ app: {
1823
+ name: chosenApp.name,
1824
+ framework: 'unknown'
1825
+ },
1826
+ addons: [],
1827
+ domains: [],
1828
+ jobs: []
1829
+ };
1830
+
1831
+ writeYamlFile(configPath, config);
1832
+ fs.mkdirSync(path.join(scalyDir, 'migrations'), { recursive: true });
1833
+ const schemaPath = path.join(scalyDir, 'schema.sql');
1834
+ if (!fs.existsSync(schemaPath)) fs.writeFileSync(schemaPath, '', 'utf8');
1835
+
1836
+ const out = {
1837
+ ok: true,
1838
+ wrote: configPath,
1839
+ stack: chosenStack,
1840
+ app: chosenApp
1841
+ };
1842
+
1843
+ if (json) console.log(JSON.stringify(out));
1844
+ else console.log(`Wrote ${configPath}`);
1845
+ return 0;
1846
+ }
1847
+
1848
+ // -------------------------
1849
+ // Deploy
1850
+ // -------------------------
1851
+ function sleep(ms) {
1852
+ return new Promise((resolve) => setTimeout(resolve, ms));
1853
+ }
1854
+
1855
+ async function runDeploy(rest) {
1856
+ const f = parseKv(rest);
1857
+ const json = parseBool(f.json, false);
1858
+ const watch = f.watch !== undefined ? parseBool(f.watch, true) : false;
1859
+ const strategy = String(f.strategy || 'auto').toLowerCase(); // auto|git|restart
1860
+ const pollSeconds = Number(f['poll-seconds'] || 5);
1861
+ const timeoutMinutes = Number(f['timeout-minutes'] || 20);
1862
+
1863
+ const appId = f.app || f['app-id'] || (f._ && f._[0]);
1864
+ if (!appId) {
1865
+ const msg = '--app <appId> is required';
1866
+ if (json)
1867
+ console.log(JSON.stringify({ ok: false, error: { message: msg } }));
1868
+ else console.error(msg);
1869
+ return 2;
1870
+ }
1871
+
1872
+ try {
1873
+ const t = getStoredBearer();
1874
+ if (t && !process.env.SCALY_API_BEARER) process.env.SCALY_API_BEARER = t;
1875
+ } catch {}
1876
+
1877
+ const deploy = require('../lib/scaly-deploy');
1878
+
1879
+ const app = await deploy.getAppBasic(appId);
1880
+ if (!app) {
1881
+ const msg = `App not found: ${appId}`;
1882
+ if (json)
1883
+ console.log(JSON.stringify({ ok: false, error: { message: msg } }));
1884
+ else console.error(msg);
1885
+ return 2;
1886
+ }
1887
+ if (!app.stackId) {
1888
+ const msg = `App has no stackId: ${appId}`;
1889
+ if (json)
1890
+ console.log(JSON.stringify({ ok: false, error: { message: msg } }));
1891
+ else console.error(msg);
1892
+ return 2;
1893
+ }
1894
+
1895
+ let chosen = strategy;
1896
+ let gitSource = null;
1897
+ if (strategy === 'auto') {
1898
+ gitSource = await deploy.getAppGitSource(appId);
1899
+ chosen = gitSource ? 'git' : 'restart';
1900
+ } else if (strategy === 'git') {
1901
+ gitSource = await deploy.getAppGitSource(appId);
1902
+ }
1903
+
1904
+ if (chosen === 'git') {
1905
+ const trigger = await deploy.triggerGitDeploy(appId);
1906
+ if (!trigger?.id) {
1907
+ const msg = 'Failed to trigger git deploy';
1908
+ if (json)
1909
+ console.log(JSON.stringify({ ok: false, error: { message: msg } }));
1910
+ else console.error(msg);
1911
+ return 1;
1912
+ }
1913
+
1914
+ if (!watch) {
1915
+ const out = { ok: true, strategy: 'git', git_deploy: trigger, app };
1916
+ if (json) console.log(JSON.stringify(out));
1917
+ else
1918
+ console.log(
1919
+ `[deploy] triggered git deploy ${trigger.id} status=${trigger.status}`
1920
+ );
1921
+ return 0;
1922
+ }
1923
+
1924
+ const deadline = Date.now() + timeoutMinutes * 60_000;
1925
+ let lastStatus = null;
1926
+ let matched = null;
1927
+ while (Date.now() < deadline) {
1928
+ const items = await deploy.listGitDeployments(appId, 20);
1929
+ matched = items.find((d) => d.id === trigger.id) || null;
1930
+ if (matched && matched.status !== lastStatus) {
1931
+ lastStatus = matched.status;
1932
+ if (!json)
1933
+ console.log(
1934
+ `[deploy] git status=${matched.status} deploymentId=${matched.deploymentId || 'n/a'}`
1935
+ );
1936
+ }
1937
+ if (
1938
+ matched &&
1939
+ ['successful', 'failed', 'cancelled'].includes(
1940
+ String(matched.status || '').toLowerCase()
1941
+ )
1942
+ )
1943
+ break;
1944
+ await sleep(pollSeconds * 1000);
1945
+ }
1946
+
1947
+ const final = matched || trigger;
1948
+ const finalStatus = String(final?.status || '').toLowerCase();
1949
+ const ok = finalStatus === 'successful';
1950
+ if (!['successful', 'failed', 'cancelled'].includes(finalStatus)) {
1951
+ const out = {
1952
+ ok: false,
1953
+ strategy: 'git',
1954
+ git_deploy: final,
1955
+ app,
1956
+ error: { message: 'Timed out waiting for git deployment' }
1957
+ };
1958
+ if (json) console.log(JSON.stringify(out));
1959
+ else console.error('[deploy] timed out waiting for git deployment');
1960
+ return 1;
1961
+ }
1962
+ const out = { ok, strategy: 'git', git_deploy: final, app };
1963
+ if (json) console.log(JSON.stringify(out));
1964
+ return ok ? 0 : 1;
1965
+ }
1966
+
1967
+ // restart strategy
1968
+ const dep = await deploy.restartStackServices(app.stackId);
1969
+ if (!dep?.id) {
1970
+ const msg =
1971
+ 'Failed to start deployment (restartStackServices returned no id)';
1972
+ if (json)
1973
+ console.log(JSON.stringify({ ok: false, error: { message: msg } }));
1974
+ else console.error(msg);
1975
+ return 1;
1976
+ }
1977
+
1978
+ if (!watch) {
1979
+ const out = { ok: true, strategy: 'restart', deployment: dep, app };
1980
+ if (json) console.log(JSON.stringify(out));
1981
+ else
1982
+ console.log(`[deploy] started deployment ${dep.id} status=${dep.status}`);
1983
+ return 0;
1984
+ }
1985
+
1986
+ const deadline = Date.now() + timeoutMinutes * 60_000;
1987
+ let last = null;
1988
+ while (Date.now() < deadline) {
1989
+ const current = await deploy.getDeployment(dep.id);
1990
+ if (
1991
+ current &&
1992
+ (current.status !== last?.status ||
1993
+ current.step !== last?.step ||
1994
+ current.progressPct !== last?.progressPct)
1995
+ ) {
1996
+ last = current;
1997
+ if (!json) {
1998
+ console.log(
1999
+ `[deploy] ${current.id} status=${current.status} step=${current.step} progress=${current.progressPct}%`
2000
+ );
2001
+ }
2002
+ }
2003
+ const status = String(current?.status || '').toLowerCase();
2004
+ if (current && ['successful', 'failed', 'cancelled'].includes(status)) {
2005
+ const out = {
2006
+ ok: status === 'successful',
2007
+ strategy: 'restart',
2008
+ deployment: current,
2009
+ app
2010
+ };
2011
+ if (json) console.log(JSON.stringify(out));
2012
+ return out.ok ? 0 : 1;
2013
+ }
2014
+ await sleep(pollSeconds * 1000);
2015
+ }
2016
+
2017
+ const out = {
2018
+ ok: false,
2019
+ error: { message: 'Timed out waiting for deployment' },
2020
+ deployment_id: dep.id
2021
+ };
2022
+ if (json) console.log(JSON.stringify(out));
2023
+ else console.error(out.error.message);
2024
+ return 1;
2025
+ }
2026
+
2027
+ // -------------------------
2028
+ // Logs follow (Unified Logs)
2029
+ // -------------------------
2030
+ async function runLogsFollow(rest) {
2031
+ const f = parseKv(rest);
2032
+ const appId = f.app || f['app-id'] || (f._ && f._[0]);
2033
+ const since = f.since || '10m';
2034
+ const level = f.level || 'all';
2035
+ const q = f.q || null;
2036
+ const pollMs = Number(f['poll-ms'] || 2000);
2037
+ const durationSeconds =
2038
+ f['duration-seconds'] !== undefined ? Number(f['duration-seconds']) : null;
2039
+ const maxLines = f['max-lines'] !== undefined ? Number(f['max-lines']) : null;
2040
+ const json = parseBool(f.json, false);
2041
+
2042
+ if (!appId) {
2043
+ console.error('--app <appId> is required');
2044
+ return 2;
2045
+ }
2046
+
2047
+ try {
2048
+ const t = getStoredBearer();
2049
+ if (t && !process.env.SCALY_API_BEARER) process.env.SCALY_API_BEARER = t;
2050
+ } catch {}
2051
+
2052
+ const logs = require('../lib/scaly-logs');
2053
+ const app = await logs.getAppBasic(appId);
2054
+ if (!app) {
2055
+ console.error(`App not found: ${appId}`);
2056
+ return 2;
2057
+ }
2058
+
2059
+ const lookbackHours = logs.parseTimeRangeToLookbackHours(since);
2060
+ if (!lookbackHours) {
2061
+ console.error('Invalid --since; expected like 15m, 1h, 1d');
2062
+ return 2;
2063
+ }
2064
+
2065
+ const deadline =
2066
+ durationSeconds && Number.isFinite(durationSeconds) && durationSeconds > 0
2067
+ ? Date.now() + durationSeconds * 1000
2068
+ : null;
2069
+ const shouldStopByLines =
2070
+ maxLines && Number.isFinite(maxLines) && maxLines > 0 ? maxLines : null;
2071
+ let printed = 0;
2072
+
2073
+ let liveFromTs = null;
2074
+ // Initial batch
2075
+ const first = await logs.getUnifiedLogs({
2076
+ accountId: app.accountId,
2077
+ appIds: [appId],
2078
+ lookbackHours,
2079
+ liveFromTs: undefined,
2080
+ limit: 200,
2081
+ q,
2082
+ level,
2083
+ pageToken: undefined
2084
+ });
2085
+ const seen = new Set();
2086
+ const seenQueue = [];
2087
+ const maxSeen = 5000;
2088
+ const remember = (id) => {
2089
+ if (seen.has(id)) return false;
2090
+ seen.add(id);
2091
+ seenQueue.push(id);
2092
+ while (seenQueue.length > maxSeen) {
2093
+ const old = seenQueue.shift();
2094
+ if (old) seen.delete(old);
2095
+ }
2096
+ return true;
2097
+ };
2098
+ if (first && Array.isArray(first.events)) {
2099
+ for (const e of first.events) {
2100
+ if (!remember(e.id)) continue;
2101
+ if (json) console.log(JSON.stringify(e));
2102
+ else console.log(`${e.ts} ${e.level || 'Unknown'} ${e.message}`);
2103
+ printed++;
2104
+ if (!liveFromTs || e.ts > liveFromTs) liveFromTs = e.ts;
2105
+ if (shouldStopByLines && printed >= shouldStopByLines) return 0;
2106
+ }
2107
+ }
2108
+ if (!liveFromTs) liveFromTs = new Date().toISOString();
2109
+
2110
+ // Tail loop
2111
+ // eslint-disable-next-line no-constant-condition
2112
+ while (true) {
2113
+ if (deadline && Date.now() >= deadline) return 0;
2114
+ const res = await logs.getUnifiedLogs({
2115
+ accountId: app.accountId,
2116
+ appIds: [appId],
2117
+ lookbackHours: undefined,
2118
+ liveFromTs,
2119
+ limit: 200,
2120
+ q,
2121
+ level,
2122
+ pageToken: undefined
2123
+ });
2124
+ if (res && Array.isArray(res.events) && res.events.length) {
2125
+ for (const e of res.events) {
2126
+ if (!remember(e.id)) continue;
2127
+ if (json) console.log(JSON.stringify(e));
2128
+ else console.log(`${e.ts} ${e.level || 'Unknown'} ${e.message}`);
2129
+ printed++;
2130
+ if (e.ts > liveFromTs) liveFromTs = e.ts;
2131
+ if (shouldStopByLines && printed >= shouldStopByLines) return 0;
2132
+ }
2133
+ }
2134
+ await sleep(pollMs);
2135
+ }
2136
+ }
2137
+
2138
+ // -------------------------
2139
+ // Secrets (App Build Secrets)
2140
+ // -------------------------
2141
+ async function runSecrets(sub, rest) {
2142
+ const f = parseKv(rest);
2143
+ const json = parseBool(f.json, false);
2144
+
2145
+ // OIDC-only for build secrets.
2146
+ try {
2147
+ const t = getStoredBearer();
2148
+ if (t && !process.env.SCALY_API_BEARER) process.env.SCALY_API_BEARER = t;
2149
+ } catch {}
2150
+
2151
+ const bearer =
2152
+ process.env.SCALY_API_BEARER ||
2153
+ process.env.API_BEARER_TOKEN ||
2154
+ process.env.API_BEARER;
2155
+ if (!bearer || !isProbablyJwt(bearer)) {
2156
+ const msg =
2157
+ 'secrets commands require a Cognito OIDC access token (JWT). ' +
2158
+ 'Run `scaly login --token <access_token_jwt>` first.';
2159
+ if (json)
2160
+ console.log(JSON.stringify({ ok: false, error: { message: msg } }));
2161
+ else console.error(msg);
2162
+ return 2;
2163
+ }
2164
+
2165
+ const appId = f.app || f['app-id'] || (f._ && f._[0]);
2166
+ if (!appId) {
2167
+ const msg = '--app <appId> is required';
2168
+ if (json)
2169
+ console.log(JSON.stringify({ ok: false, error: { message: msg } }));
2170
+ else console.error(msg);
2171
+ return 2;
2172
+ }
2173
+
2174
+ const secrets = require('../lib/scaly-secrets');
2175
+ const { loadScalyConfig } = require('../lib/scaly-project');
2176
+
2177
+ if (sub === 'list') {
2178
+ const items = await secrets.listBuildSecrets(appId);
2179
+ const out = {
2180
+ ok: true,
2181
+ app_id: appId,
2182
+ secrets: items,
2183
+ count: items.length
2184
+ };
2185
+ if (json) console.log(JSON.stringify(out));
2186
+ else {
2187
+ for (const s of items) console.log(`${s.name}\t${s.id}`);
2188
+ }
2189
+ return 0;
2190
+ }
2191
+
2192
+ if (sub === 'set') {
2193
+ const name = f.name;
2194
+ const fromEnv = f['from-env'] || f.from_env;
2195
+ const stdin = parseBool(f.stdin, false);
2196
+ if (!name) {
2197
+ const msg = '--name <KEY> is required';
2198
+ if (json)
2199
+ console.log(JSON.stringify({ ok: false, error: { message: msg } }));
2200
+ else console.error(msg);
2201
+ return 2;
2202
+ }
2203
+
2204
+ let value = null;
2205
+ if (fromEnv) {
2206
+ value = process.env[String(fromEnv)] ?? null;
2207
+ if (value === null) {
2208
+ const msg = `Environment variable not set: ${fromEnv}`;
2209
+ if (json)
2210
+ console.log(JSON.stringify({ ok: false, error: { message: msg } }));
2211
+ else console.error(msg);
2212
+ return 2;
2213
+ }
2214
+ } else if (stdin) {
2215
+ value = String(await readStdinAll()).replace(/\r?\n$/, '');
2216
+ } else {
2217
+ value = await promptHidden(`Value for ${name}: `);
2218
+ }
2219
+
2220
+ const res = await secrets.setBuildSecret({ appId, name, value });
2221
+ const out = {
2222
+ ok: true,
2223
+ app_id: appId,
2224
+ action: res.action,
2225
+ name,
2226
+ id: res.secret?.id || null
2227
+ };
2228
+ if (json) console.log(JSON.stringify(out));
2229
+ else console.log(`[secrets] ${res.action} ${name}`);
2230
+ return 0;
2231
+ }
2232
+
2233
+ if (sub === 'delete') {
2234
+ const id = f.id || null;
2235
+ const name = f.name || null;
2236
+ const yes = parseBool(f.yes, false);
2237
+ if (!id && !name) {
2238
+ const msg = 'delete requires --id <secretId> or --name <KEY>';
2239
+ if (json)
2240
+ console.log(JSON.stringify({ ok: false, error: { message: msg } }));
2241
+ else console.error(msg);
2242
+ return 2;
2243
+ }
2244
+
2245
+ let secretId = id;
2246
+ if (!secretId) {
2247
+ const items = await secrets.listBuildSecrets(appId);
2248
+ const match = items.find((s) => s.name === name);
2249
+ if (!match) {
2250
+ const msg = `Secret not found: ${name}`;
2251
+ if (json)
2252
+ console.log(JSON.stringify({ ok: false, error: { message: msg } }));
2253
+ else console.error(msg);
2254
+ return 2;
2255
+ }
2256
+ secretId = match.id;
2257
+ }
2258
+
2259
+ const proceed = yes
2260
+ ? true
2261
+ : await promptYesNo(`Delete secret ${name || secretId}? (y/N) `);
2262
+ if (!proceed) {
2263
+ const out = { ok: false, aborted: true };
2264
+ if (json) console.log(JSON.stringify(out));
2265
+ else console.error('Aborted.');
2266
+ return 1;
2267
+ }
2268
+
2269
+ await secrets.deleteBuildSecret({ id: secretId });
2270
+ const out = { ok: true, deleted: true, id: secretId };
2271
+ if (json) console.log(JSON.stringify(out));
2272
+ else console.log('[secrets] deleted');
2273
+ return 0;
2274
+ }
2275
+
2276
+ if (sub === 'sync') {
2277
+ const env = f.env || null;
2278
+ const apply = parseBool(f.apply, false);
2279
+ const dryRun =
2280
+ f['dry-run'] !== undefined ? parseBool(f['dry-run'], true) : !apply;
2281
+ const configAppName =
2282
+ f['config-app'] || f.config_app || f['app-name'] || f.app_name || null;
2283
+
2284
+ let loaded;
2285
+ try {
2286
+ loaded = loadScalyConfig({ cwd: process.cwd(), env });
2287
+ } catch (err) {
2288
+ const msg = String(err && err.message ? err.message : err);
2289
+ if (json)
2290
+ console.log(JSON.stringify({ ok: false, error: { message: msg } }));
2291
+ else console.error(msg);
2292
+ return 2;
2293
+ }
2294
+ if (!loaded.validation.ok) {
2295
+ const msg = 'Invalid .scaly/config.yaml';
2296
+ if (json)
2297
+ console.log(
2298
+ JSON.stringify({
2299
+ ok: false,
2300
+ error: { message: msg },
2301
+ details: loaded.validation.errors
2302
+ })
2303
+ );
2304
+ else {
2305
+ console.error(msg);
2306
+ for (const e of loaded.validation.errors) console.error(`- ${e}`);
2307
+ }
2308
+ return 2;
2309
+ }
2310
+
2311
+ const apps = loaded.config.apps || [];
2312
+ const appsWithSecrets = apps.filter(
2313
+ (a) =>
2314
+ a &&
2315
+ typeof a === 'object' &&
2316
+ Array.isArray(a.secrets) &&
2317
+ a.secrets.length
2318
+ );
2319
+
2320
+ let selectedApp = null;
2321
+ if (configAppName) {
2322
+ selectedApp = apps.find((a) => a && a.name === configAppName) || null;
2323
+ if (!selectedApp) {
2324
+ const available = apps
2325
+ .map((a) => a && a.name)
2326
+ .filter(Boolean)
2327
+ .join(', ');
2328
+ const msg = `Unknown --config-app ${configAppName}. Available: ${available || '(none)'}`;
2329
+ if (json)
2330
+ console.log(JSON.stringify({ ok: false, error: { message: msg } }));
2331
+ else console.error(msg);
2332
+ return 2;
2333
+ }
2334
+ } else if (apps.length === 1) {
2335
+ selectedApp = apps[0];
2336
+ } else if (apps.some((a) => a && a.name === appId)) {
2337
+ // Convenience: if user passed an app name (not id) to --app, treat it as config app selector.
2338
+ selectedApp = apps.find((a) => a && a.name === appId) || null;
2339
+ } else if (appsWithSecrets.length === 1) {
2340
+ selectedApp = appsWithSecrets[0];
2341
+ } else {
2342
+ const available = apps
2343
+ .map((a) => a && a.name)
2344
+ .filter(Boolean)
2345
+ .join(', ');
2346
+ const msg =
2347
+ `Multiple apps in .scaly/config.yaml. Use --config-app <name> to select which app's secrets to sync ` +
2348
+ `to --app ${appId}. Available: ${available || '(none)'}`;
2349
+ if (json)
2350
+ console.log(JSON.stringify({ ok: false, error: { message: msg } }));
2351
+ else console.error(msg);
2352
+ return 2;
2353
+ }
2354
+
2355
+ const wantedSecrets = [];
2356
+ if (selectedApp && Array.isArray(selectedApp.secrets)) {
2357
+ for (const s of selectedApp.secrets) {
2358
+ if (!s || typeof s !== 'object') continue;
2359
+ if (!s.name) continue;
2360
+ wantedSecrets.push({ name: s.name, source_env: s.source_env || null });
2361
+ }
2362
+ }
2363
+
2364
+ const actions = [];
2365
+ for (const s of wantedSecrets) {
2366
+ const source = s.source_env;
2367
+ const present = source
2368
+ ? Object.prototype.hasOwnProperty.call(process.env, source)
2369
+ : false;
2370
+ actions.push({
2371
+ name: s.name,
2372
+ source_env: source,
2373
+ present,
2374
+ action: present ? (dryRun ? 'would_set' : 'set') : 'skipped'
2375
+ });
2376
+ }
2377
+
2378
+ if (!dryRun) {
2379
+ for (const a of actions) {
2380
+ if (a.action !== 'set') continue;
2381
+ const value = process.env[a.source_env];
2382
+ await secrets.setBuildSecret({ appId, name: a.name, value });
2383
+ }
2384
+ }
2385
+
2386
+ const out = {
2387
+ ok: true,
2388
+ app_id: appId,
2389
+ config_app: selectedApp ? selectedApp.name : null,
2390
+ dry_run: dryRun,
2391
+ actions
2392
+ };
2393
+ if (json) console.log(JSON.stringify(out));
2394
+ else {
2395
+ for (const a of actions) {
2396
+ const tag =
2397
+ a.action === 'skipped' ? 'MISSING_ENV' : a.action.toUpperCase();
2398
+ console.log(
2399
+ `[secrets] ${tag} ${a.name}${a.source_env ? ` (${a.source_env})` : ''}`
2400
+ );
2401
+ }
2402
+ if (dryRun)
2403
+ console.log('[secrets] dry-run only. Re-run with --apply to write.');
2404
+ }
2405
+ return 0;
2406
+ }
2407
+
2408
+ console.error('Usage: scaly secrets <list|set|delete|sync> ...');
2409
+ return 2;
2410
+ }
2411
+
2412
+ // -------------------------
2413
+ // DB tunnel: localhost port-forward via WSS
2414
+ // -------------------------
2415
+ async function mintDbProxyInfo(rest) {
2416
+ const f = parseKv(rest);
2417
+ const addOnId = f.addon || f['add-on'] || (f._ && f._[0]);
2418
+ if (!addOnId) {
2419
+ const e = new Error('db requires --addon <addOnId>');
2420
+ e.code = 'SCALY_DB_ADDON_REQUIRED';
2421
+ throw e;
2422
+ }
2423
+
2424
+ const { getToken, apiPost, deriveDomain } = await importHelpers();
2425
+ const bearer = await getToken();
2426
+ if (!isProbablyJwt(bearer)) {
2427
+ const e = new Error(
2428
+ 'db commands require a Cognito OIDC access token (JWT) in Authorization: Bearer.'
2429
+ );
2430
+ e.code = 'SCALY_DB_JWT_REQUIRED';
2431
+ throw e;
2432
+ }
2433
+
2434
+ const ttlMinutes =
2435
+ f['ttl-minutes'] !== undefined ? Number(f['ttl-minutes']) : undefined;
2436
+ const tokenRes = await apiPost(bearer, CREATE_DATABASE_PROXY_TOKEN_MUTATION, {
2437
+ addOnId,
2438
+ ttlMinutes
2439
+ });
2440
+ const info = tokenRes?.createDatabaseProxyToken;
2441
+ if (!info?.token) {
2442
+ const e = new Error('Could not mint database proxy token for add-on');
2443
+ e.code = 'SCALY_DB_TOKEN_FAILED';
2444
+ throw e;
2445
+ }
2446
+
2447
+ const domain = deriveDomain();
2448
+ const tunnelUrl = `wss://dbt.${domain}`;
2449
+
2450
+ return { f, addOnId, bearer, info, tunnelUrl };
2451
+ }
2452
+
2453
+ async function startDbTunnelServer({ bearer, tunnelUrl, host, localPort }) {
2454
+ const net = require('net');
2455
+ const { WebSocket, createWebSocketStream } = require('ws');
2456
+ const { pipeline } = require('stream');
2457
+
2458
+ const server = net.createServer((socket) => {
2459
+ socket.setNoDelay(true);
2460
+
2461
+ const ws = new WebSocket(tunnelUrl, {
2462
+ headers: {
2463
+ authorization: `Bearer ${bearer}`
2464
+ }
2465
+ });
2466
+
2467
+ let closed = false;
2468
+ const closeAll = () => {
2469
+ if (closed) return;
2470
+ closed = true;
2471
+ try {
2472
+ socket.destroy();
2473
+ } catch {}
2474
+ try {
2475
+ ws.terminate();
2476
+ } catch {}
2477
+ };
2478
+
2479
+ const done = () => closeAll();
2480
+ const wsStream = createWebSocketStream(ws);
2481
+ pipeline(socket, wsStream, done);
2482
+ pipeline(wsStream, socket, done);
2483
+
2484
+ ws.on('close', done);
2485
+ ws.on('error', done);
2486
+ socket.on('close', done);
2487
+ socket.on('error', done);
2488
+ });
2489
+
2490
+ await new Promise((resolve, reject) => {
2491
+ server.once('error', reject);
2492
+ server.listen(localPort, host, resolve);
2493
+ });
2494
+
2495
+ return server;
2496
+ }
2497
+
2498
+ async function runDbConnect(rest) {
2499
+ const f = parseKv(rest);
2500
+ const json = parseBool(f.json, false);
2501
+ const show = parseBool(f.show, false);
2502
+ const copy = f.copy !== undefined ? parseBool(f.copy, true) : !show;
2503
+
2504
+ let minted;
2505
+ try {
2506
+ minted = await mintDbProxyInfo(rest);
2507
+ } catch (e) {
2508
+ const msg = String(e && e.message ? e.message : e);
2509
+ if (json)
2510
+ console.log(JSON.stringify({ ok: false, error: { message: msg } }));
2511
+ else console.error(msg);
2512
+ return 2;
2513
+ }
2514
+ const { addOnId, bearer, info, tunnelUrl } = minted;
2515
+
2516
+ const host = f.host || '127.0.0.1';
2517
+ const localPort = Number(f['local-port'] || info.port || 5432);
2518
+
2519
+ const server = await startDbTunnelServer({
2520
+ bearer,
2521
+ tunnelUrl,
2522
+ host,
2523
+ localPort
2524
+ });
2525
+
2526
+ const psqlCmd = `PGSSLMODE=require PGPASSWORD='${info.token}' psql -h ${host} -p ${localPort} -U ${info.username} -d ${info.database}`;
2527
+ let copied = false;
2528
+ if (copy && !show) {
2529
+ copied = copyToClipboard(psqlCmd);
2530
+ }
2531
+
2532
+ if (json) {
2533
+ console.log(
2534
+ JSON.stringify({
2535
+ ok: true,
2536
+ addon_id: addOnId,
2537
+ host,
2538
+ local_port: localPort,
2539
+ username: info.username,
2540
+ database: info.database,
2541
+ expires_at: info.expiresAt,
2542
+ copied,
2543
+ show_supported: true
2544
+ })
2545
+ );
2546
+ } else {
2547
+ console.log(`[db connect] addOn=${addOnId}`);
2548
+ console.log(
2549
+ `[db connect] tunnel=${tunnelUrl} -> localhost ${host}:${localPort}`
2550
+ );
2551
+ console.log(`[db connect] token expiresAt=${info.expiresAt}`);
2552
+ console.log('');
2553
+ if (show) {
2554
+ console.log(psqlCmd);
2555
+ console.log('');
2556
+ } else if (copy) {
2557
+ console.log(
2558
+ copied
2559
+ ? '[db connect] copied psql command to clipboard (use --show to print)'
2560
+ : '[db connect] could not copy to clipboard; re-run with --show to print'
2561
+ );
2562
+ console.log('');
2563
+ }
2564
+ }
2565
+
2566
+ if (!json) {
2567
+ console.log(
2568
+ `[db connect] listening on ${host}:${localPort} (Ctrl-C to stop)`
2569
+ );
2570
+ }
2571
+
2572
+ const closeServer = () => {
2573
+ try {
2574
+ server.close(() => process.exit(0));
2575
+ } catch {
2576
+ process.exit(0);
2577
+ }
2578
+ };
2579
+ process.once('SIGINT', closeServer);
2580
+ process.once('SIGTERM', closeServer);
2581
+ await new Promise(() => {});
2582
+ }
2583
+
2584
+ async function runDbShell(rest) {
2585
+ const { spawnSync } = require('child_process');
2586
+ const f = parseKv(rest);
2587
+ let minted;
2588
+ try {
2589
+ minted = await mintDbProxyInfo(rest);
2590
+ } catch (e) {
2591
+ console.error(String(e && e.message ? e.message : e));
2592
+ return 2;
2593
+ }
2594
+ const { addOnId, bearer, info, tunnelUrl } = minted;
2595
+ const host = f.host || '127.0.0.1';
2596
+ const localPort = Number(f['local-port'] || info.port || 5432);
2597
+ const server = await startDbTunnelServer({
2598
+ bearer,
2599
+ tunnelUrl,
2600
+ host,
2601
+ localPort
2602
+ });
2603
+
2604
+ const env = {
2605
+ ...process.env,
2606
+ PGPASSWORD: info.token,
2607
+ PGSSLMODE: 'require'
2608
+ };
2609
+
2610
+ console.log(`[db shell] addOn=${addOnId} on ${host}:${localPort}`);
2611
+ const res = spawnSync(
2612
+ 'psql',
2613
+ [
2614
+ '-h',
2615
+ host,
2616
+ '-p',
2617
+ String(localPort),
2618
+ '-U',
2619
+ info.username,
2620
+ '-d',
2621
+ info.database
2622
+ ],
2623
+ {
2624
+ stdio: 'inherit',
2625
+ env
2626
+ }
2627
+ );
2628
+
2629
+ try {
2630
+ server.close();
2631
+ } catch {}
2632
+ return typeof res.status === 'number' ? res.status : 1;
2633
+ }
2634
+
2635
+ async function runDbSchemaDump(rest) {
2636
+ const { spawnSync } = require('child_process');
2637
+ const fs = require('fs');
2638
+ const path = require('path');
2639
+ const f = parseKv(rest);
2640
+
2641
+ const outPath = f.out || path.join('.scaly', 'schema.sql');
2642
+
2643
+ let minted;
2644
+ try {
2645
+ minted = await mintDbProxyInfo(rest);
2646
+ } catch (e) {
2647
+ console.error(String(e && e.message ? e.message : e));
2648
+ return 2;
2649
+ }
2650
+ const { addOnId, bearer, info, tunnelUrl } = minted;
2651
+ const host = f.host || '127.0.0.1';
2652
+ const localPort = Number(f['local-port'] || info.port || 5432);
2653
+ const server = await startDbTunnelServer({
2654
+ bearer,
2655
+ tunnelUrl,
2656
+ host,
2657
+ localPort
2658
+ });
2659
+
2660
+ const env = {
2661
+ ...process.env,
2662
+ PGPASSWORD: info.token,
2663
+ PGSSLMODE: 'require'
2664
+ };
2665
+
2666
+ console.log(`[db schema dump] addOn=${addOnId} -> ${outPath}`);
2667
+
2668
+ const res = spawnSync(
2669
+ 'pg_dump',
2670
+ [
2671
+ '--schema-only',
2672
+ '--no-owner',
2673
+ '--no-privileges',
2674
+ '-h',
2675
+ host,
2676
+ '-p',
2677
+ String(localPort),
2678
+ '-U',
2679
+ info.username,
2680
+ '-d',
2681
+ info.database
2682
+ ],
2683
+ { encoding: 'utf8', env }
2684
+ );
2685
+
2686
+ try {
2687
+ server.close();
2688
+ } catch {}
2689
+
2690
+ if (res.status !== 0) {
2691
+ console.error(res.stderr || '[db schema dump] pg_dump failed');
2692
+ return res.status || 1;
2693
+ }
2694
+
2695
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
2696
+ fs.writeFileSync(outPath, res.stdout, 'utf8');
2697
+ console.log(`[db schema dump] wrote ${outPath}`);
2698
+ return 0;
2699
+ }
2700
+
2701
+ async function runDbMigrate(rest) {
2702
+ const { spawnSync } = require('child_process');
2703
+ const fs = require('fs');
2704
+ const path = require('path');
2705
+ const f = parseKv(rest);
2706
+ const file = (f._ && f._[0]) || null;
2707
+ if (!file) {
2708
+ console.error(
2709
+ 'Usage: scaly db migrate <sql-file> --addon <addOnId> [--yes]'
2710
+ );
2711
+ return 2;
2712
+ }
2713
+ const yes = parseBool(f.yes, false);
2714
+
2715
+ const abs = path.resolve(file);
2716
+ if (!fs.existsSync(abs)) {
2717
+ console.error(`Migration file not found: ${abs}`);
2718
+ return 2;
2719
+ }
2720
+ if (!yes) {
2721
+ const ok = await promptYesNo(`Apply migration ${abs}? (y/N) `);
2722
+ if (!ok) {
2723
+ console.error('Aborted.');
2724
+ return 1;
2725
+ }
2726
+ }
2727
+
2728
+ let minted;
2729
+ try {
2730
+ minted = await mintDbProxyInfo(rest);
2731
+ } catch (e) {
2732
+ console.error(String(e && e.message ? e.message : e));
2733
+ return 2;
2734
+ }
2735
+ const { addOnId, bearer, info, tunnelUrl } = minted;
2736
+ const host = f.host || '127.0.0.1';
2737
+ const localPort = Number(f['local-port'] || info.port || 5432);
2738
+ const server = await startDbTunnelServer({
2739
+ bearer,
2740
+ tunnelUrl,
2741
+ host,
2742
+ localPort
2743
+ });
2744
+
2745
+ const env = {
2746
+ ...process.env,
2747
+ PGPASSWORD: info.token,
2748
+ PGSSLMODE: 'require'
2749
+ };
2750
+
2751
+ console.log(`[db migrate] addOn=${addOnId} file=${abs}`);
2752
+ const res = spawnSync(
2753
+ 'psql',
2754
+ [
2755
+ '-v',
2756
+ 'ON_ERROR_STOP=1',
2757
+ '-h',
2758
+ host,
2759
+ '-p',
2760
+ String(localPort),
2761
+ '-U',
2762
+ info.username,
2763
+ '-d',
2764
+ info.database,
2765
+ '-f',
2766
+ abs
2767
+ ],
2768
+ { stdio: 'inherit', env }
2769
+ );
2770
+
2771
+ try {
2772
+ server.close();
2773
+ } catch {}
2774
+
2775
+ return typeof res.status === 'number' ? res.status : 1;
2776
+ }
2777
+
2778
+ // -------------------------
2779
+ // Insights: diagnose-app
2780
+ // -------------------------
2781
+ async function runDiagnoseApp(rest) {
2782
+ const f = parseKv(rest);
2783
+ const accountId = f.account;
2784
+ const stackId = f.stack;
2785
+ const appName = f.app || (f._ && f._[0]);
2786
+ if (!accountId || !stackId || !appName) {
2787
+ console.error(
2788
+ 'diagnose-app requires --account <id> --stack <id> --app <name>'
2789
+ );
2790
+ return 2;
2791
+ }
2792
+ await sourceLocalEnv();
2793
+ const {
2794
+ getToken,
2795
+ apiPost,
2796
+ deriveDomain,
2797
+ regionToAws,
2798
+ checkHttp200,
2799
+ makeCreds,
2800
+ elbClient,
2801
+ ecsClient
2802
+ } = await importHelpers();
2803
+ const token = await getToken();
2804
+
2805
+ const stackRes = await apiPost(token, GET_STACK_QUERY, {
2806
+ where: { id: stackId }
2807
+ });
2808
+ const stack = stackRes?.getStack;
2809
+ const domain = deriveDomain();
2810
+ const accountPrefix = String(accountId).split('-')[0];
2811
+ const url = `https://${accountPrefix}-${stack?.name}.${domain}/${appName}/`;
2812
+
2813
+ // Probe URL
2814
+ const http = await checkHttp200(url, {
2815
+ tries: Number(f.tries) || 6,
2816
+ delayMs: Number(f['delay-ms']) || 5000
2817
+ });
2818
+
2819
+ // Pull stack logs for last N (default 30m)
2820
+ const lookback = f.since || '30m';
2821
+ const logsRes = await apiPost(token, GET_SCALY_STACK_LOGS, {
2822
+ where: { id: stackId, startTime: sinceToIso(lookback) }
2823
+ });
2824
+ const events = (logsRes?.getScalyStackLogs?.events || []).slice(-400);
2825
+
2826
+ const findings = [];
2827
+ const text = events.map((e) => e.message || '').join('\n');
2828
+ if (/fork\/exec\s+venv\/bin\/python: no such file/i.test(text)) {
2829
+ findings.push({
2830
+ code: 'APP_VENV_MISSING',
2831
+ severity: 'high',
2832
+ hint: 'Replicator/unpacker missing venv. Confirm rootfs contains venv and python shim.'
2833
+ });
2834
+ }
2835
+ if (
2836
+ /http:\s+proxy error: dial tcp 127\.0\.0\.1/i.test(text) ||
2837
+ /connection refused/i.test(text)
2838
+ ) {
2839
+ findings.push({
2840
+ code: 'APP_PORT_REFUSED',
2841
+ severity: 'high',
2842
+ hint: 'App process not listening. Check start command and runtime.'
2843
+ });
2844
+ }
2845
+ if (/Unable to determine type of app/i.test(text)) {
2846
+ findings.push({
2847
+ code: 'APP_TYPE_UNKNOWN',
2848
+ severity: 'medium',
2849
+ hint: 'Server could not infer app type; ensure template sets it or requirements specify.'
2850
+ });
2851
+ }
2852
+ if (/User pool .* does not exist/i.test(text)) {
2853
+ findings.push({
2854
+ code: 'COGNITO_POOL_MISSING',
2855
+ severity: 'medium',
2856
+ hint: 'Identity add-on misconfigured; verify user pool id/region.'
2857
+ });
2858
+ }
2859
+ if (/ResourceAlreadyExistsException/i.test(text)) {
2860
+ findings.push({
2861
+ code: 'RESOURCE_EXISTS',
2862
+ severity: 'low',
2863
+ hint: 'Installer attempted to create a resource owned by the stack. Remove ad-hoc creation.'
2864
+ });
2865
+ }
2866
+ // Node runtime signals
2867
+ if (/MODULE_NOT_FOUND|Cannot find module/i.test(text)) {
2868
+ findings.push({
2869
+ code: 'NODE_MODULE_NOT_FOUND',
2870
+ severity: 'high',
2871
+ hint: 'node_modules missing or build incomplete. Ensure deps bundled or installed server-side.'
2872
+ });
2873
+ }
2874
+ if (/npm ERR!/i.test(text)) {
2875
+ findings.push({
2876
+ code: 'NPM_ERROR',
2877
+ severity: 'medium',
2878
+ hint: 'NPM failed during build/start. Inspect logs for first error.'
2879
+ });
2880
+ }
2881
+ if (/EADDRINUSE|address already in use/i.test(text)) {
2882
+ findings.push({
2883
+ code: 'PORT_IN_USE',
2884
+ severity: 'medium',
2885
+ hint: 'App bound to a busy port. Ensure it binds to the provided port.'
2886
+ });
2887
+ }
2888
+ // R runtime signals
2889
+ if (
2890
+ /there is no package called/i.test(text) ||
2891
+ /package or namespace load failed/i.test(text)
2892
+ ) {
2893
+ findings.push({
2894
+ code: 'R_PACKAGE_MISSING',
2895
+ severity: 'high',
2896
+ hint: 'renv restore likely incomplete. Verify renv.lock and cache installer finished.'
2897
+ });
2898
+ }
2899
+ if (/cannot open shared object file/i.test(text)) {
2900
+ findings.push({
2901
+ code: 'R_SHARED_LIB_MISSING',
2902
+ severity: 'medium',
2903
+ hint: 'System library missing. Consider adding runtime shim with required libs.'
2904
+ });
2905
+ }
2906
+
2907
+ // Infra checks (ALB target health + ECS tasks) using brokered creds
2908
+ let infra = {};
2909
+ try {
2910
+ const region = regionToAws(stack?.account?.region || 'EU') || 'eu-west-1';
2911
+ const credsRes = await apiPost(token, ISSUE_AWS_CREDENTIALS_MUTATION, {
2912
+ input: {
2913
+ accountId,
2914
+ regionAws: region,
2915
+ scopes: ['ELBV2_READ', 'ECS_READ'],
2916
+ durationSeconds: 900
2917
+ }
2918
+ });
2919
+ const c = credsRes?.issueAwsCredentials;
2920
+ if (c?.accessKeyId) {
2921
+ const creds = makeCreds(c);
2922
+ // Target group heuristic: scaly-<first8-of-stackId>
2923
+ const shortId = String(stackId).split('-')[0];
2924
+ const elb = elbClient(region, creds);
2925
+ const tgResp = await elb.send(new DescribeTargetGroupsCommand({}));
2926
+ const tgs = (tgResp?.TargetGroups || []).filter((t) =>
2927
+ (t.TargetGroupName || '').includes(shortId)
2928
+ );
2929
+ let targetHealth = null;
2930
+ if (tgs.length > 0 && tgs[0].TargetGroupArn) {
2931
+ const th = await elb.send(
2932
+ new DescribeTargetHealthCommand({
2933
+ TargetGroupArn: tgs[0].TargetGroupArn
2934
+ })
2935
+ );
2936
+ targetHealth = (th?.TargetHealthDescriptions || []).map((d) => ({
2937
+ id: d.Target?.Id,
2938
+ port: d.Target?.Port,
2939
+ state: d.TargetHealth?.State,
2940
+ reason: d.TargetHealth?.Reason
2941
+ }));
2942
+ }
2943
+
2944
+ const ecs = ecsClient(region, creds);
2945
+ const clusters = await ecs.send(new ListClustersCommand({}));
2946
+ const clusterArns = (clusters?.clusterArns || []).filter((a) =>
2947
+ /scaly-/.test(a)
2948
+ );
2949
+ let services = [];
2950
+ for (const cluster of clusterArns) {
2951
+ const ls = await ecs.send(new ListServicesCommand({ cluster }));
2952
+ const svcArns = (ls?.serviceArns || []).filter((a) =>
2953
+ a.includes(shortId)
2954
+ );
2955
+ if (svcArns.length) {
2956
+ const ds = await ecs.send(
2957
+ new DescribeServicesCommand({ cluster, services: svcArns })
2958
+ );
2959
+ for (const s of ds?.services || []) {
2960
+ const lt = await ecs.send(
2961
+ new ListTasksCommand({ cluster, serviceName: s.serviceName })
2962
+ );
2963
+ let tasks = [];
2964
+ if ((lt?.taskArns || []).length) {
2965
+ const dt = await ecs.send(
2966
+ new DescribeTasksCommand({ cluster, tasks: lt.taskArns })
2967
+ );
2968
+ tasks = (dt?.tasks || []).map((t) => ({
2969
+ taskArn: t.taskArn,
2970
+ lastStatus: t.lastStatus,
2971
+ desiredStatus: t.desiredStatus
2972
+ }));
2973
+ }
2974
+ services.push({
2975
+ cluster,
2976
+ serviceName: s.serviceName,
2977
+ desired: s.desiredCount,
2978
+ running: s.runningCount,
2979
+ tasks
2980
+ });
2981
+ }
2982
+ }
2983
+ }
2984
+ infra = { region, targetGroupCount: tgs.length, targetHealth, services };
2985
+ }
2986
+ } catch (e) {
2987
+ infra = { error: String((e && e.message) || e) };
2988
+ }
2989
+
2990
+ const ok = !!http;
2991
+ const summary = {
2992
+ ok,
2993
+ url,
2994
+ http: http || null,
2995
+ findings,
2996
+ sample: events.slice(-10),
2997
+ infra
2998
+ };
2999
+ if (f.json && String(f.json).toLowerCase() !== 'false') {
3000
+ console.log(JSON.stringify(summary));
3001
+ } else {
3002
+ console.log('DIAG_RESULT:', JSON.stringify(summary, null, 2));
3003
+ }
3004
+ return ok ? 0 : 1;
3005
+ }
3006
+
3007
+ // -------------------------
3008
+ // Auth: AWS via STS broker
3009
+ // -------------------------
3010
+ async function runAuthAws(rest) {
3011
+ const f = parseKv(rest);
3012
+ const regionArg = f.region || f.r;
3013
+ const scopesArg = f.scopes || f.s;
3014
+ const duration = Number(f.duration) || 1800;
3015
+ if (!regionArg || !scopesArg) {
3016
+ console.error(
3017
+ 'auth aws requires --region <eu-west-1|EU|US|...> and --scopes <logs:read,ecs:read,...>'
3018
+ );
3019
+ return 2;
3020
+ }
3021
+ await sourceLocalEnv();
3022
+ const { getToken, apiPost, regionToAws } = await importHelpers();
3023
+ const token = await getToken();
3024
+ const region = regionArg.includes('-')
3025
+ ? regionArg
3026
+ : regionToAws(regionArg) || regionArg;
3027
+ const scopes = String(scopesArg)
3028
+ .split(',')
3029
+ .map((s) =>
3030
+ s
3031
+ .trim()
3032
+ .toUpperCase()
3033
+ .replace(/[^A-Z_]/g, '_')
3034
+ )
3035
+ .filter(Boolean);
3036
+ const accountId = f.account || process.env.SCALY_ACCOUNT_ID || '';
3037
+ if (!accountId) {
3038
+ console.error(
3039
+ '--account <id> is recommended for proper scoping (or set SCALY_ACCOUNT_ID)'
3040
+ );
3041
+ }
3042
+ try {
3043
+ const res = await apiPost(token, ISSUE_AWS_CREDENTIALS_MUTATION, {
3044
+ input: {
3045
+ accountId: accountId || 'UNKNOWN',
3046
+ regionAws: region,
3047
+ scopes,
3048
+ durationSeconds: duration
3049
+ }
3050
+ });
3051
+ const c = res && res.issueAwsCredentials;
3052
+ if (!c || !c.accessKeyId)
3053
+ throw new Error(
3054
+ 'Broker did not return credentials (API not yet enabled?)'
3055
+ );
3056
+ if (f.export && String(f.export).toLowerCase() !== 'false') {
3057
+ console.log(
3058
+ `# region=${c.region} role=${c.assumedRoleArn} exp=${c.expiration}`
3059
+ );
3060
+ console.log(`export AWS_ACCESS_KEY_ID='${c.accessKeyId}'`);
3061
+ console.log(`export AWS_SECRET_ACCESS_KEY='${c.secretAccessKey}'`);
3062
+ console.log(`export AWS_SESSION_TOKEN='${c.sessionToken}'`);
3063
+ return 0;
3064
+ }
3065
+ // Default: JSON for programmatic/agent use
3066
+ console.log(JSON.stringify(c));
3067
+ return 0;
3068
+ } catch (e) {
3069
+ console.error(
3070
+ 'auth aws failed:',
3071
+ (e && e.response && e.response.data) || String(e)
3072
+ );
3073
+ // Provide an inline fallback template
3074
+ if (!(f.json && String(f.json).toLowerCase() !== 'false')) {
3075
+ console.log(
3076
+ `# Broker not available yet. Once enabled, rerun this command.`
3077
+ );
3078
+ }
3079
+ return 1;
3080
+ }
3081
+ }
3082
+
3083
+ main(process.argv);