@atlasauth/cli 0.18.1 → 0.20.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/CHANGELOG.md +29 -0
- package/README.md +9 -6
- package/dist/args.d.ts +6 -0
- package/dist/args.js +3 -0
- package/dist/args.js.map +1 -1
- package/dist/commands/config.d.ts +1 -0
- package/dist/commands/config.js +308 -31
- package/dist/commands/config.js.map +1 -1
- package/dist/commands/referrals.d.ts +5 -0
- package/dist/commands/referrals.js +27 -2
- package/dist/commands/referrals.js.map +1 -1
- package/dist/help.js +21 -11
- package/dist/help.js.map +1 -1
- package/package.json +3 -3
package/dist/commands/config.js
CHANGED
|
@@ -38,12 +38,15 @@ function pick(row, keys) {
|
|
|
38
38
|
}
|
|
39
39
|
return out;
|
|
40
40
|
}
|
|
41
|
-
/**
|
|
42
|
-
|
|
41
|
+
/**
|
|
42
|
+
* The write body: strip null/undefined. On a PATCH (`keepNull`) an explicit file
|
|
43
|
+
* `null` is kept so it clears a nullable field; an absent key stays unmanaged.
|
|
44
|
+
*/
|
|
45
|
+
function compact(config, keepNull = false) {
|
|
43
46
|
const out = {};
|
|
44
47
|
if (config && typeof config === 'object' && !Array.isArray(config)) {
|
|
45
48
|
for (const [k, v] of Object.entries(config)) {
|
|
46
|
-
if (v ===
|
|
49
|
+
if (v === undefined || (v === null && !keepNull))
|
|
47
50
|
continue;
|
|
48
51
|
out[k] = v;
|
|
49
52
|
}
|
|
@@ -64,26 +67,83 @@ function enc(segment) {
|
|
|
64
67
|
*/
|
|
65
68
|
function registry() {
|
|
66
69
|
const specs = [
|
|
70
|
+
// 0. Email provider — first, because email sign-in strategies need one. Secrets
|
|
71
|
+
// (api_key, …) are write-only: the live side shows "(secret set)", never a value.
|
|
72
|
+
{
|
|
73
|
+
kind: 'singleton',
|
|
74
|
+
type: 'email_provider',
|
|
75
|
+
async fetchLive(http) {
|
|
76
|
+
let p;
|
|
77
|
+
try {
|
|
78
|
+
p = await http.get('/v1/messaging_providers/email');
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
if ((0, config_http_1.isNotFound)(err))
|
|
82
|
+
return null;
|
|
83
|
+
throw err;
|
|
84
|
+
}
|
|
85
|
+
if (!p || p.configured === false)
|
|
86
|
+
return null;
|
|
87
|
+
const config = { ...obj((p.config ?? null)) };
|
|
88
|
+
const set = obj((p.secrets_set ?? null));
|
|
89
|
+
const names = Array.isArray(p.secrets_set) ? p.secrets_set : Object.keys(set).filter((k) => set[k] === true);
|
|
90
|
+
for (const k of names)
|
|
91
|
+
config[k] = SECRET_SET;
|
|
92
|
+
return pick({ ...p, config }, ['transport', 'from_address', 'config']);
|
|
93
|
+
},
|
|
94
|
+
async apply(http, config) {
|
|
95
|
+
await http.put('/v1/messaging_providers/email', compact(withoutMarkers(config)));
|
|
96
|
+
},
|
|
97
|
+
normalize: (c) => redactSecrets(c, ['config']),
|
|
98
|
+
},
|
|
67
99
|
// 1. Instance config (auth_config + allowed origins) — the §9.3 singleton.
|
|
68
100
|
{
|
|
69
101
|
kind: 'singleton',
|
|
70
102
|
type: 'instance',
|
|
71
103
|
async fetchLive(http) {
|
|
72
104
|
const inst = await http.get('/v1/instance');
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
};
|
|
105
|
+
const hints = obj(inst.allowed_origin_integrations ?? null);
|
|
106
|
+
const origins = originStrings(inst.allowed_origins ?? []).map((o) => (hints[o] ? { origin: o, integration: hints[o] } : o));
|
|
107
|
+
return { allowed_origins: origins, auth_config: stripOriginIntegrations(inst.auth_config ?? {}) };
|
|
77
108
|
},
|
|
78
109
|
async apply(http, config) {
|
|
79
110
|
const c = compact(config);
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
111
|
+
// Integration hints live in auth_config.originIntegrations server-side and a
|
|
112
|
+
// write replaces auth_config, so carry the live hints over for any origin the
|
|
113
|
+
// file names as a plain string (the plan ignores hints the file does not set).
|
|
114
|
+
const live = await http.get('/v1/instance');
|
|
115
|
+
const liveHints = obj(live.allowed_origin_integrations ?? null);
|
|
116
|
+
const authConfig = stripOriginIntegrations(c.auth_config ?? {});
|
|
117
|
+
const body = { auth_config: authConfig };
|
|
118
|
+
if (c.allowed_origins !== undefined) {
|
|
119
|
+
// A file without allowed_origins leaves the live origins alone (a partial
|
|
120
|
+
// document, e.g. one produced by `atlas migrate better-auth config`).
|
|
121
|
+
body.allowed_origins = (Array.isArray(c.allowed_origins) ? c.allowed_origins : []).map((e) => typeof e === 'string' && liveHints[e] ? { origin: e, integration: liveHints[e] } : e);
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
const kept = obj(live.auth_config ?? null).originIntegrations;
|
|
125
|
+
if (kept !== undefined)
|
|
126
|
+
authConfig.originIntegrations = kept;
|
|
127
|
+
}
|
|
85
128
|
await http.patch('/v1/instance', body);
|
|
86
129
|
},
|
|
130
|
+
normalize(c) {
|
|
131
|
+
const d = obj(c);
|
|
132
|
+
const hints = {};
|
|
133
|
+
for (const e of Array.isArray(d.allowed_origins) ? d.allowed_origins : []) {
|
|
134
|
+
const o = obj(e);
|
|
135
|
+
if (o.integration)
|
|
136
|
+
hints[String(o.origin)] = o.integration;
|
|
137
|
+
}
|
|
138
|
+
const out = {};
|
|
139
|
+
if (d.allowed_origins !== undefined)
|
|
140
|
+
out.allowed_origins = originStrings(d.allowed_origins);
|
|
141
|
+
if (Object.keys(hints).length)
|
|
142
|
+
out.allowed_origin_integrations = hints;
|
|
143
|
+
if (d.auth_config !== undefined)
|
|
144
|
+
out.auth_config = stripOriginIntegrations(d.auth_config);
|
|
145
|
+
return out;
|
|
146
|
+
},
|
|
87
147
|
},
|
|
88
148
|
// 2. Org settings schema (P1-5) — a singleton JSON Schema; 404 when unset.
|
|
89
149
|
{
|
|
@@ -138,12 +198,52 @@ function registry() {
|
|
|
138
198
|
await http.post('/v1/oauth_clients', compact(config));
|
|
139
199
|
},
|
|
140
200
|
async update(http, live, config) {
|
|
141
|
-
await http.patch(`/v1/oauth_clients/${enc(live.id)}`, compact(config));
|
|
201
|
+
await http.patch(`/v1/oauth_clients/${enc(live.id)}`, compact(config, true));
|
|
142
202
|
},
|
|
143
203
|
async remove(http, live) {
|
|
144
204
|
await http.delete(`/v1/oauth_clients/${enc(live.id)}`);
|
|
145
205
|
},
|
|
146
206
|
},
|
|
207
|
+
// 4b. Hooks — after the clients. before_user_create: url, timeout_ms, fail_closed,
|
|
208
|
+
// enabled; the signing secret is write-only ("(secret set)").
|
|
209
|
+
{
|
|
210
|
+
kind: 'singleton',
|
|
211
|
+
type: 'hooks',
|
|
212
|
+
async fetchLive(http) {
|
|
213
|
+
let h;
|
|
214
|
+
try {
|
|
215
|
+
h = await http.get('/v1/instance/hooks/before_user_create');
|
|
216
|
+
}
|
|
217
|
+
catch (err) {
|
|
218
|
+
if ((0, config_http_1.isNotFound)(err))
|
|
219
|
+
return null;
|
|
220
|
+
throw err;
|
|
221
|
+
}
|
|
222
|
+
if (!h || (h.url == null && h.enabled !== true))
|
|
223
|
+
return null;
|
|
224
|
+
const bu = pick(h, ['url', 'timeout_ms', 'fail_closed', 'enabled']);
|
|
225
|
+
if (h.signing_secret_set === true)
|
|
226
|
+
bu.signing_secret = SECRET_SET;
|
|
227
|
+
return { before_user_create: bu };
|
|
228
|
+
},
|
|
229
|
+
async apply(http, config, opts) {
|
|
230
|
+
const bu = compact(obj(withoutMarkers(config)).before_user_create ?? {}, true);
|
|
231
|
+
if (Object.keys(bu).length === 0)
|
|
232
|
+
return;
|
|
233
|
+
try {
|
|
234
|
+
await http.patch(`/v1/instance/hooks/before_user_create${opts.forceHook ? '?force=true' : ''}`, bu);
|
|
235
|
+
}
|
|
236
|
+
catch (err) {
|
|
237
|
+
const refused = err instanceof backend_1.AtlasApiError && err.status === 409 ? err.errors.find((e) => e.code === 'HOOK_TEST_FAILED') : undefined;
|
|
238
|
+
if (refused) {
|
|
239
|
+
throw new errors_1.CliError(`hooks.before_user_create: the change was refused because the hook test call failed (HOOK_TEST_FAILED): ${refused.message} ` +
|
|
240
|
+
'The hook was left as it was. Deploy the route, or pass --force-hook to apply it anyway.');
|
|
241
|
+
}
|
|
242
|
+
throw err;
|
|
243
|
+
}
|
|
244
|
+
},
|
|
245
|
+
normalize: (c) => redactSecrets(c, ['before_user_create']),
|
|
246
|
+
},
|
|
147
247
|
// 5. Social / enterprise OAuth providers — key = the catalog provider slug.
|
|
148
248
|
{
|
|
149
249
|
kind: 'collection',
|
|
@@ -182,7 +282,7 @@ function registry() {
|
|
|
182
282
|
await http.post('/v1/custom_oauth_providers', compact(config));
|
|
183
283
|
},
|
|
184
284
|
async update(http, live, config) {
|
|
185
|
-
const body = compact(config);
|
|
285
|
+
const body = compact(config, true);
|
|
186
286
|
delete body.provider_key; // immutable
|
|
187
287
|
await http.patch(`/v1/custom_oauth_providers/${enc(live.id)}`, body);
|
|
188
288
|
},
|
|
@@ -209,7 +309,7 @@ function registry() {
|
|
|
209
309
|
await http.post('/v1/resource_servers', body);
|
|
210
310
|
},
|
|
211
311
|
async update(http, live, config) {
|
|
212
|
-
const body = compact(config);
|
|
312
|
+
const body = compact(config, true);
|
|
213
313
|
delete body.identifier; // immutable
|
|
214
314
|
delete body.signing_alg; // read-only
|
|
215
315
|
await http.patch(`/v1/resource_servers/${enc(live.id)}`, body);
|
|
@@ -314,10 +414,12 @@ function registry() {
|
|
|
314
414
|
await http.post('/v1/webhook_endpoints', {
|
|
315
415
|
url: c.url,
|
|
316
416
|
enabled_events: c.enabled_events ?? [],
|
|
417
|
+
// Sent on create so a paused endpoint is never live, even briefly.
|
|
418
|
+
...(typeof c.active === 'boolean' ? { active: c.active } : {}),
|
|
317
419
|
});
|
|
318
420
|
},
|
|
319
421
|
async update(http, live, config) {
|
|
320
|
-
await http.patch(`/v1/webhook_endpoints/${enc(live.id)}`, compact(config));
|
|
422
|
+
await http.patch(`/v1/webhook_endpoints/${enc(live.id)}`, compact(config, true));
|
|
321
423
|
},
|
|
322
424
|
async remove(http, live) {
|
|
323
425
|
await http.delete(`/v1/webhook_endpoints/${enc(live.id)}`);
|
|
@@ -339,7 +441,7 @@ function registry() {
|
|
|
339
441
|
await http.post('/v1/sso_connections', compact(config));
|
|
340
442
|
},
|
|
341
443
|
async update(http, live, config) {
|
|
342
|
-
const body = compact(config);
|
|
444
|
+
const body = compact(config, true);
|
|
343
445
|
delete body.type; // fixed at creation; PATCH does not take it
|
|
344
446
|
await http.patch(`/v1/sso_connections/${enc(live.id)}`, body);
|
|
345
447
|
},
|
|
@@ -347,6 +449,37 @@ function registry() {
|
|
|
347
449
|
await http.delete(`/v1/sso_connections/${enc(live.id)}`);
|
|
348
450
|
},
|
|
349
451
|
},
|
|
452
|
+
// 10b. SSO onboarding profiles (hosted setup links) — key = name. After the SSO
|
|
453
|
+
// connections; a profile may name an organization that already exists.
|
|
454
|
+
{
|
|
455
|
+
kind: 'collection',
|
|
456
|
+
type: 'sso_onboarding_profiles',
|
|
457
|
+
keyOf: (c) => String(obj(c).name ?? ''),
|
|
458
|
+
async fetchLive(http) {
|
|
459
|
+
let rows;
|
|
460
|
+
try {
|
|
461
|
+
rows = await listData(http, '/v1/sso_onboarding_profiles');
|
|
462
|
+
}
|
|
463
|
+
catch (err) {
|
|
464
|
+
if ((0, config_http_1.isNotFound)(err))
|
|
465
|
+
return [];
|
|
466
|
+
throw err;
|
|
467
|
+
}
|
|
468
|
+
return rows.map((r) => {
|
|
469
|
+
const config = pick(r, SSO_PROFILE_KEYS);
|
|
470
|
+
return { key: String(r.name ?? ''), id: String(r.id ?? ''), config };
|
|
471
|
+
});
|
|
472
|
+
},
|
|
473
|
+
async create(http, config) {
|
|
474
|
+
await http.post('/v1/sso_onboarding_profiles', compact(config));
|
|
475
|
+
},
|
|
476
|
+
async update(http, live, config) {
|
|
477
|
+
await http.patch(`/v1/sso_onboarding_profiles/${enc(live.id)}`, compact(config, true));
|
|
478
|
+
},
|
|
479
|
+
async remove(http, live) {
|
|
480
|
+
await http.delete(`/v1/sso_onboarding_profiles/${enc(live.id)}`);
|
|
481
|
+
},
|
|
482
|
+
},
|
|
350
483
|
// 11. Billing plans — key = slug.
|
|
351
484
|
{
|
|
352
485
|
kind: 'collection',
|
|
@@ -364,7 +497,7 @@ function registry() {
|
|
|
364
497
|
await http.post('/v1/billing/plans', compact(config));
|
|
365
498
|
},
|
|
366
499
|
async update(http, live, config) {
|
|
367
|
-
const body = compact(config);
|
|
500
|
+
const body = compact(config, true);
|
|
368
501
|
delete body.slug; // immutable
|
|
369
502
|
await http.patch(`/v1/billing/plans/${enc(live.id)}`, body);
|
|
370
503
|
},
|
|
@@ -389,7 +522,7 @@ function registry() {
|
|
|
389
522
|
await http.post('/v1/notification_templates', compact(config));
|
|
390
523
|
},
|
|
391
524
|
async update(http, live, config) {
|
|
392
|
-
const body = compact(config);
|
|
525
|
+
const body = compact(config, true);
|
|
393
526
|
delete body.name; // the name is the URL key
|
|
394
527
|
await http.put(`/v1/notification_templates/${enc(live.id)}`, body);
|
|
395
528
|
},
|
|
@@ -451,6 +584,55 @@ function registry() {
|
|
|
451
584
|
// No delete: pruning a policy would silently loosen an org's security, so
|
|
452
585
|
// org policies are never deleted by import (reported as skipped).
|
|
453
586
|
},
|
|
587
|
+
// 12. Branding — the hosted-page appearance; a partial merge on write (PATCH).
|
|
588
|
+
{
|
|
589
|
+
kind: 'singleton',
|
|
590
|
+
type: 'branding',
|
|
591
|
+
async fetchLive(http) {
|
|
592
|
+
let b;
|
|
593
|
+
try {
|
|
594
|
+
b = await http.get('/v1/branding');
|
|
595
|
+
}
|
|
596
|
+
catch (err) {
|
|
597
|
+
if ((0, config_http_1.isNotFound)(err))
|
|
598
|
+
return null;
|
|
599
|
+
throw err;
|
|
600
|
+
}
|
|
601
|
+
const { object: _o, resolved: _r, ...fields } = b ?? {};
|
|
602
|
+
const out = compact(fields);
|
|
603
|
+
return Object.keys(out).length ? out : null;
|
|
604
|
+
},
|
|
605
|
+
async apply(http, config) {
|
|
606
|
+
await http.patch('/v1/branding', compact(config, true));
|
|
607
|
+
},
|
|
608
|
+
},
|
|
609
|
+
// 13. Cookie-consent banner — last: it only needs the instance. PUT replaces the
|
|
610
|
+
// whole setting and needs script_url, so apply merges the file over the live one.
|
|
611
|
+
{
|
|
612
|
+
kind: 'singleton',
|
|
613
|
+
type: 'cookie_consent',
|
|
614
|
+
async fetchLive(http) {
|
|
615
|
+
let cc;
|
|
616
|
+
try {
|
|
617
|
+
cc = await http.get('/v1/instance/cookie_consent');
|
|
618
|
+
}
|
|
619
|
+
catch (err) {
|
|
620
|
+
if ((0, config_http_1.isNotFound)(err))
|
|
621
|
+
return null;
|
|
622
|
+
throw err;
|
|
623
|
+
}
|
|
624
|
+
const out = pick(cc ?? {}, COOKIE_CONSENT_KEYS);
|
|
625
|
+
return Object.keys(out).length ? out : null;
|
|
626
|
+
},
|
|
627
|
+
async apply(http, config) {
|
|
628
|
+
const live = await http.get('/v1/instance/cookie_consent');
|
|
629
|
+
const body = { ...pick(live ?? {}, COOKIE_CONSENT_KEYS), ...compact(config, true) };
|
|
630
|
+
if (body.script_url === undefined || body.script_url === null) {
|
|
631
|
+
throw new errors_1.CliError('cookie_consent needs a script_url (the consent manager script) when none is set on the instance.');
|
|
632
|
+
}
|
|
633
|
+
await http.put('/v1/instance/cookie_consent', body);
|
|
634
|
+
},
|
|
635
|
+
},
|
|
454
636
|
];
|
|
455
637
|
return specs;
|
|
456
638
|
}
|
|
@@ -462,6 +644,22 @@ const OAUTH_CLIENT_KEYS = [
|
|
|
462
644
|
'token_endpoint_auth_method',
|
|
463
645
|
'logo_url',
|
|
464
646
|
'first_party',
|
|
647
|
+
'post_logout_redirect_uris',
|
|
648
|
+
'claims_redirect_uris',
|
|
649
|
+
'backchannel_logout_uri',
|
|
650
|
+
'backchannel_logout_session_required',
|
|
651
|
+
'frontchannel_logout_uri',
|
|
652
|
+
'frontchannel_logout_session_required',
|
|
653
|
+
'skip_logout_confirmation',
|
|
654
|
+
'refresh_touches_session',
|
|
655
|
+
'require_verified_email',
|
|
656
|
+
'require_pkce',
|
|
657
|
+
'login_url',
|
|
658
|
+
'consent_url',
|
|
659
|
+
'subject_type',
|
|
660
|
+
'sector_identifier_uri',
|
|
661
|
+
'access_token_format',
|
|
662
|
+
'token_lifetimes',
|
|
465
663
|
];
|
|
466
664
|
const OAUTH_PROVIDER_KEYS = [
|
|
467
665
|
'provider',
|
|
@@ -521,6 +719,8 @@ const SSO_KEYS = [
|
|
|
521
719
|
'claim_role_mappings',
|
|
522
720
|
'default_role_id',
|
|
523
721
|
];
|
|
722
|
+
const SSO_PROFILE_KEYS = ['name', 'allowed_connection_types', 'organization_id', 'company_name', 'allow_scim', 'intents', 'return_url'];
|
|
723
|
+
const COOKIE_CONSENT_KEYS = ['enabled', 'script_url', 'script_id', 'site_id', 'blocking_mode', 'extra_origins', 'settings_link', 'culture', 'host_aliases'];
|
|
524
724
|
const BILLING_KEYS = [
|
|
525
725
|
'name',
|
|
526
726
|
'slug',
|
|
@@ -557,6 +757,40 @@ function obj(config) {
|
|
|
557
757
|
? config
|
|
558
758
|
: {};
|
|
559
759
|
}
|
|
760
|
+
/** What a write-only secret looks like on the live side, and in a plan. */
|
|
761
|
+
const SECRET_SET = '(secret set)';
|
|
762
|
+
const SECRET_KEY = /(secret|token|password|passwd|api_?key|private_?key)/i;
|
|
763
|
+
/**
|
|
764
|
+
* Show each secret value under `groups` as "(secret set)" so a plan never echoes it
|
|
765
|
+
* and compares equal to the live "(secret set)". An unresolved \${VAR} stays visible
|
|
766
|
+
* (apply refuses it), as does an empty value.
|
|
767
|
+
*/
|
|
768
|
+
function redactSecrets(config, groups) {
|
|
769
|
+
const out = { ...obj(config) };
|
|
770
|
+
const mask = (g) => Object.fromEntries(Object.entries(g).map(([k, v]) => [k, SECRET_KEY.test(k) && typeof v === 'string' && v !== '' && !/^\$\{[A-Z0-9_]+\}$/.test(v) ? SECRET_SET : v]));
|
|
771
|
+
for (const g of groups)
|
|
772
|
+
if (out[g] && typeof out[g] === 'object' && !Array.isArray(out[g]))
|
|
773
|
+
out[g] = mask(obj(out[g]));
|
|
774
|
+
return out;
|
|
775
|
+
}
|
|
776
|
+
/** A document without any "(secret set)" marker: what an export writes and what apply sends. */
|
|
777
|
+
function withoutMarkers(value) {
|
|
778
|
+
if (Array.isArray(value))
|
|
779
|
+
return value.map(withoutMarkers);
|
|
780
|
+
if (value && typeof value === 'object') {
|
|
781
|
+
return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== SECRET_SET).map(([k, v]) => [k, withoutMarkers(v)]));
|
|
782
|
+
}
|
|
783
|
+
return value;
|
|
784
|
+
}
|
|
785
|
+
/** An origin list in either form (strings, or { origin, integration }) as plain strings. */
|
|
786
|
+
function originStrings(list) {
|
|
787
|
+
return (Array.isArray(list) ? list : []).map((e) => (typeof e === 'string' ? e : String(obj(e).origin ?? '')));
|
|
788
|
+
}
|
|
789
|
+
/** auth_config without the integration hints, which belong to allowed_origins. */
|
|
790
|
+
function stripOriginIntegrations(authConfig) {
|
|
791
|
+
const { originIntegrations: _hints, ...rest } = obj(authConfig);
|
|
792
|
+
return rest;
|
|
793
|
+
}
|
|
560
794
|
/** A stable natural key for an SSO connection, which has no name field. */
|
|
561
795
|
function ssoKey(config) {
|
|
562
796
|
const c = obj(config);
|
|
@@ -664,7 +898,7 @@ async function writeExport(http, dir, format) {
|
|
|
664
898
|
const live = await spec.fetchLive(http);
|
|
665
899
|
if (live === null)
|
|
666
900
|
continue; // nothing to write (e.g. no schema registered)
|
|
667
|
-
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(dir, `${spec.type}.${ext}`), (0, serialize_1.serialize)(live, format));
|
|
901
|
+
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(dir, `${spec.type}.${ext}`), (0, serialize_1.serialize)(withoutMarkers(live), format));
|
|
668
902
|
fileCount += 1;
|
|
669
903
|
}
|
|
670
904
|
else {
|
|
@@ -694,6 +928,7 @@ async function exportConfig(ctx, values) {
|
|
|
694
928
|
*/
|
|
695
929
|
async function planConfigImport(http, opts) {
|
|
696
930
|
const { dir, format, env, prune } = opts;
|
|
931
|
+
const applyOpts = { forceHook: opts.forceHook === true };
|
|
697
932
|
const ext = format === 'json' ? 'json' : 'yaml';
|
|
698
933
|
if (!(0, node_fs_1.existsSync)(dir)) {
|
|
699
934
|
throw new errors_1.CliError(`Config directory not found: ${dir}. Run 'atlas config export' first.`);
|
|
@@ -711,12 +946,20 @@ async function planConfigImport(http, opts) {
|
|
|
711
946
|
const parsed = interpolate((0, serialize_1.deserialize)(text, format), env);
|
|
712
947
|
if (spec.kind === 'singleton') {
|
|
713
948
|
const desired = parsed;
|
|
714
|
-
const
|
|
715
|
-
|
|
949
|
+
const liveRaw = await spec.fetchLive(http);
|
|
950
|
+
const norm = spec.normalize ?? ((c) => c);
|
|
951
|
+
const desiredN = norm(desired);
|
|
952
|
+
// A section that only clears fields (all null) against nothing live is already satisfied.
|
|
953
|
+
if (liveRaw === null && onlyNulls(desiredN)) {
|
|
954
|
+
unchanged += 1;
|
|
955
|
+
continue;
|
|
956
|
+
}
|
|
957
|
+
const live = liveRaw === null ? null : comparable(norm(liveRaw), desiredN, prune);
|
|
958
|
+
if (live !== null && (0, serialize_1.deepEqual)(desiredN, live)) {
|
|
716
959
|
unchanged += 1;
|
|
717
960
|
continue;
|
|
718
961
|
}
|
|
719
|
-
changes.push({ type: spec.type, action: live === null ? 'create' : 'update', key: '', ...(live === null ? {} : { before: live }), after:
|
|
962
|
+
changes.push({ type: spec.type, action: live === null ? 'create' : 'update', key: '', ...(live === null ? {} : { before: live }), after: desiredN, apply: () => spec.apply(http, desired, applyOpts) });
|
|
720
963
|
continue;
|
|
721
964
|
}
|
|
722
965
|
// Collection: diff desired-by-key against live-by-key.
|
|
@@ -731,12 +974,12 @@ async function planConfigImport(http, opts) {
|
|
|
731
974
|
if (!existing) {
|
|
732
975
|
changes.push({ type: spec.type, action: 'create', key, after: cfg, apply: () => spec.create(http, cfg) });
|
|
733
976
|
}
|
|
734
|
-
else if (!(0, serialize_1.deepEqual)(cfg, existing.config)) {
|
|
977
|
+
else if (!(0, serialize_1.deepEqual)(cfg, comparable(existing.config, cfg, prune))) {
|
|
735
978
|
if (!spec.update) {
|
|
736
979
|
throw new errors_1.CliError(`${spec.type} '${key}' changed but is immutable; recreate it instead.`);
|
|
737
980
|
}
|
|
738
981
|
const upd = spec.update;
|
|
739
|
-
changes.push({ type: spec.type, action: 'update', key, before: existing.config, after: cfg, apply: () => upd(http, existing, cfg) });
|
|
982
|
+
changes.push({ type: spec.type, action: 'update', key, before: comparable(existing.config, cfg, prune), after: cfg, apply: () => upd(http, existing, cfg) });
|
|
740
983
|
}
|
|
741
984
|
else {
|
|
742
985
|
unchanged += 1;
|
|
@@ -761,6 +1004,39 @@ async function planConfigImport(http, opts) {
|
|
|
761
1004
|
}
|
|
762
1005
|
return { changes, unchanged, skippedDeletes };
|
|
763
1006
|
}
|
|
1007
|
+
/** True for a non-empty object whose every leaf is `null` (a file that only clears fields). */
|
|
1008
|
+
function onlyNulls(x) {
|
|
1009
|
+
if (x === null)
|
|
1010
|
+
return true;
|
|
1011
|
+
if (typeof x !== 'object' || Array.isArray(x) || x === undefined)
|
|
1012
|
+
return false;
|
|
1013
|
+
const vals = Object.values(x);
|
|
1014
|
+
return vals.length > 0 && vals.every(onlyNulls);
|
|
1015
|
+
}
|
|
1016
|
+
/**
|
|
1017
|
+
* The live document as the plan compares it. With `--prune` the whole live
|
|
1018
|
+
* document (keys the file leaves unset show as drift). Without it only the keys
|
|
1019
|
+
* the file sets, recursively, so a freshly applied file plans clean. A desired
|
|
1020
|
+
* `null` means "unset", so it matches a live key that is absent.
|
|
1021
|
+
*/
|
|
1022
|
+
function comparable(live, desired, prune) {
|
|
1023
|
+
if (prune)
|
|
1024
|
+
return live;
|
|
1025
|
+
const isObj = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
|
|
1026
|
+
if (!isObj(live) || !isObj(desired))
|
|
1027
|
+
return live;
|
|
1028
|
+
const out = {};
|
|
1029
|
+
for (const k of Object.keys(desired)) {
|
|
1030
|
+
const l = live[k];
|
|
1031
|
+
if (l === undefined || l === null) {
|
|
1032
|
+
if (desired[k] === null)
|
|
1033
|
+
out[k] = null;
|
|
1034
|
+
continue;
|
|
1035
|
+
}
|
|
1036
|
+
out[k] = comparable(l, desired[k], false);
|
|
1037
|
+
}
|
|
1038
|
+
return out;
|
|
1039
|
+
}
|
|
764
1040
|
/** The stored credential of a named profile (one profile per environment). */
|
|
765
1041
|
function profileHttp(ctx, name) {
|
|
766
1042
|
const stored = ctx.config.load().profiles[name];
|
|
@@ -823,7 +1099,7 @@ async function importConfig(ctx, values) {
|
|
|
823
1099
|
const format = resolveFormat(values);
|
|
824
1100
|
const dryRun = values['dry-run'] === true;
|
|
825
1101
|
const prune = values.prune === true;
|
|
826
|
-
const plan = await planConfigImport(http, { dir, format, env: readSecretsEnv(ctx, values), prune });
|
|
1102
|
+
const plan = await planConfigImport(http, { dir, format, env: readSecretsEnv(ctx, values), prune, forceHook: values['force-hook'] === true });
|
|
827
1103
|
printPlan(ctx, plan.changes, plan.unchanged, plan.skippedDeletes, { dryRun, prune });
|
|
828
1104
|
if (dryRun)
|
|
829
1105
|
return 0;
|
|
@@ -858,6 +1134,7 @@ const show = (v) => (v === undefined ? '(unset)' : typeof v === 'string' ? JSON.
|
|
|
858
1134
|
async function planConfig(ctx, values, source) {
|
|
859
1135
|
const http = requireHttp(ctx, values);
|
|
860
1136
|
const { dir, format, env } = source;
|
|
1137
|
+
const where = source.label ?? dir;
|
|
861
1138
|
const prune = values.prune === true;
|
|
862
1139
|
const plan = await planConfigImport(http, { dir, format, env, prune });
|
|
863
1140
|
const deletes = prune ? [] : plan.skippedDeletes;
|
|
@@ -892,12 +1169,12 @@ async function planConfig(ctx, values, source) {
|
|
|
892
1169
|
}
|
|
893
1170
|
if (deletes.length) {
|
|
894
1171
|
ctx.io.out('');
|
|
895
|
-
ctx.io.out(`Live but not in ${
|
|
1172
|
+
ctx.io.out(`Live but not in ${where} (kept; pass --prune to plan their deletion):`);
|
|
896
1173
|
for (const d of deletes)
|
|
897
1174
|
ctx.io.out(` ${d.type}${d.key ? ` ${d.key}` : ''}`);
|
|
898
1175
|
}
|
|
899
1176
|
if (pending === 0)
|
|
900
|
-
ctx.io.out(
|
|
1177
|
+
ctx.io.out(`No drift: the instance matches ${where}.`);
|
|
901
1178
|
}
|
|
902
1179
|
return wantsCheck(values) && pending > 0 ? 2 : 0;
|
|
903
1180
|
}
|
|
@@ -955,7 +1232,7 @@ function resolveSource(ctx, args) {
|
|
|
955
1232
|
throw new errors_1.CliError(`Config not found: ${path}. Pass a config file (atlas.yaml / atlas.json) or a directory from 'atlas config export'.`);
|
|
956
1233
|
}
|
|
957
1234
|
if (!isFile)
|
|
958
|
-
return { dir: path, format: resolveFormat(args.values), env, cleanup: () => undefined };
|
|
1235
|
+
return { dir: path, label: path, format: resolveFormat(args.values), env, cleanup: () => undefined };
|
|
959
1236
|
const ext = (0, node_path_1.extname)(path).toLowerCase();
|
|
960
1237
|
const format = ext === '.yaml' || ext === '.yml' ? 'yaml' : ext === '.json' ? 'json' : resolveFormat(args.values);
|
|
961
1238
|
const doc = (0, serialize_1.deserialize)(ctx.readFile(path), format);
|
|
@@ -970,7 +1247,7 @@ function resolveSource(ctx, args) {
|
|
|
970
1247
|
const dir = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), 'atlas-apply-'));
|
|
971
1248
|
for (const [type, value] of Object.entries(doc))
|
|
972
1249
|
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(dir, `${type}.json`), (0, serialize_1.serialize)(value, 'json'));
|
|
973
|
-
return { dir, format: 'json', env, cleanup: () => (0, node_fs_1.rmSync)(dir, { recursive: true, force: true }) };
|
|
1250
|
+
return { dir, label: path, format: 'json', env, cleanup: () => (0, node_fs_1.rmSync)(dir, { recursive: true, force: true }) };
|
|
974
1251
|
}
|
|
975
1252
|
/**
|
|
976
1253
|
* `atlas plan [<file|dir>]`: the read-only diff of `atlas config plan`, taking
|
|
@@ -1000,7 +1277,7 @@ async function applyCommand(ctx, args) {
|
|
|
1000
1277
|
try {
|
|
1001
1278
|
const kept = keepingSecrets(requireHttp(ctx, values), (0, args_1.str)(values, 'out') || (0, secrets_1.productDir)(ctx.env, 'atlas'));
|
|
1002
1279
|
const prune = values.prune === true;
|
|
1003
|
-
const plan = await planConfigImport(kept.http, { dir: source.dir, format: source.format, env: source.env, prune });
|
|
1280
|
+
const plan = await planConfigImport(kept.http, { dir: source.dir, format: source.format, env: source.env, prune, forceHook: values['force-hook'] === true });
|
|
1004
1281
|
printPlan(ctx, plan.changes, plan.unchanged, plan.skippedDeletes, { dryRun: false, prune });
|
|
1005
1282
|
if (plan.changes.length === 0) {
|
|
1006
1283
|
ctx.io.out('No changes to apply.');
|