@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.
@@ -38,12 +38,15 @@ function pick(row, keys) {
38
38
  }
39
39
  return out;
40
40
  }
41
- /** The write body for a create/update: strip null/undefined so the API never sees them. */
42
- function compact(config) {
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 === null || v === undefined)
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
- return {
74
- allowed_origins: inst.allowed_origins ?? [],
75
- auth_config: inst.auth_config ?? {},
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
- // A file without allowed_origins leaves the live origins alone (a partial
81
- // document, e.g. one produced by `atlas migrate better-auth config`).
82
- const body = { auth_config: c.auth_config ?? {} };
83
- if (c.allowed_origins !== undefined)
84
- body.allowed_origins = c.allowed_origins;
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 live = await spec.fetchLive(http);
715
- if (live !== null && (0, serialize_1.deepEqual)(desired, live)) {
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: desired, apply: () => spec.apply(http, desired) });
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 ${dir} (kept; pass --prune to plan their deletion):`);
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('No drift: the instance matches the config directory.');
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.');