@occam-scaly/scaly-cli 0.2.7 → 0.2.9

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 CHANGED
@@ -68,6 +68,15 @@ Usage:
68
68
  scaly auth status [--json]
69
69
  scaly auth logout [--json]
70
70
 
71
+ scaly doctor [--json] [--env-file .env] [--require KEY1,KEY2] [--db-host 127.0.0.1] [--db-port 5432] [--storage-addon <id>] [--storage-prefix <prefix>]
72
+
73
+ scaly user-groups list [--account <accountId>] [--json]
74
+ scaly user-groups members --name <group> [--account <accountId>] [--json]
75
+ scaly user-groups create --name <group> [--description <text>] [--account <accountId>] [--json]
76
+ scaly user-groups delete --name <group> [--yes] [--account <accountId>] [--json]
77
+ scaly user-groups add --name <group> <email...> [--emails a,b] [--account <accountId>] [--json]
78
+ scaly user-groups remove --name <group> <email...> [--emails a,b] [--account <accountId>] [--json]
79
+
71
80
  scaly secrets list --app <appId> [--json]
72
81
  scaly secrets set --app <appId> --name <KEY> [--from-env ENV] [--stdin] [--json]
73
82
  scaly secrets delete --app <appId> (--name <KEY> | --id <secretId>) [--yes] [--json]
@@ -132,6 +141,14 @@ function main(argv) {
132
141
  return runAuth(sub, flags).then((code) => process.exit(code));
133
142
  }
134
143
 
144
+ if (cmd === 'doctor') {
145
+ return runDoctor(args.slice(1)).then((code) => process.exit(code));
146
+ }
147
+
148
+ if (cmd === 'user-groups') {
149
+ return runUserGroups(sub, rest).then((code) => process.exit(code));
150
+ }
151
+
135
152
  if (cmd === 'secrets') {
136
153
  return runSecrets(sub, rest).then((code) => process.exit(code));
137
154
  }
@@ -487,28 +504,14 @@ async function sourceLocalEnv() {
487
504
  }
488
505
 
489
506
  async function importHelpers() {
490
- const {
491
- SecretsManagerClient,
492
- GetSecretValueCommand
493
- } = require('@aws-sdk/client-secrets-manager');
494
- const {
495
- CloudWatchLogsClient,
496
- DescribeLogStreamsCommand
497
- } = require('@aws-sdk/client-cloudwatch-logs');
498
- const {
499
- ECSClient,
500
- ListClustersCommand,
501
- ListServicesCommand,
502
- DescribeServicesCommand,
503
- ListTasksCommand,
504
- DescribeTasksCommand
505
- } = require('@aws-sdk/client-ecs');
506
- const {
507
- ElasticLoadBalancingV2Client,
508
- DescribeTargetGroupsCommand,
509
- DescribeTargetHealthCommand
510
- } = require('@aws-sdk/client-elastic-load-balancing-v2');
511
507
  const axios = require('axios');
508
+ const { resolveApiEndpoint } = require('../lib/scaly-api');
509
+ const { normalizeStage, readAuthStore } = require('../lib/scaly-auth');
510
+
511
+ // Ensure API endpoint is set for common commands (db/secrets/etc).
512
+ if (!process.env.API_ENDPOINT && !process.env.SCALY_API_URL) {
513
+ process.env.API_ENDPOINT = resolveApiEndpoint();
514
+ }
512
515
 
513
516
  const API = () => process.env.API_ENDPOINT;
514
517
  const deriveDomain = () => {
@@ -528,12 +531,48 @@ async function importHelpers() {
528
531
  })[String(r || '').toUpperCase()] || null;
529
532
 
530
533
  async function getToken() {
531
- // Prefer stored bearer token or env override to avoid aws-vault
534
+ // Prefer stored bearer token or env override.
532
535
  const t = getStoredBearer();
533
536
  if (t) return t;
534
537
  if (process.env.SCALY_API_BEARER) return process.env.SCALY_API_BEARER;
535
- if (!process.env.COGNITO_SECRET) throw new Error('COGNITO_SECRET not set');
538
+
539
+ // Prefer a Scaly session token from `scaly auth login`.
540
+ if (process.env.SCALY_OIDC_TOKEN) return process.env.SCALY_OIDC_TOKEN;
541
+ const found = readAuthStore();
542
+ if (found && found.session && found.session.access_token) {
543
+ const s = found.session;
544
+ const expMs = typeof s.expires_at === 'number' ? s.expires_at : null;
545
+ const stage = normalizeStage(
546
+ s.stage || process.env.SCALY_STAGE || 'prod'
547
+ );
548
+ if (!process.env.SCALY_STAGE) process.env.SCALY_STAGE = stage;
549
+ if (expMs && Date.now() >= expMs) {
550
+ const e = new Error(
551
+ `Your Scaly session token expired. Run: scaly auth login --stage ${stage}`
552
+ );
553
+ e.code = 'TOKEN_EXPIRED';
554
+ throw e;
555
+ }
556
+ return s.access_token;
557
+ }
558
+
559
+ // Internal-only fallback (dev tooling): Cognito client credentials via AWS Secrets Manager.
560
+ if (!process.env.COGNITO_SECRET) {
561
+ const stage = normalizeStage(process.env.SCALY_STAGE || 'prod');
562
+ const e = new Error(
563
+ `Not authenticated. Run: scaly auth login --stage ${stage}`
564
+ );
565
+ e.code = 'SCALY_AUTH_REQUIRED';
566
+ throw e;
567
+ }
536
568
  const region = process.env.COGNITO_SECRET_REGION || 'ca-central-1';
569
+ console.error(
570
+ '[warn] Using COGNITO_SECRET flow (internal dev only). Prefer: scaly auth login'
571
+ );
572
+ const {
573
+ SecretsManagerClient,
574
+ GetSecretValueCommand
575
+ } = require('@aws-sdk/client-secrets-manager');
537
576
  const sm = new SecretsManagerClient({ region });
538
577
  const res = await sm.send(
539
578
  new GetSecretValueCommand({ SecretId: process.env.COGNITO_SECRET })
@@ -571,9 +610,13 @@ async function importHelpers() {
571
610
  }
572
611
 
573
612
  function elbClient(region, creds) {
613
+ const {
614
+ ElasticLoadBalancingV2Client
615
+ } = require('@aws-sdk/client-elastic-load-balancing-v2');
574
616
  return new ElasticLoadBalancingV2Client({ region, credentials: creds });
575
617
  }
576
618
  function ecsClient(region, creds) {
619
+ const { ECSClient } = require('@aws-sdk/client-ecs');
577
620
  return new ECSClient({ region, credentials: creds });
578
621
  }
579
622
 
@@ -599,6 +642,10 @@ async function importHelpers() {
599
642
 
600
643
  async function checkCloudwatchLogs(accountId, appId, region) {
601
644
  try {
645
+ const {
646
+ CloudWatchLogsClient,
647
+ DescribeLogStreamsCommand
648
+ } = require('@aws-sdk/client-cloudwatch-logs');
602
649
  const cwl = new CloudWatchLogsClient({ region });
603
650
  const group = `/scaly/account/${accountId}`;
604
651
  const cmd = new DescribeLogStreamsCommand({
@@ -2284,6 +2331,36 @@ async function runSecrets(sub, rest) {
2284
2331
  const json = parseBool(f.json, false);
2285
2332
 
2286
2333
  // OIDC-only for build secrets.
2334
+ try {
2335
+ const { resolveApiEndpoint } = require('../lib/scaly-api');
2336
+ const { normalizeStage, readAuthStore } = require('../lib/scaly-auth');
2337
+ if (!process.env.API_ENDPOINT && !process.env.SCALY_API_URL) {
2338
+ process.env.API_ENDPOINT = resolveApiEndpoint();
2339
+ }
2340
+
2341
+ const found = readAuthStore();
2342
+ if (
2343
+ found &&
2344
+ found.session &&
2345
+ found.session.access_token &&
2346
+ !process.env.SCALY_API_BEARER
2347
+ ) {
2348
+ const expMs =
2349
+ typeof found.session.expires_at === 'number'
2350
+ ? found.session.expires_at
2351
+ : null;
2352
+ const stage = normalizeStage(
2353
+ found.session.stage || process.env.SCALY_STAGE || 'prod'
2354
+ );
2355
+ if (!process.env.SCALY_STAGE) process.env.SCALY_STAGE = stage;
2356
+ if (expMs && Date.now() >= expMs) {
2357
+ // Fall through to error message below.
2358
+ } else {
2359
+ process.env.SCALY_API_BEARER = found.session.access_token;
2360
+ }
2361
+ }
2362
+ } catch {}
2363
+
2287
2364
  try {
2288
2365
  const t = getStoredBearer();
2289
2366
  if (t && !process.env.SCALY_API_BEARER) process.env.SCALY_API_BEARER = t;
@@ -2295,8 +2372,8 @@ async function runSecrets(sub, rest) {
2295
2372
  process.env.API_BEARER;
2296
2373
  if (!bearer || !isProbablyJwt(bearer)) {
2297
2374
  const msg =
2298
- 'secrets commands require a Cognito OIDC access token (JWT). ' +
2299
- 'Run `scaly login --token <access_token_jwt>` first.';
2375
+ 'secrets commands require a Scaly session token (JWT). ' +
2376
+ 'Run `scaly auth login` first.';
2300
2377
  if (json)
2301
2378
  console.log(JSON.stringify({ ok: false, error: { message: msg } }));
2302
2379
  else console.error(msg);
@@ -2550,6 +2627,225 @@ async function runSecrets(sub, rest) {
2550
2627
  return 2;
2551
2628
  }
2552
2629
 
2630
+ // -------------------------
2631
+ // User Groups (account-scoped)
2632
+ // -------------------------
2633
+ async function runUserGroups(sub, rest) {
2634
+ const action = String(sub || '')
2635
+ .trim()
2636
+ .toLowerCase();
2637
+ const f = parseKv(rest);
2638
+ const json = parseBool(f.json, false);
2639
+
2640
+ const accountId = await require('../lib/scaly-user-groups').resolveAccountId(
2641
+ f.account || f['account-id'] || f.account_id
2642
+ );
2643
+ if (!accountId) {
2644
+ const msg =
2645
+ 'Unable to infer account id. Pass --account <accountId> (or ensure you have accessible stacks).';
2646
+ if (json)
2647
+ console.log(JSON.stringify({ ok: false, error: { message: msg } }));
2648
+ else console.error(msg);
2649
+ return 2;
2650
+ }
2651
+
2652
+ const groups = require('../lib/scaly-user-groups');
2653
+
2654
+ try {
2655
+ if (!action || action === 'list') {
2656
+ const res = await groups.listGroups({ accountId });
2657
+ const out = {
2658
+ ok: true,
2659
+ account_id: accountId,
2660
+ user_groups: { addon_id: res.pool.id, name: res.pool.name },
2661
+ groups: (res.groups || []).map((g) => ({
2662
+ name: g.name,
2663
+ description: g.description || null
2664
+ }))
2665
+ };
2666
+ if (json) console.log(JSON.stringify(out));
2667
+ else out.groups.forEach((g) => console.log(g.name));
2668
+ return 0;
2669
+ }
2670
+
2671
+ if (action === 'members') {
2672
+ const name = f.name || (f._ && f._[0]);
2673
+ if (!name) {
2674
+ const msg = '--name <group> is required';
2675
+ if (json)
2676
+ console.log(JSON.stringify({ ok: false, error: { message: msg } }));
2677
+ else console.error(msg);
2678
+ return 2;
2679
+ }
2680
+ const res = await groups.listGroupMembers({ accountId, name });
2681
+ const out = {
2682
+ ok: true,
2683
+ account_id: accountId,
2684
+ group: { name, description: res.group.description || null },
2685
+ members: (res.members || []).map((u) => ({
2686
+ email: u.email,
2687
+ name: u.name,
2688
+ status: u.status || null,
2689
+ enabled: typeof u.enabled === 'boolean' ? u.enabled : null
2690
+ }))
2691
+ };
2692
+ if (json) console.log(JSON.stringify(out));
2693
+ else out.members.forEach((u) => console.log(u.email));
2694
+ return 0;
2695
+ }
2696
+
2697
+ if (action === 'create') {
2698
+ const name = f.name || (f._ && f._[0]);
2699
+ if (!name) {
2700
+ const msg = '--name <group> is required';
2701
+ if (json)
2702
+ console.log(JSON.stringify({ ok: false, error: { message: msg } }));
2703
+ else console.error(msg);
2704
+ return 2;
2705
+ }
2706
+ const res = await groups.createGroup({
2707
+ accountId,
2708
+ name,
2709
+ description: f.description || null
2710
+ });
2711
+ const out = {
2712
+ ok: true,
2713
+ account_id: accountId,
2714
+ user_groups: { addon_id: res.pool.id, name: res.pool.name },
2715
+ created: res.created
2716
+ };
2717
+ if (json) console.log(JSON.stringify(out));
2718
+ else console.log(`Created group: ${name}`);
2719
+ return 0;
2720
+ }
2721
+
2722
+ if (action === 'delete') {
2723
+ const name = f.name || (f._ && f._[0]);
2724
+ const yes = parseBool(f.yes, false);
2725
+ if (!name) {
2726
+ const msg = '--name <group> is required';
2727
+ if (json)
2728
+ console.log(JSON.stringify({ ok: false, error: { message: msg } }));
2729
+ else console.error(msg);
2730
+ return 2;
2731
+ }
2732
+ if (!yes) {
2733
+ const msg = 'Refusing to delete group without --yes';
2734
+ if (json)
2735
+ console.log(JSON.stringify({ ok: false, error: { message: msg } }));
2736
+ else console.error(msg);
2737
+ return 2;
2738
+ }
2739
+ const res = await groups.deleteGroup({ accountId, name });
2740
+ const out = {
2741
+ ok: true,
2742
+ account_id: accountId,
2743
+ deleted: true,
2744
+ count: res.result ? res.result.count : null
2745
+ };
2746
+ if (json) console.log(JSON.stringify(out));
2747
+ else console.log(`Deleted group: ${name}`);
2748
+ return 0;
2749
+ }
2750
+
2751
+ if (action === 'add' || action === 'remove') {
2752
+ const name = f.name || (f._ && f._[0]);
2753
+ if (!name) {
2754
+ const msg = '--name <group> is required';
2755
+ if (json)
2756
+ console.log(JSON.stringify({ ok: false, error: { message: msg } }));
2757
+ else console.error(msg);
2758
+ return 2;
2759
+ }
2760
+ const rawEmails = []
2761
+ .concat(f.emails ? String(f.emails).split(',') : [])
2762
+ .concat(f.email ? [String(f.email)] : [])
2763
+ .concat(Array.isArray(f._) ? f._.slice(1) : []);
2764
+ const emails = rawEmails.map((e) => String(e).trim()).filter(Boolean);
2765
+ if (!emails.length) {
2766
+ const msg =
2767
+ 'At least one email is required (positional args or --emails a,b)';
2768
+ if (json)
2769
+ console.log(JSON.stringify({ ok: false, error: { message: msg } }));
2770
+ else console.error(msg);
2771
+ return 2;
2772
+ }
2773
+ const res =
2774
+ action === 'add'
2775
+ ? await groups.addUsers({ accountId, name, emails })
2776
+ : await groups.removeUsers({ accountId, name, emails });
2777
+ const out = {
2778
+ ok: true,
2779
+ account_id: accountId,
2780
+ group: name,
2781
+ emails,
2782
+ count: res.result ? res.result.count : null
2783
+ };
2784
+ if (json) console.log(JSON.stringify(out));
2785
+ else
2786
+ console.log(
2787
+ `${action === 'add' ? 'Added' : 'Removed'} ${emails.length} user(s).`
2788
+ );
2789
+ return 0;
2790
+ }
2791
+
2792
+ const msg = `Unknown user-groups command: ${action}`;
2793
+ if (json)
2794
+ console.log(JSON.stringify({ ok: false, error: { message: msg } }));
2795
+ else console.error(msg);
2796
+ return 2;
2797
+ } catch (e) {
2798
+ const msg = String((e && e.message) || e);
2799
+ if (json)
2800
+ console.log(
2801
+ JSON.stringify({
2802
+ ok: false,
2803
+ error: { message: msg, code: e && e.code ? e.code : null }
2804
+ })
2805
+ );
2806
+ else console.error(msg);
2807
+ return 2;
2808
+ }
2809
+ }
2810
+
2811
+ // -------------------------
2812
+ // Doctor (local checklist)
2813
+ // -------------------------
2814
+ async function runDoctor(rest) {
2815
+ const f = parseKv(rest);
2816
+ const json = parseBool(f.json, false);
2817
+ const required =
2818
+ f.require && typeof f.require === 'string'
2819
+ ? f.require
2820
+ .split(',')
2821
+ .map((s) => s.trim())
2822
+ .filter(Boolean)
2823
+ : [];
2824
+
2825
+ const { runDoctor: doctor } = require('../lib/scaly-doctor');
2826
+ const res = await doctor({
2827
+ envFile: f['env-file'] || f.env_file || '.env',
2828
+ requiredEnvVars: required,
2829
+ dbHost: f['db-host'] || f.db_host || '127.0.0.1',
2830
+ dbPort:
2831
+ f['db-port'] !== undefined ? Number(f['db-port'] || f.db_port) : null,
2832
+ storageAddonId: f['storage-addon'] || f.storage_addon || null,
2833
+ storagePrefix: f['storage-prefix'] || f.storage_prefix || null
2834
+ });
2835
+
2836
+ if (json) {
2837
+ console.log(JSON.stringify(res));
2838
+ } else {
2839
+ for (const c of res.checks) {
2840
+ const status = c.ok ? 'OK' : 'FAIL';
2841
+ console.log(`[doctor] ${status} ${c.name}`);
2842
+ if (c.hint) console.log(` ${c.hint}`);
2843
+ }
2844
+ }
2845
+
2846
+ return res.ok ? 0 : 1;
2847
+ }
2848
+
2553
2849
  // -------------------------
2554
2850
  // DB tunnel: localhost port-forward via WSS
2555
2851
  // -------------------------
@@ -2566,7 +2862,7 @@ async function mintDbProxyInfo(rest) {
2566
2862
  const bearer = await getToken();
2567
2863
  if (!isProbablyJwt(bearer)) {
2568
2864
  const e = new Error(
2569
- 'db commands require a Cognito OIDC access token (JWT) in Authorization: Bearer.'
2865
+ 'db commands require a Scaly session token (JWT). Run `scaly auth login` first.'
2570
2866
  );
2571
2867
  e.code = 'SCALY_DB_JWT_REQUIRED';
2572
2868
  throw e;
@@ -3059,6 +3355,17 @@ async function runDiagnoseApp(rest) {
3059
3355
  });
3060
3356
  const c = credsRes?.issueAwsCredentials;
3061
3357
  if (c?.accessKeyId) {
3358
+ const {
3359
+ DescribeTargetGroupsCommand,
3360
+ DescribeTargetHealthCommand
3361
+ } = require('@aws-sdk/client-elastic-load-balancing-v2');
3362
+ const {
3363
+ ListClustersCommand,
3364
+ ListServicesCommand,
3365
+ DescribeServicesCommand,
3366
+ ListTasksCommand,
3367
+ DescribeTasksCommand
3368
+ } = require('@aws-sdk/client-ecs');
3062
3369
  const creds = makeCreds(c);
3063
3370
  // Target group heuristic: scaly-<first8-of-stackId>
3064
3371
  const shortId = String(stackId).split('-')[0];
package/lib/scaly-api.js CHANGED
@@ -140,7 +140,7 @@ function buildHeaders(auth) {
140
140
  };
141
141
  }
142
142
 
143
- async function graphqlRequest(query, variables) {
143
+ async function graphqlRequest(query, variables, extraHeaders) {
144
144
  const endpoint = resolveApiEndpoint();
145
145
  const auth = resolveAuth();
146
146
  if (!auth) {
@@ -156,7 +156,10 @@ async function graphqlRequest(query, variables) {
156
156
  {
157
157
  headers: {
158
158
  'content-type': 'application/json',
159
- ...buildHeaders(auth)
159
+ ...buildHeaders(auth),
160
+ ...(extraHeaders && typeof extraHeaders === 'object'
161
+ ? extraHeaders
162
+ : {})
160
163
  },
161
164
  timeout: 60_000
162
165
  }
@@ -304,6 +307,26 @@ const QUERIES = {
304
307
  minIdle
305
308
  }
306
309
  }
310
+ `,
311
+ listGroups: `
312
+ query ListGroups($where: AddOnWhereUniqueInput!) {
313
+ listGroups(where: $where) { name description }
314
+ }
315
+ `,
316
+ listGroupsWithUsers: `
317
+ query ListGroupsWithUsers($where: AddOnWhereUniqueInput!) {
318
+ listGroups(where: $where) { name description users { email name status enabled } }
319
+ }
320
+ `,
321
+ listBucketObjects: `
322
+ query ListBucketObjects($where: AddOnWhereUniqueInput!, $prefix: String, $pageSize: Int) {
323
+ listBucketObjects(where: $where, prefix: $prefix, pageSize: $pageSize) {
324
+ bucket
325
+ prefix
326
+ nextCursor
327
+ objects { key size lastModified }
328
+ }
329
+ }
307
330
  `
308
331
  };
309
332
 
@@ -332,6 +355,26 @@ const MUTATIONS = {
332
355
  mutation CreateAddOn($data: AddOnCreateInput!) {
333
356
  createAddOn(data: $data) { id name type accountId status }
334
357
  }
358
+ `,
359
+ createUserGroups: `
360
+ mutation CreateUserGroups($where: AddOnWhereUniqueInput!, $data: [CognitoUserGroupInput!]!) {
361
+ createUserGroups(where: $where, data: $data) { name description }
362
+ }
363
+ `,
364
+ deleteUserGroups: `
365
+ mutation DeleteUserGroups($where: AddOnWhereUniqueInput!, $groups: [String!]!) {
366
+ deleteUserGroups(where: $where, groups: $groups) { count }
367
+ }
368
+ `,
369
+ addUsersToGroups: `
370
+ mutation AddUsersToGroups($where: AddOnWhereUniqueInput!, $emails: [String!]!, $groups: [String!]!) {
371
+ addUsersToGroups(where: $where, emails: $emails, groups: $groups) { count }
372
+ }
373
+ `,
374
+ removeUsersFromGroups: `
375
+ mutation RemoveUsersFromGroups($where: AddOnWhereUniqueInput!, $emails: [String!]!, $groups: [String!]!) {
376
+ removeUsersFromGroups(where: $where, emails: $emails, groups: $groups) { count }
377
+ }
335
378
  `
336
379
  };
337
380
 
@@ -381,63 +424,162 @@ async function listAddOnsByType({ accountId, type, take = 10 }) {
381
424
  return data?.listAddOns || [];
382
425
  }
383
426
 
384
- async function createStack({ accountId, name, size, minIdle }) {
385
- const data = await graphqlRequest(MUTATIONS.createStack, {
386
- data: {
387
- name,
388
- size,
389
- minIdle: typeof minIdle === 'number' ? minIdle : undefined,
390
- account: { connect: { id: accountId } }
391
- }
427
+ async function listGroups({ addonId, includeUsers = false }) {
428
+ const data = await graphqlRequest(
429
+ includeUsers ? QUERIES.listGroupsWithUsers : QUERIES.listGroups,
430
+ { where: { id: addonId } }
431
+ );
432
+ return data?.listGroups || [];
433
+ }
434
+
435
+ async function createUserGroup({ addonId, name, description }) {
436
+ const data = await graphqlRequest(MUTATIONS.createUserGroups, {
437
+ where: { id: addonId },
438
+ data: [{ name, description: description || null }]
392
439
  });
393
- return data?.createStack;
440
+ return (data?.createUserGroups || [])[0] || null;
394
441
  }
395
442
 
396
- async function updateStack({ id, name, size, minIdle }) {
397
- const data = await graphqlRequest(MUTATIONS.updateStack, {
398
- where: { id },
399
- data: {
400
- name: name || undefined,
401
- size: size || undefined,
402
- minIdle: typeof minIdle === 'number' ? minIdle : undefined
403
- }
443
+ async function deleteUserGroup({ addonId, name }) {
444
+ const data = await graphqlRequest(MUTATIONS.deleteUserGroups, {
445
+ where: { id: addonId },
446
+ groups: [name]
404
447
  });
405
- return data?.updateStack;
448
+ return data?.deleteUserGroups || null;
406
449
  }
407
450
 
408
- async function createApp({ accountId, stackId, name, minIdle }) {
409
- const data = await graphqlRequest(MUTATIONS.createApp, {
410
- data: {
411
- name,
412
- minIdle: typeof minIdle === 'number' ? minIdle : undefined,
413
- account: { connect: { id: accountId } },
414
- stack: stackId ? { connect: { id: stackId } } : undefined
415
- }
451
+ async function addUsersToGroup({ addonId, name, emails }) {
452
+ const data = await graphqlRequest(MUTATIONS.addUsersToGroups, {
453
+ where: { id: addonId },
454
+ emails,
455
+ groups: [name]
416
456
  });
417
- return data?.createApp;
457
+ return data?.addUsersToGroups || null;
418
458
  }
419
459
 
420
- async function updateApp({ id, stackId, name, minIdle }) {
421
- const data = await graphqlRequest(MUTATIONS.updateApp, {
422
- where: { id },
423
- data: {
424
- name: name || undefined,
425
- minIdle: typeof minIdle === 'number' ? minIdle : undefined,
426
- stack: stackId ? { connect: { id: stackId } } : undefined
427
- }
460
+ async function removeUsersFromGroup({ addonId, name, emails }) {
461
+ const data = await graphqlRequest(MUTATIONS.removeUsersFromGroups, {
462
+ where: { id: addonId },
463
+ emails,
464
+ groups: [name]
428
465
  });
429
- return data?.updateApp;
466
+ return data?.removeUsersFromGroups || null;
430
467
  }
431
468
 
432
- async function createAddOn({ accountId, name, type, database }) {
433
- const data = await graphqlRequest(MUTATIONS.createAddOn, {
434
- data: {
435
- name,
436
- type,
437
- account: { connect: { id: accountId } },
438
- addOnDatabase: database ? { create: database } : undefined
439
- }
469
+ async function listBucketObjects({ addonId, prefix, pageSize = 1 }) {
470
+ const data = await graphqlRequest(QUERIES.listBucketObjects, {
471
+ where: { id: addonId },
472
+ prefix: prefix || null,
473
+ pageSize
440
474
  });
475
+ return data?.listBucketObjects || null;
476
+ }
477
+
478
+ async function createStack({
479
+ accountId,
480
+ name,
481
+ size,
482
+ minIdle,
483
+ executionGroupId
484
+ }) {
485
+ const data = await graphqlRequest(
486
+ MUTATIONS.createStack,
487
+ {
488
+ data: {
489
+ name,
490
+ size,
491
+ minIdle: typeof minIdle === 'number' ? minIdle : undefined,
492
+ account: { connect: { id: accountId } }
493
+ }
494
+ },
495
+ executionGroupId
496
+ ? { 'x-scaly-execution-group-id': executionGroupId }
497
+ : undefined
498
+ );
499
+ return data?.createStack;
500
+ }
501
+
502
+ async function updateStack({ id, name, size, minIdle, executionGroupId }) {
503
+ const data = await graphqlRequest(
504
+ MUTATIONS.updateStack,
505
+ {
506
+ where: { id },
507
+ data: {
508
+ name: name || undefined,
509
+ size: size || undefined,
510
+ minIdle: typeof minIdle === 'number' ? minIdle : undefined
511
+ }
512
+ },
513
+ executionGroupId
514
+ ? { 'x-scaly-execution-group-id': executionGroupId }
515
+ : undefined
516
+ );
517
+ return data?.updateStack;
518
+ }
519
+
520
+ async function createApp({
521
+ accountId,
522
+ stackId,
523
+ name,
524
+ minIdle,
525
+ executionGroupId
526
+ }) {
527
+ const data = await graphqlRequest(
528
+ MUTATIONS.createApp,
529
+ {
530
+ data: {
531
+ name,
532
+ minIdle: typeof minIdle === 'number' ? minIdle : undefined,
533
+ account: { connect: { id: accountId } },
534
+ stack: stackId ? { connect: { id: stackId } } : undefined
535
+ }
536
+ },
537
+ executionGroupId
538
+ ? { 'x-scaly-execution-group-id': executionGroupId }
539
+ : undefined
540
+ );
541
+ return data?.createApp;
542
+ }
543
+
544
+ async function updateApp({ id, stackId, name, minIdle, executionGroupId }) {
545
+ const data = await graphqlRequest(
546
+ MUTATIONS.updateApp,
547
+ {
548
+ where: { id },
549
+ data: {
550
+ name: name || undefined,
551
+ minIdle: typeof minIdle === 'number' ? minIdle : undefined,
552
+ stack: stackId ? { connect: { id: stackId } } : undefined
553
+ }
554
+ },
555
+ executionGroupId
556
+ ? { 'x-scaly-execution-group-id': executionGroupId }
557
+ : undefined
558
+ );
559
+ return data?.updateApp;
560
+ }
561
+
562
+ async function createAddOn({
563
+ accountId,
564
+ name,
565
+ type,
566
+ database,
567
+ executionGroupId
568
+ }) {
569
+ const data = await graphqlRequest(
570
+ MUTATIONS.createAddOn,
571
+ {
572
+ data: {
573
+ name,
574
+ type,
575
+ account: { connect: { id: accountId } },
576
+ addOnDatabase: database ? { create: database } : undefined
577
+ }
578
+ },
579
+ executionGroupId
580
+ ? { 'x-scaly-execution-group-id': executionGroupId }
581
+ : undefined
582
+ );
441
583
  return data?.createAddOn;
442
584
  }
443
585
 
@@ -452,6 +594,12 @@ module.exports = {
452
594
  listAddOnsByType,
453
595
  listStacks,
454
596
  listApps,
597
+ listGroups,
598
+ createUserGroup,
599
+ deleteUserGroup,
600
+ addUsersToGroup,
601
+ removeUsersFromGroup,
602
+ listBucketObjects,
455
603
  createStack,
456
604
  updateStack,
457
605
  createApp,
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const api = require('./scaly-api');
4
+ const { randomUUID } = require('crypto');
4
5
 
5
6
  async function applyPlan({ config, plan, autoApprove = false }) {
6
7
  const actionable = (plan.operations || []).filter(
@@ -8,6 +9,7 @@ async function applyPlan({ config, plan, autoApprove = false }) {
8
9
  );
9
10
 
10
11
  const results = [];
12
+ const executionGroupId = randomUUID();
11
13
 
12
14
  let createdStack = null;
13
15
  let stackId = null;
@@ -20,7 +22,8 @@ async function applyPlan({ config, plan, autoApprove = false }) {
20
22
  accountId: op.desired.accountId,
21
23
  name: op.desired.name,
22
24
  size: op.desired.size,
23
- minIdle: op.desired.minIdle
25
+ minIdle: op.desired.minIdle,
26
+ executionGroupId
24
27
  });
25
28
  createdStack = res;
26
29
  stackId = res?.id || stackId;
@@ -33,7 +36,8 @@ async function applyPlan({ config, plan, autoApprove = false }) {
33
36
  id: op.resource.id,
34
37
  name: op.desired.name,
35
38
  size: op.desired.size,
36
- minIdle: op.desired.minIdle
39
+ minIdle: op.desired.minIdle,
40
+ executionGroupId
37
41
  });
38
42
  stackId = res?.id || stackId;
39
43
  results.push({ op_id: op.id, ok: true, result: res });
@@ -48,7 +52,8 @@ async function applyPlan({ config, plan, autoApprove = false }) {
48
52
  const res = await api.createApp({
49
53
  accountId: op.desired.accountId,
50
54
  stackId,
51
- name: op.desired.name
55
+ name: op.desired.name,
56
+ executionGroupId
52
57
  });
53
58
  results.push({ op_id: op.id, ok: true, result: res });
54
59
  continue;
@@ -59,7 +64,8 @@ async function applyPlan({ config, plan, autoApprove = false }) {
59
64
  accountId: op.desired.accountId,
60
65
  name: op.desired.name,
61
66
  type: op.desired.type,
62
- database: op.desired.database || undefined
67
+ database: op.desired.database || undefined,
68
+ executionGroupId
63
69
  });
64
70
  results.push({ op_id: op.id, ok: true, result: res });
65
71
  continue;
@@ -76,6 +82,7 @@ async function applyPlan({ config, plan, autoApprove = false }) {
76
82
  return {
77
83
  ok,
78
84
  auto_approve: !!autoApprove,
85
+ execution_group_id: executionGroupId,
79
86
  applied: actionable.length,
80
87
  results,
81
88
  created_stack_id: createdStack?.id || null
@@ -0,0 +1,211 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const net = require('net');
5
+ const path = require('path');
6
+
7
+ const api = require('./scaly-api');
8
+ const { normalizeStage, readAuthStore } = require('./scaly-auth');
9
+
10
+ function parseEnvKeys(text) {
11
+ const out = new Set();
12
+ for (const rawLine of String(text || '').split('\n')) {
13
+ const line = rawLine.trim();
14
+ if (!line || line.startsWith('#')) continue;
15
+ const idx = line.indexOf('=');
16
+ if (idx <= 0) continue;
17
+ out.add(line.slice(0, idx).trim());
18
+ }
19
+ return out;
20
+ }
21
+
22
+ function tcpCheck(host, port, timeoutMs = 1000) {
23
+ return new Promise((resolve) => {
24
+ const socket = net.createConnection({ host, port });
25
+ const done = (ok) => {
26
+ try {
27
+ socket.destroy();
28
+ } catch {}
29
+ resolve(ok);
30
+ };
31
+ socket.setTimeout(timeoutMs);
32
+ socket.once('connect', () => done(true));
33
+ socket.once('timeout', () => done(false));
34
+ socket.once('error', () => done(false));
35
+ });
36
+ }
37
+
38
+ async function runDoctor({
39
+ envFile = '.env',
40
+ requiredEnvVars = [],
41
+ dbHost = '127.0.0.1',
42
+ dbPort = null,
43
+ storageAddonId = null,
44
+ storagePrefix = null
45
+ } = {}) {
46
+ const checks = [];
47
+
48
+ // Auth store / token
49
+ const found = readAuthStore();
50
+ const session = found && found.session ? found.session : null;
51
+ const stage = normalizeStage(
52
+ (session && session.stage) || process.env.SCALY_STAGE || 'prod'
53
+ );
54
+ const expMs =
55
+ session && typeof session.expires_at === 'number'
56
+ ? session.expires_at
57
+ : null;
58
+ const authenticated = Boolean(session && session.access_token);
59
+ const expired = Boolean(expMs && expMs <= Date.now());
60
+
61
+ checks.push({
62
+ name: 'auth',
63
+ ok: authenticated && !expired,
64
+ details: {
65
+ authenticated,
66
+ stage: session ? session.stage || stage : stage,
67
+ expires_in_seconds: expMs
68
+ ? Math.max(0, Math.floor((expMs - Date.now()) / 1000))
69
+ : null,
70
+ store_path: found ? found.path : null
71
+ },
72
+ hint: !authenticated
73
+ ? 'Run `scaly auth login`.'
74
+ : expired
75
+ ? `Run \`scaly auth login --stage ${stage}\`.`
76
+ : null
77
+ });
78
+
79
+ // API reachability
80
+ if (!process.env.SCALY_API_KEY) {
81
+ checks.push({
82
+ name: 'api',
83
+ ok: false,
84
+ details: { skipped: true },
85
+ hint: 'Set SCALY_API_KEY to verify API reachability.'
86
+ });
87
+ } else {
88
+ try {
89
+ const startedAt = Date.now();
90
+ await api.listStacks({ take: 1 });
91
+ checks.push({
92
+ name: 'api',
93
+ ok: true,
94
+ details: {
95
+ endpoint: api.resolveApiEndpoint(),
96
+ latency_ms: Date.now() - startedAt
97
+ }
98
+ });
99
+ } catch (e) {
100
+ checks.push({
101
+ name: 'api',
102
+ ok: false,
103
+ details: {
104
+ endpoint: api.resolveApiEndpoint(),
105
+ error: String((e && e.message) || e)
106
+ },
107
+ hint: 'Verify SCALY_API_KEY / SCALY_STAGE and confirm the API is reachable.'
108
+ });
109
+ }
110
+ }
111
+
112
+ // .env key checklist (optional)
113
+ if (Array.isArray(requiredEnvVars) && requiredEnvVars.length > 0) {
114
+ try {
115
+ const abs = path.resolve(envFile);
116
+ if (!fs.existsSync(abs)) {
117
+ checks.push({
118
+ name: 'env',
119
+ ok: false,
120
+ details: {
121
+ env_file: envFile,
122
+ missing_file: true,
123
+ required: requiredEnvVars
124
+ },
125
+ hint: `Create ${envFile} (or pass --env-file <path>).`
126
+ });
127
+ } else {
128
+ const keys = parseEnvKeys(fs.readFileSync(abs, 'utf8'));
129
+ const missing = requiredEnvVars.filter((k) => !keys.has(k));
130
+ checks.push({
131
+ name: 'env',
132
+ ok: missing.length === 0,
133
+ details: { env_file: envFile, required: requiredEnvVars, missing },
134
+ hint: missing.length
135
+ ? `Add missing keys to ${envFile} (values are not shown).`
136
+ : null
137
+ });
138
+ }
139
+ } catch (e) {
140
+ checks.push({
141
+ name: 'env',
142
+ ok: false,
143
+ details: { env_file: envFile, error: String((e && e.message) || e) }
144
+ });
145
+ }
146
+ }
147
+
148
+ // DB tunnel port check (optional)
149
+ if (typeof dbPort === 'number') {
150
+ const okTcp = await tcpCheck(dbHost, dbPort, 1000);
151
+ checks.push({
152
+ name: 'db_tunnel',
153
+ ok: okTcp,
154
+ details: { host: dbHost, port: dbPort },
155
+ hint: okTcp ? null : 'Start a DB tunnel (CLI or MCP) and retry.'
156
+ });
157
+ }
158
+
159
+ // Storage access check (optional)
160
+ if (storageAddonId) {
161
+ if (!process.env.SCALY_API_KEY) {
162
+ checks.push({
163
+ name: 'storage',
164
+ ok: false,
165
+ details: { addon_id: storageAddonId, skipped: true },
166
+ hint: 'Set SCALY_API_KEY and run `scaly auth login` to verify storage access.'
167
+ });
168
+ } else if (!authenticated || expired) {
169
+ checks.push({
170
+ name: 'storage',
171
+ ok: false,
172
+ details: { addon_id: storageAddonId, skipped: true },
173
+ hint: `Run \`scaly auth login --stage ${stage}\` to enable storage checks.`
174
+ });
175
+ } else {
176
+ try {
177
+ const startedAt = Date.now();
178
+ const res = await api.listBucketObjects({
179
+ addonId: storageAddonId,
180
+ prefix: storagePrefix,
181
+ pageSize: 1
182
+ });
183
+ checks.push({
184
+ name: 'storage',
185
+ ok: true,
186
+ details: {
187
+ addon_id: storageAddonId,
188
+ bucket: res ? res.bucket : null,
189
+ prefix: storagePrefix,
190
+ latency_ms: Date.now() - startedAt
191
+ }
192
+ });
193
+ } catch (e) {
194
+ checks.push({
195
+ name: 'storage',
196
+ ok: false,
197
+ details: {
198
+ addon_id: storageAddonId,
199
+ error: String((e && e.message) || e)
200
+ },
201
+ hint: 'Verify storage add-on id and that your session token is valid.'
202
+ });
203
+ }
204
+ }
205
+ }
206
+
207
+ const ok = checks.every((c) => c.ok !== false);
208
+ return { ok, checks };
209
+ }
210
+
211
+ module.exports = { runDoctor };
@@ -0,0 +1,97 @@
1
+ 'use strict';
2
+
3
+ const api = require('./scaly-api');
4
+
5
+ async function resolveAccountId(explicit) {
6
+ if (explicit) return explicit;
7
+ const stacks = await api.listStacks({ take: 1 });
8
+ return stacks && stacks[0] ? stacks[0].accountId : null;
9
+ }
10
+
11
+ async function getAccountUserGroupsAddOn(accountId) {
12
+ const pools = await api.listAddOnsByType({
13
+ accountId,
14
+ type: 'COGNITO',
15
+ take: 5
16
+ });
17
+ if (!pools || pools.length === 0) {
18
+ const e = new Error(
19
+ 'User Groups is not provisioned for this account. Contact support to repair this account.'
20
+ );
21
+ e.code = 'COGNITO_POOL_MISSING';
22
+ throw e;
23
+ }
24
+ if (pools.length > 1) {
25
+ const e = new Error(
26
+ 'Multiple User Groups pools exist in this account. Contact support to repair this account before continuing.'
27
+ );
28
+ e.code = 'COGNITO_POOL_DUPLICATE';
29
+ e.details = { pools };
30
+ throw e;
31
+ }
32
+ return pools[0];
33
+ }
34
+
35
+ async function listGroups({ accountId }) {
36
+ const pool = await getAccountUserGroupsAddOn(accountId);
37
+ const groups = await api.listGroups({
38
+ addonId: pool.id,
39
+ includeUsers: false
40
+ });
41
+ return { pool, groups };
42
+ }
43
+
44
+ async function listGroupMembers({ accountId, name }) {
45
+ const pool = await getAccountUserGroupsAddOn(accountId);
46
+ const groups = await api.listGroups({ addonId: pool.id, includeUsers: true });
47
+ const g = (groups || []).find((x) => x && x.name === name) || null;
48
+ if (!g) {
49
+ const e = new Error(`Group not found: ${name}`);
50
+ e.code = 'GROUP_NOT_FOUND';
51
+ throw e;
52
+ }
53
+ return { pool, group: g, members: g.users || [] };
54
+ }
55
+
56
+ async function createGroup({ accountId, name, description }) {
57
+ const pool = await getAccountUserGroupsAddOn(accountId);
58
+ const created = await api.createUserGroup({
59
+ addonId: pool.id,
60
+ name,
61
+ description
62
+ });
63
+ return { pool, created };
64
+ }
65
+
66
+ async function deleteGroup({ accountId, name }) {
67
+ const pool = await getAccountUserGroupsAddOn(accountId);
68
+ const res = await api.deleteUserGroup({ addonId: pool.id, name });
69
+ return { pool, result: res };
70
+ }
71
+
72
+ async function addUsers({ accountId, name, emails }) {
73
+ const pool = await getAccountUserGroupsAddOn(accountId);
74
+ const res = await api.addUsersToGroup({ addonId: pool.id, name, emails });
75
+ return { pool, result: res };
76
+ }
77
+
78
+ async function removeUsers({ accountId, name, emails }) {
79
+ const pool = await getAccountUserGroupsAddOn(accountId);
80
+ const res = await api.removeUsersFromGroup({
81
+ addonId: pool.id,
82
+ name,
83
+ emails
84
+ });
85
+ return { pool, result: res };
86
+ }
87
+
88
+ module.exports = {
89
+ resolveAccountId,
90
+ getAccountUserGroupsAddOn,
91
+ listGroups,
92
+ listGroupMembers,
93
+ createGroup,
94
+ deleteGroup,
95
+ addUsers,
96
+ removeUsers
97
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@occam-scaly/scaly-cli",
3
- "version": "0.2.7",
3
+ "version": "0.2.9",
4
4
  "description": "Scaly CLI (auth + project config helpers)",
5
5
  "bin": {
6
6
  "scaly": "./bin/scaly.js"