astro-dev-edit 0.11.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.
Files changed (81) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +125 -0
  3. package/package.json +52 -0
  4. package/src/client/admin-bar.ts +622 -0
  5. package/src/client/api.ts +370 -0
  6. package/src/client/classify-cache.ts +61 -0
  7. package/src/client/css-inspect.ts +345 -0
  8. package/src/client/editors/asset-picker.ts +155 -0
  9. package/src/client/editors/body-editor.ts +419 -0
  10. package/src/client/editors/collections-panel.ts +1532 -0
  11. package/src/client/editors/copy-panel.ts +73 -0
  12. package/src/client/editors/drawer.ts +95 -0
  13. package/src/client/editors/entry.ts +433 -0
  14. package/src/client/editors/expression.ts +77 -0
  15. package/src/client/editors/fields.ts +309 -0
  16. package/src/client/editors/image.ts +268 -0
  17. package/src/client/editors/markup-insert.ts +73 -0
  18. package/src/client/editors/markup.ts +125 -0
  19. package/src/client/editors/media-grid.ts +326 -0
  20. package/src/client/editors/media-modal.ts +588 -0
  21. package/src/client/editors/notice.ts +160 -0
  22. package/src/client/editors/peek.ts +135 -0
  23. package/src/client/editors/settings-panel.ts +457 -0
  24. package/src/client/editors/source-popup.ts +166 -0
  25. package/src/client/editors/text.ts +105 -0
  26. package/src/client/editors/unsplash-pane.ts +317 -0
  27. package/src/client/element-context.ts +308 -0
  28. package/src/client/features.ts +81 -0
  29. package/src/client/focus.ts +166 -0
  30. package/src/client/group.ts +186 -0
  31. package/src/client/highlight.ts +146 -0
  32. package/src/client/hover.ts +485 -0
  33. package/src/client/icons.ts +160 -0
  34. package/src/client/markdown.ts +319 -0
  35. package/src/client/overlay.ts +466 -0
  36. package/src/client/page-source.ts +143 -0
  37. package/src/client/router.ts +198 -0
  38. package/src/client/shadow.ts +111 -0
  39. package/src/client/source-map.ts +150 -0
  40. package/src/client/state.ts +153 -0
  41. package/src/client/styles.ts +3485 -0
  42. package/src/client/tree-model.ts +45 -0
  43. package/src/client/tree.ts +366 -0
  44. package/src/client/ui.ts +987 -0
  45. package/src/client/unsplash-search.ts +250 -0
  46. package/src/index.ts +299 -0
  47. package/src/patcher/astro.ts +792 -0
  48. package/src/patcher/content-config.ts +1035 -0
  49. package/src/patcher/dotenv.ts +121 -0
  50. package/src/patcher/expression-trace.ts +326 -0
  51. package/src/patcher/frontmatter.ts +249 -0
  52. package/src/patcher/registry.ts +11 -0
  53. package/src/patcher/types.ts +32 -0
  54. package/src/server/annotate.ts +173 -0
  55. package/src/server/assets.ts +167 -0
  56. package/src/server/collection-entries.ts +91 -0
  57. package/src/server/content-config.ts +210 -0
  58. package/src/server/editor.ts +15 -0
  59. package/src/server/entry-detect.ts +110 -0
  60. package/src/server/entry-resolve-routes.ts +218 -0
  61. package/src/server/entry-routes.ts +304 -0
  62. package/src/server/inspect-locate.ts +81 -0
  63. package/src/server/inspect-routes.ts +94 -0
  64. package/src/server/middleware.ts +480 -0
  65. package/src/server/options.ts +778 -0
  66. package/src/server/page-source-routes.ts +71 -0
  67. package/src/server/paths.ts +219 -0
  68. package/src/server/private-files.ts +116 -0
  69. package/src/server/route-manifest.ts +200 -0
  70. package/src/server/router.ts +94 -0
  71. package/src/server/schema-introspect.ts +233 -0
  72. package/src/server/schema-routes.ts +808 -0
  73. package/src/server/settings-routes.ts +246 -0
  74. package/src/server/settings.ts +382 -0
  75. package/src/server/text-writes.ts +105 -0
  76. package/src/server/unsplash-routes.ts +515 -0
  77. package/src/server/zod-adapt.ts +239 -0
  78. package/src/shared/asset-path.ts +132 -0
  79. package/src/shared/protocol.ts +935 -0
  80. package/src/shared/slug.ts +17 -0
  81. package/src/shared/unsplash.ts +51 -0
@@ -0,0 +1,246 @@
1
+ import type { TextWriter } from './text-writes.ts';
2
+ import type { AstroIntegrationLogger } from 'astro';
3
+ import type { SettingsResponse, SettingsUpdateRequest } from '../shared/protocol.ts';
4
+ import {
5
+ applyOptionPatch,
6
+ coerceOptionPatch,
7
+ type OptionsResolver,
8
+ type ResolvedOption,
9
+ } from './options.ts';
10
+ import type { Route, RouteResult } from './router.ts';
11
+ import {
12
+ hasStaleStoredKey,
13
+ keyWritable,
14
+ maskKey,
15
+ readStoredOptions,
16
+ saveStoredOptions,
17
+ saveUnsplashKey,
18
+ uncoveredSecretFiles,
19
+ } from './settings.ts';
20
+ import type { UnsplashConfig } from './unsplash-routes.ts';
21
+
22
+ /**
23
+ * The `/settings` route group — what the overlay's Settings panel reads and
24
+ * writes.
25
+ *
26
+ * Split out of `unsplash-routes.ts`, which held these two endpoints only because
27
+ * the sole setting was that feature's access key and whose comment named this
28
+ * split as the moment a second, unrelated setting arrived. That moment is the
29
+ * option editor.
30
+ *
31
+ * Two things, deliberately different in kind, and now in two different files:
32
+ *
33
+ * - **`options`** — ordinary values in `.astro-dev-edit.json`, read back in
34
+ * full. The panel needs the effective value *and* its provenance, because an
35
+ * option `astro.config.mjs` sets cannot be changed from here and the panel
36
+ * must say so instead of accepting input that resolution would discard.
37
+ * - **the access key** — a secret, written to `.env.local`. It is never in a
38
+ * response: a read reports only whether one resolved, from where, whether the
39
+ * panel may change it, and a masked fragment. It must never enter a log line
40
+ * or an error message either.
41
+ *
42
+ * **An option patch is all-or-nothing.** A patch naming any unknown,
43
+ * config-only or locked key is refused whole, with per-key messages, before
44
+ * anything reaches disk — so a partly-valid patch can never leave the file
45
+ * half-updated. This is the same property `/apply`'s verify-all-then-write-once
46
+ * loop has.
47
+ *
48
+ * That does **not** extend across the two halves of a combined save: options
49
+ * are written before the key is validated, so a save carrying both can store
50
+ * the options and still refuse the key. Options first is the deliberate order —
51
+ * the key half is the one with precedence rules that can refuse, and losing an
52
+ * accepted option patch because a key was rejected would be the worse trade.
53
+ *
54
+ * Both halves write a **fixed path** (`.astro-dev-edit.json` and `.env.local`
55
+ * at the project root, never client-supplied), which is why they bypass
56
+ * `paths.ts::validateEditablePath` — see the header of `settings.ts` for the
57
+ * full rationale.
58
+ */
59
+
60
+ export interface SettingsRouteDeps {
61
+ writeText?: TextWriter;
62
+ logger: AstroIntegrationLogger;
63
+ /** Project root (fsPath). The settings file sits at its top level. */
64
+ root: string;
65
+ /** The live option resolver — the same one every other route reads through. */
66
+ optionsResolver: OptionsResolver;
67
+ /**
68
+ * The Unsplash config, for its key resolver **only**.
69
+ *
70
+ * Deliberately not re-deriving the key here: `resolveUnsplashKey` has its own
71
+ * precedence (config → shell → env file → legacy stored) that
72
+ * `/unsplash/search` already reads
73
+ * through this seam, and a second call site would be a second place for that
74
+ * order to drift. It is also the seam tests inject through, so a duplicate
75
+ * would make this route disagree with the searches it reports on.
76
+ */
77
+ unsplash: UnsplashConfig | null;
78
+ }
79
+
80
+ /** Body cap for a settings write. Generous for a sparse patch of scalars and
81
+ * short path lists, far too small to smuggle anything bulky. */
82
+ const MAX_SETTINGS_BYTES = 8 * 1024;
83
+
84
+ export function createSettingsRoutes(deps: SettingsRouteDeps): Route[] {
85
+ const { logger, root, optionsResolver, unsplash } = deps;
86
+
87
+ /** The full panel payload. Shared by read and write, so a save answers with
88
+ * the same shape a read would and the panel needs no second request. */
89
+ async function settingsBody(): Promise<SettingsResponse> {
90
+ const { options, described } = await optionsResolver.resolve();
91
+ // Enabled-ness comes from the **option**, which is what this panel toggles
92
+ // and the one source of truth for it. Only the key is asked of the injected
93
+ // config, which owns its own config → env → file precedence.
94
+ const unsplashOn = options.unsplash !== false && Boolean(unsplash);
95
+
96
+ // Only asked for when the feature is on, so a disabled source reports
97
+ // "not enabled" without touching the filesystem at all.
98
+ const resolved = unsplashOn ? await unsplash!.resolve() : { key: '', source: null };
99
+ const { key, source } = resolved;
100
+ const { writable, clearable } = keyWritable(resolved);
101
+ const uncovered = await uncoveredSecretFiles(root);
102
+
103
+ return {
104
+ options: described.map(toWire),
105
+ ...(uncovered.length > 0 ? { gitignoreWarning: uncovered } : {}),
106
+ unsplash: {
107
+ enabled: unsplashOn,
108
+ configured: Boolean(key),
109
+ source,
110
+ ...(resolved.file ? { sourceFile: resolved.file } : {}),
111
+ ...(key ? { hint: maskKey(key) } : {}),
112
+ writable,
113
+ clearable,
114
+ ...((await hasStaleStoredKey(root, resolved)) ? { staleStoredKey: true as const } : {}),
115
+ },
116
+ };
117
+ }
118
+
119
+ return [
120
+ {
121
+ method: 'GET',
122
+ path: '/settings',
123
+ label: 'settings read',
124
+ handler: async () => ({ status: 200, body: await settingsBody() }),
125
+ onError: () => ({ status: 500, body: { error: 'could not read settings' } }),
126
+ },
127
+
128
+ {
129
+ method: 'POST',
130
+ path: '/settings',
131
+ maxBytes: MAX_SETTINGS_BYTES,
132
+ label: 'settings write',
133
+ handler: async (body) => {
134
+ const req = (body ?? {}) as SettingsUpdateRequest;
135
+ const hasOptions = req.options && typeof req.options === 'object';
136
+ const hasKey = typeof req.unsplash?.accessKey === 'string';
137
+ if (!hasOptions && !hasKey) {
138
+ return { status: 400, body: { error: 'nothing to save' } };
139
+ }
140
+
141
+ if (hasOptions) {
142
+ const refusal = await writeOptions(req.options!);
143
+ if (refusal) return refusal;
144
+ }
145
+ if (hasKey) {
146
+ const refusal = await writeAccessKey(req.unsplash!.accessKey);
147
+ if (refusal) return refusal;
148
+ }
149
+ return { status: 200, body: await settingsBody() };
150
+ },
151
+ onError: () => ({ status: 500, body: { error: 'could not save settings' } }),
152
+ },
153
+ ];
154
+
155
+ /** Validate and store an option patch, or return the refusal. */
156
+ async function writeOptions(patch: Record<string, unknown>): Promise<RouteResult | null> {
157
+ const { values, errors } = coerceOptionPatch(patch);
158
+
159
+ // `locked` depends on the current resolution, so it is checked here rather
160
+ // than in the pure coercion step.
161
+ const { described } = await optionsResolver.resolve();
162
+ const byKey = new Map(described.map((o) => [o.key, o]));
163
+ for (const key of values.keys()) {
164
+ const opt = byKey.get(key);
165
+ if (opt?.locked) {
166
+ errors[key] =
167
+ `${opt.label} is set in astro.config.mjs, which takes precedence. ` +
168
+ 'Remove it there to manage this option from the panel.';
169
+ values.delete(key);
170
+ }
171
+ }
172
+
173
+ if (Object.keys(errors).length > 0) {
174
+ // All-or-nothing: nothing has touched disk yet, and nothing will.
175
+ return {
176
+ status: 422,
177
+ body: { error: 'some options were refused', code: 'validation', fieldErrors: errors },
178
+ };
179
+ }
180
+ if (values.size === 0) return null;
181
+
182
+ const next = applyOptionPatch(await readStoredOptions(root), values);
183
+ await saveStoredOptions(root, next, deps.writeText);
184
+ logger.info(`settings saved: ${[...values.keys()].join(', ')}`);
185
+ return null;
186
+ }
187
+
188
+ /** Store or clear the access key, or return the refusal. */
189
+ async function writeAccessKey(accessKey: string): Promise<RouteResult | null> {
190
+ const { options } = await optionsResolver.resolve();
191
+ if (options.unsplash === false || !unsplash) {
192
+ return {
193
+ status: 403,
194
+ body: { error: 'the Unsplash photo source is disabled', code: 'disabled' },
195
+ };
196
+ }
197
+ // Anything that outranks the file this panel writes would make the save a
198
+ // value that silently does nothing. Refuse, and name the file to edit.
199
+ const resolved = await unsplash.resolve();
200
+ const { writable, clearable, reason } = keyWritable(resolved);
201
+ const clearing = !accessKey.trim();
202
+ if (!writable || (clearing && !clearable)) {
203
+ return { status: 409, body: { error: reason!, code: 'conflict' } };
204
+ }
205
+
206
+ const saved = await saveUnsplashKey(root, accessKey, deps.writeText);
207
+ if (!saved.ok) {
208
+ return {
209
+ status: 422,
210
+ body: {
211
+ error: 'the access key was refused',
212
+ code: 'validation',
213
+ fieldErrors: { accessKey: saved.reason },
214
+ },
215
+ };
216
+ }
217
+ if (saved.staleStoredKey) {
218
+ logger.warn(
219
+ `saved the Unsplash access key to ${saved.file}, but could not remove the ` +
220
+ 'older copy from .astro-dev-edit.json — saving again will retry.',
221
+ );
222
+ }
223
+ logger.info(
224
+ (clearing ? `cleared the Unsplash access key from ${saved.file}` : `stored an Unsplash access key in ${saved.file}`) +
225
+ (saved.migrated ? ', and removed the older copy from .astro-dev-edit.json' : ''),
226
+ );
227
+ return null;
228
+ }
229
+ }
230
+
231
+ /** Server shape → wire shape. `restartRequired` is omitted when false so the
232
+ * common case stays absent from the payload. */
233
+ function toWire(o: ResolvedOption): NonNullable<SettingsResponse['options']>[number] {
234
+ return {
235
+ key: o.key,
236
+ label: o.label,
237
+ help: o.help,
238
+ type: o.type,
239
+ group: o.group,
240
+ ...(o.choices ? { choices: o.choices } : {}),
241
+ value: o.value,
242
+ source: o.source,
243
+ locked: o.locked,
244
+ ...(o.restartRequired ? { restartRequired: true } : {}),
245
+ };
246
+ }
@@ -0,0 +1,382 @@
1
+ import type { TextWriter } from './text-writes.ts';
2
+ import { chmod, readFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import type { SettingsSource } from '../shared/protocol.ts';
5
+ import { upsertEnvVar } from '../patcher/dotenv.ts';
6
+ import type { StoredOptions } from './options.ts';
7
+ import { atomicWrite, SECRET_MODE } from './paths.ts';
8
+
9
+ /**
10
+ * User settings that live outside the Astro config, and the resolution of the
11
+ * Unsplash access key.
12
+ *
13
+ * **Two files, for two different kinds of thing.**
14
+ *
15
+ * `.astro-dev-edit.json` holds the option document the Settings panel writes
16
+ * and the entry drawer's field overrides — ordinary values, in the same
17
+ * vocabulary `astro.config.mjs` uses, so the file reads like the config it
18
+ * supplements and `options.ts` can apply one `read` per option to either
19
+ * source. It is never served over HTTP (`private-files.ts`), but it is a plain
20
+ * JSON file in the project's own tree.
21
+ *
22
+ * `.env.local` holds the access key, as `UNSPLASH_ACCESS_KEY`. A secret does
23
+ * not belong in a file that sits in the directory Vite serves — a guard is one
24
+ * thing to get wrong, whereas `.env` and `.env.*` are already denied by Vite
25
+ * itself and already expected to hold secrets by every project's ignore rules.
26
+ * It is also the file a developer would have edited by hand, so the panel is
27
+ * writing to the same place rather than inventing a private one.
28
+ *
29
+ * A legacy `unsplash.accessKey` inside `.astro-dev-edit.json` is still read, at
30
+ * the lowest precedence, and is stripped the next time a key is saved. See
31
+ * {@link saveUnsplashKey} for why the two writes go in the order they do.
32
+ *
33
+ * **A class of write of its own.** Neither file can go through
34
+ * `paths.ts::validateEditablePath`, which would block both three ways:
35
+ * `realpath` fails on a file that does not exist yet, a root dotfile is outside
36
+ * `contentRoots`, and neither `.json` nor an extensionless dotfile is an
37
+ * editable extension. They follow `/entry/create` instead: a **fixed target** —
38
+ * the path is a constant here, never client-supplied — written through the
39
+ * injected `writeText`.
40
+ *
41
+ * Server-side rather than `localStorage` so settings survive a browser data
42
+ * clear, work from any browser, and have a home for future additions.
43
+ *
44
+ * The key is **never** returned to the client — only whether one resolved,
45
+ * where from, and a masked fragment. It must never enter a log line or an error
46
+ * message either.
47
+ */
48
+
49
+ /** Fixed, never client-supplied. */
50
+ export const SETTINGS_FILE = '.astro-dev-edit.json';
51
+
52
+ /**
53
+ * The env file the Settings panel owns. `.env.local` rather than `.env`
54
+ * because it is conventionally the personal, gitignored half of the pair, it
55
+ * outranks `.env` so the panel can override a value a team shares, and Vite's
56
+ * default `.env.*` deny already covers it.
57
+ */
58
+ export const ENV_TARGET = '.env.local';
59
+
60
+ /**
61
+ * The files Vite's `loadEnv` reads, **lowest precedence first** — it merges
62
+ * them left to right, so later wins, and `process.env` then overrides them all.
63
+ * Mirrors `getEnvFilesForMode`. The two `development` entries are why a save
64
+ * can be refused: they outrank the file this module writes.
65
+ */
66
+ const ENV_FILES = ['.env', '.env.local', '.env.development', '.env.development.local'] as const;
67
+
68
+ /** Env files that beat {@link ENV_TARGET}, so a key in one cannot be replaced
69
+ * by writing `.env.local`. */
70
+ const ENV_FILES_ABOVE_TARGET: readonly string[] = ['.env.development', '.env.development.local'];
71
+
72
+ const ENV_VAR = 'UNSPLASH_ACCESS_KEY';
73
+
74
+ interface StoredSettings {
75
+ /** Legacy. Read for back-compat, never written; stripped on the next key
76
+ * save. The live key lives in {@link ENV_TARGET}. */
77
+ unsplash?: { accessKey?: string };
78
+ /** What the Settings panel writes — see `options.ts::StoredOptions`. */
79
+ options?: StoredOptions;
80
+ }
81
+
82
+ /** Read the settings file. Every failure — absent, unreadable, malformed JSON,
83
+ * wrong shape — degrades to "nothing stored" rather than throwing, so a
84
+ * corrupt file makes the feature unconfigured instead of breaking the page. */
85
+ async function readSettingsFile(root: string): Promise<StoredSettings> {
86
+ try {
87
+ const raw = await readFile(join(root, SETTINGS_FILE), 'utf8');
88
+ const parsed: unknown = JSON.parse(raw);
89
+ if (!parsed || typeof parsed !== 'object') return {};
90
+ return parsed as StoredSettings;
91
+ } catch {
92
+ return {};
93
+ }
94
+ }
95
+
96
+ /** File contents, or null when it does not exist. Any other read error is a
97
+ * real problem and propagates. */
98
+ async function readIfPresent(target: string): Promise<string | null> {
99
+ try {
100
+ return await readFile(target, 'utf8');
101
+ } catch (error) {
102
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
103
+ throw error;
104
+ }
105
+ }
106
+
107
+ /** Write the settings file atomically. It holds no secret, so it takes no
108
+ * mode — but the chmod predates that split and costs nothing to keep, and the
109
+ * file may still hold a legacy key until the next save strips it. */
110
+ async function writeSettingsFile(
111
+ root: string,
112
+ next: StoredSettings,
113
+ writeText: TextWriter = (target, content, _original, mode) => atomicWrite(target, content, mode),
114
+ ): Promise<void> {
115
+ const target = join(root, SETTINGS_FILE);
116
+ await writeText(target, JSON.stringify(next, null, 2) + '\n', undefined, SECRET_MODE);
117
+ try {
118
+ await chmod(target, SECRET_MODE);
119
+ } catch {
120
+ // Non-POSIX filesystem; the file is written either way.
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Read the access key out of the environment, and say where it came from.
126
+ *
127
+ * `astro dev` does **not** populate `process.env` from `.env` — Astro's env
128
+ * plugin does that only when `isBuild` (`vite-plugin-env.js`: `if (!isBuild ||
129
+ * populated) return`), and Vite has not mutated `process.env` since v2. So a
130
+ * plain `process.env` read sees a `.env` entry *never* in dev. Going through
131
+ * Vite's own `loadEnv` also handles `.env.local`, `.env.[mode]`, quoting and
132
+ * expansion.
133
+ *
134
+ * The order here is not a heuristic: `loadEnv` with an empty prefix copies
135
+ * `process.env` over everything it parsed from files, so an exported variable
136
+ * genuinely does outrank all four files. Checking `process.env` first only
137
+ * makes that visible, so the panel can say the key cannot be changed from a
138
+ * file. The file scan afterwards is cosmetic — it names the winning file, while
139
+ * the *value* always comes from `loadEnv`, quoting and expansion intact.
140
+ *
141
+ * `vite` is imported dynamically rather than declared as a dependency: it
142
+ * resolves from any Astro project, and a static import would make this module
143
+ * unloadable anywhere it doesn't.
144
+ */
145
+ async function readEnvKey(
146
+ root: string,
147
+ name: string,
148
+ ): Promise<{ key: string; origin: 'shell' | 'file' | null; file?: string }> {
149
+ const exported = (process.env[name] ?? '').trim();
150
+ if (exported) return { key: exported, origin: 'shell' };
151
+
152
+ let value = '';
153
+ try {
154
+ const { loadEnv } = await import('vite');
155
+ // Empty prefix: return unprefixed variables too (the default `VITE_` prefix
156
+ // would hide UNSPLASH_ACCESS_KEY entirely).
157
+ value = (loadEnv('development', root, '')[name] ?? '').trim();
158
+ } catch {
159
+ // vite unresolvable, or the project has no readable .env — fall through.
160
+ }
161
+ if (!value) return { key: '', origin: null };
162
+
163
+ // Highest-precedence file that declares it, for the panel to name. A literal
164
+ // assignment scan, not a parse: it decides only which label to show.
165
+ const declares = new RegExp(`^\\s*(export\\s+)?${name}\\s*=`);
166
+ for (const file of [...ENV_FILES].reverse()) {
167
+ const raw = await readIfPresent(join(root, file));
168
+ if (raw && raw.split(/\r?\n/).some((line) => declares.test(line))) {
169
+ return { key: value, origin: 'file', file };
170
+ }
171
+ }
172
+ // A value with no file behind it: `loadEnv` also merges `process.env`, and a
173
+ // variable set to whitespace would have been trimmed away above.
174
+ return { key: value, origin: 'file' };
175
+ }
176
+
177
+ export interface ResolvedKey {
178
+ /** Empty when nothing is configured anywhere. */
179
+ key: string;
180
+ source: SettingsSource | null;
181
+ /** Project-relative file the key came from, when it came from one. */
182
+ file?: string;
183
+ }
184
+
185
+ /**
186
+ * Resolve the Unsplash access key, highest precedence first:
187
+ *
188
+ * 1. `unsplash.accessKey` in the Astro config — documented but discouraged;
189
+ * that file is committed and is read by `astro build`.
190
+ * 2. An exported `UNSPLASH_ACCESS_KEY` — for CI, and impossible to change from
191
+ * the panel.
192
+ * 3. `UNSPLASH_ACCESS_KEY` in a `.env` file, which is what the panel writes.
193
+ * 4. A legacy key in the settings file, kept working until the next save.
194
+ *
195
+ * Called per request (never captured at config time), so a key entered through
196
+ * the UI works without a dev-server restart.
197
+ */
198
+ export async function resolveUnsplashKey(root: string, configKey?: string): Promise<ResolvedKey> {
199
+ if (configKey?.trim()) return { key: configKey.trim(), source: 'config' };
200
+
201
+ const env = await readEnvKey(root, ENV_VAR);
202
+ if (env.key) {
203
+ return env.origin === 'shell'
204
+ ? { key: env.key, source: 'env-shell' }
205
+ : { key: env.key, source: 'env-file', ...(env.file ? { file: env.file } : {}) };
206
+ }
207
+
208
+ const stored = (await readSettingsFile(root)).unsplash?.accessKey?.trim();
209
+ if (stored) return { key: stored, source: 'file', file: SETTINGS_FILE };
210
+
211
+ return { key: '', source: null };
212
+ }
213
+
214
+ /**
215
+ * Whether the panel may change the key, and whether **Clear** would do
216
+ * anything. The server owns this rather than the panel, because the answer
217
+ * turns on *which* env file won — precedence the client has no business
218
+ * carrying a second copy of.
219
+ */
220
+ export function keyWritable(resolved: ResolvedKey): {
221
+ writable: boolean;
222
+ clearable: boolean;
223
+ reason?: string;
224
+ } {
225
+ const refuse = (reason: string) => ({ writable: false, clearable: false, reason });
226
+
227
+ switch (resolved.source) {
228
+ case 'config':
229
+ return refuse(
230
+ 'An access key is set in your Astro config, which takes precedence. ' +
231
+ 'Remove `unsplash.accessKey` from astro.config.mjs first.',
232
+ );
233
+ case 'env-shell':
234
+ return refuse(
235
+ `${ENV_VAR} is exported in your environment, which overrides every .env ` +
236
+ 'file. Unset it in your shell first.',
237
+ );
238
+ case 'env-file':
239
+ if (resolved.file && ENV_FILES_ABOVE_TARGET.includes(resolved.file)) {
240
+ return refuse(
241
+ `An access key in ${resolved.file} takes precedence over ${ENV_TARGET}, ` +
242
+ `which is where this panel saves. Remove it from ${resolved.file} first.`,
243
+ );
244
+ }
245
+ // A key in `.env` can be overridden by writing `.env.local`, but removing
246
+ // a line from `.env.local` cannot unset it — so saving works and clearing
247
+ // does not. A Clear button that leaves the key working is worse than one
248
+ // that says why it cannot.
249
+ return resolved.file === '.env'
250
+ ? {
251
+ writable: true,
252
+ clearable: false,
253
+ reason:
254
+ `An access key is also set in .env. Saving here writes ${ENV_TARGET}, ` +
255
+ 'which takes precedence — remove the .env one when you are ready.',
256
+ }
257
+ : { writable: true, clearable: true };
258
+ // A legacy stored key, or nothing configured at all.
259
+ default:
260
+ return { writable: true, clearable: true };
261
+ }
262
+ }
263
+
264
+ /** A legacy key is sitting in the settings file while something else wins, so
265
+ * the next save has something to clean up and the panel has something to say. */
266
+ export async function hasStaleStoredKey(root: string, resolved: ResolvedKey): Promise<boolean> {
267
+ if (resolved.source === 'file') return false;
268
+ return Boolean((await readSettingsFile(root)).unsplash?.accessKey?.trim());
269
+ }
270
+
271
+ export type KeySaveResult =
272
+ | { ok: false; reason: string }
273
+ | { ok: true; file: string; migrated: boolean; staleStoredKey?: true };
274
+
275
+ /**
276
+ * Store (or, with an empty string, clear) the access key.
277
+ *
278
+ * **Env first, settings file second, always.** The order is the failure model:
279
+ *
280
+ * - The patch is refused → nothing has touched disk, and nothing will.
281
+ * - The `.env.local` write throws → nothing changed. The legacy key, if there
282
+ * is one, still resolves and the panel repaints unchanged.
283
+ * - The strip throws → the key is in *both* files. `.env.local` outranks the
284
+ * settings file, so the feature works and what is left behind is a stale
285
+ * secret rather than a broken save; it is reported as `staleStoredKey`, the
286
+ * panel nudges, and the next save retries the strip.
287
+ *
288
+ * Stripping first would risk the opposite: a key in neither file.
289
+ */
290
+ export async function saveUnsplashKey(
291
+ root: string,
292
+ accessKey: string,
293
+ writeText?: TextWriter,
294
+ ): Promise<KeySaveResult> {
295
+ const write: TextWriter =
296
+ writeText ?? ((target, content, _original, mode) => atomicWrite(target, content, mode));
297
+ const target = join(root, ENV_TARGET);
298
+
299
+ const before = await readIfPresent(target);
300
+ const patched = upsertEnvVar(before ?? '', ENV_VAR, accessKey);
301
+ if (!patched.ok) return { ok: false, reason: patched.reason };
302
+
303
+ if (patched.action !== 'unchanged') {
304
+ await write(target, patched.text, before, SECRET_MODE);
305
+ }
306
+
307
+ // Migration. Only ever a removal, so a failure here can lose nothing.
308
+ const current = await readSettingsFile(root);
309
+ if (current.unsplash?.accessKey === undefined) return { ok: true, file: ENV_TARGET, migrated: false };
310
+
311
+ const { unsplash: _legacy, ...rest } = current;
312
+ try {
313
+ await writeSettingsFile(root, rest, writeText);
314
+ } catch {
315
+ return { ok: true, file: ENV_TARGET, migrated: false, staleStoredKey: true };
316
+ }
317
+ return { ok: true, file: ENV_TARGET, migrated: true };
318
+ }
319
+
320
+ /**
321
+ * The stored option document, or `{}` when nothing is stored. Degrades on every
322
+ * failure path exactly as {@link readSettingsFile} does, so a corrupt file makes
323
+ * the panel's changes vanish rather than breaking every endpoint that resolves
324
+ * options.
325
+ */
326
+ export async function readStoredOptions(root: string): Promise<StoredOptions> {
327
+ const stored = (await readSettingsFile(root)).options;
328
+ return stored && typeof stored === 'object' ? stored : {};
329
+ }
330
+
331
+ /** Replace the stored option document, leaving anything else in the file alone.
332
+ * The caller has already merged the panel's sparse patch into `next` — see
333
+ * `options.ts::applyOptionPatch`. */
334
+ export async function saveStoredOptions(root: string, next: StoredOptions, writeText?: TextWriter): Promise<void> {
335
+ const current = await readSettingsFile(root);
336
+ await writeSettingsFile(root, { ...current, options: next }, writeText);
337
+ }
338
+
339
+ /** A fragment of the key, for recognition only — never enough to use. Eight
340
+ * bullets regardless of length, so the mask leaks nothing about the real one. */
341
+ export function maskKey(key: string): string {
342
+ return '••••••••' + key.slice(-4);
343
+ }
344
+
345
+ /**
346
+ * Which of the files this integration writes are **not** covered by the
347
+ * project's `.gitignore`, in the order the panel should name them.
348
+ *
349
+ * Best-effort and deliberately not a glob engine: it compares whole lines
350
+ * against a list of the patterns projects actually write. Erring toward a false
351
+ * warning is the right direction when the alternative is a committed secret —
352
+ * but only up to a point. `.env*` is what Astro's own starters ship, so
353
+ * treating it as "not covered" would put a permanent, undismissable warning on
354
+ * nearly every real project, and a warning nobody can clear is one everybody
355
+ * learns to ignore.
356
+ *
357
+ * `.env.local` is checked only once it exists, so a project that has never
358
+ * saved a key is not nagged about a file it does not have. The settings file is
359
+ * checked unconditionally: it appears the moment any tab saves an option.
360
+ *
361
+ * This integration cannot edit a consuming project's ignore rules, which is why
362
+ * this reports rather than fixes.
363
+ */
364
+ export async function uncoveredSecretFiles(root: string): Promise<string[]> {
365
+ let lines: string[] = [];
366
+ try {
367
+ lines = (await readFile(join(root, '.gitignore'), 'utf8')).split('\n').map((line) => line.trim());
368
+ } catch {
369
+ lines = []; // no .gitignore at all — definitely not covered
370
+ }
371
+
372
+ const covered = (file: string, patterns: string[]) =>
373
+ [file, '/' + file, ...patterns].some((pattern) => lines.includes(pattern));
374
+
375
+ const uncovered: string[] = [];
376
+ if ((await readIfPresent(join(root, ENV_TARGET))) !== null &&
377
+ !covered(ENV_TARGET, ['.env*', '.env.*', '.env*.local', '*.local'])) {
378
+ uncovered.push(ENV_TARGET);
379
+ }
380
+ if (!covered(SETTINGS_FILE, ['.astro-dev-edit.*', '.astro-*'])) uncovered.push(SETTINGS_FILE);
381
+ return uncovered;
382
+ }