@occam-scaly/scaly-cli 0.2.8 → 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 +236 -0
- package/lib/scaly-api.js +97 -0
- package/lib/scaly-doctor.js +211 -0
- package/lib/scaly-user-groups.js +97 -0
- package/package.json +1 -1
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
|
}
|
|
@@ -2610,6 +2627,225 @@ async function runSecrets(sub, rest) {
|
|
|
2610
2627
|
return 2;
|
|
2611
2628
|
}
|
|
2612
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
|
+
|
|
2613
2849
|
// -------------------------
|
|
2614
2850
|
// DB tunnel: localhost port-forward via WSS
|
|
2615
2851
|
// -------------------------
|
package/lib/scaly-api.js
CHANGED
|
@@ -307,6 +307,26 @@ const QUERIES = {
|
|
|
307
307
|
minIdle
|
|
308
308
|
}
|
|
309
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
|
+
}
|
|
310
330
|
`
|
|
311
331
|
};
|
|
312
332
|
|
|
@@ -335,6 +355,26 @@ const MUTATIONS = {
|
|
|
335
355
|
mutation CreateAddOn($data: AddOnCreateInput!) {
|
|
336
356
|
createAddOn(data: $data) { id name type accountId status }
|
|
337
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
|
+
}
|
|
338
378
|
`
|
|
339
379
|
};
|
|
340
380
|
|
|
@@ -384,6 +424,57 @@ async function listAddOnsByType({ accountId, type, take = 10 }) {
|
|
|
384
424
|
return data?.listAddOns || [];
|
|
385
425
|
}
|
|
386
426
|
|
|
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 }]
|
|
439
|
+
});
|
|
440
|
+
return (data?.createUserGroups || [])[0] || null;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
async function deleteUserGroup({ addonId, name }) {
|
|
444
|
+
const data = await graphqlRequest(MUTATIONS.deleteUserGroups, {
|
|
445
|
+
where: { id: addonId },
|
|
446
|
+
groups: [name]
|
|
447
|
+
});
|
|
448
|
+
return data?.deleteUserGroups || null;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
async function addUsersToGroup({ addonId, name, emails }) {
|
|
452
|
+
const data = await graphqlRequest(MUTATIONS.addUsersToGroups, {
|
|
453
|
+
where: { id: addonId },
|
|
454
|
+
emails,
|
|
455
|
+
groups: [name]
|
|
456
|
+
});
|
|
457
|
+
return data?.addUsersToGroups || null;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
async function removeUsersFromGroup({ addonId, name, emails }) {
|
|
461
|
+
const data = await graphqlRequest(MUTATIONS.removeUsersFromGroups, {
|
|
462
|
+
where: { id: addonId },
|
|
463
|
+
emails,
|
|
464
|
+
groups: [name]
|
|
465
|
+
});
|
|
466
|
+
return data?.removeUsersFromGroups || null;
|
|
467
|
+
}
|
|
468
|
+
|
|
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
|
|
474
|
+
});
|
|
475
|
+
return data?.listBucketObjects || null;
|
|
476
|
+
}
|
|
477
|
+
|
|
387
478
|
async function createStack({
|
|
388
479
|
accountId,
|
|
389
480
|
name,
|
|
@@ -503,6 +594,12 @@ module.exports = {
|
|
|
503
594
|
listAddOnsByType,
|
|
504
595
|
listStacks,
|
|
505
596
|
listApps,
|
|
597
|
+
listGroups,
|
|
598
|
+
createUserGroup,
|
|
599
|
+
deleteUserGroup,
|
|
600
|
+
addUsersToGroup,
|
|
601
|
+
removeUsersFromGroup,
|
|
602
|
+
listBucketObjects,
|
|
506
603
|
createStack,
|
|
507
604
|
updateStack,
|
|
508
605
|
createApp,
|
|
@@ -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
|
+
};
|