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.
- package/LICENSE +21 -0
- package/README.md +125 -0
- package/package.json +52 -0
- package/src/client/admin-bar.ts +622 -0
- package/src/client/api.ts +370 -0
- package/src/client/classify-cache.ts +61 -0
- package/src/client/css-inspect.ts +345 -0
- package/src/client/editors/asset-picker.ts +155 -0
- package/src/client/editors/body-editor.ts +419 -0
- package/src/client/editors/collections-panel.ts +1532 -0
- package/src/client/editors/copy-panel.ts +73 -0
- package/src/client/editors/drawer.ts +95 -0
- package/src/client/editors/entry.ts +433 -0
- package/src/client/editors/expression.ts +77 -0
- package/src/client/editors/fields.ts +309 -0
- package/src/client/editors/image.ts +268 -0
- package/src/client/editors/markup-insert.ts +73 -0
- package/src/client/editors/markup.ts +125 -0
- package/src/client/editors/media-grid.ts +326 -0
- package/src/client/editors/media-modal.ts +588 -0
- package/src/client/editors/notice.ts +160 -0
- package/src/client/editors/peek.ts +135 -0
- package/src/client/editors/settings-panel.ts +457 -0
- package/src/client/editors/source-popup.ts +166 -0
- package/src/client/editors/text.ts +105 -0
- package/src/client/editors/unsplash-pane.ts +317 -0
- package/src/client/element-context.ts +308 -0
- package/src/client/features.ts +81 -0
- package/src/client/focus.ts +166 -0
- package/src/client/group.ts +186 -0
- package/src/client/highlight.ts +146 -0
- package/src/client/hover.ts +485 -0
- package/src/client/icons.ts +160 -0
- package/src/client/markdown.ts +319 -0
- package/src/client/overlay.ts +466 -0
- package/src/client/page-source.ts +143 -0
- package/src/client/router.ts +198 -0
- package/src/client/shadow.ts +111 -0
- package/src/client/source-map.ts +150 -0
- package/src/client/state.ts +153 -0
- package/src/client/styles.ts +3485 -0
- package/src/client/tree-model.ts +45 -0
- package/src/client/tree.ts +366 -0
- package/src/client/ui.ts +987 -0
- package/src/client/unsplash-search.ts +250 -0
- package/src/index.ts +299 -0
- package/src/patcher/astro.ts +792 -0
- package/src/patcher/content-config.ts +1035 -0
- package/src/patcher/dotenv.ts +121 -0
- package/src/patcher/expression-trace.ts +326 -0
- package/src/patcher/frontmatter.ts +249 -0
- package/src/patcher/registry.ts +11 -0
- package/src/patcher/types.ts +32 -0
- package/src/server/annotate.ts +173 -0
- package/src/server/assets.ts +167 -0
- package/src/server/collection-entries.ts +91 -0
- package/src/server/content-config.ts +210 -0
- package/src/server/editor.ts +15 -0
- package/src/server/entry-detect.ts +110 -0
- package/src/server/entry-resolve-routes.ts +218 -0
- package/src/server/entry-routes.ts +304 -0
- package/src/server/inspect-locate.ts +81 -0
- package/src/server/inspect-routes.ts +94 -0
- package/src/server/middleware.ts +480 -0
- package/src/server/options.ts +778 -0
- package/src/server/page-source-routes.ts +71 -0
- package/src/server/paths.ts +219 -0
- package/src/server/private-files.ts +116 -0
- package/src/server/route-manifest.ts +200 -0
- package/src/server/router.ts +94 -0
- package/src/server/schema-introspect.ts +233 -0
- package/src/server/schema-routes.ts +808 -0
- package/src/server/settings-routes.ts +246 -0
- package/src/server/settings.ts +382 -0
- package/src/server/text-writes.ts +105 -0
- package/src/server/unsplash-routes.ts +515 -0
- package/src/server/zod-adapt.ts +239 -0
- package/src/shared/asset-path.ts +132 -0
- package/src/shared/protocol.ts +935 -0
- package/src/shared/slug.ts +17 -0
- 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
|
+
}
|