netlify-cli 27.11.2 → 27.12.1

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 (62) hide show
  1. package/dist/commands/base-command.js +2 -2
  2. package/dist/commands/deploy/deploy.js +28 -59
  3. package/dist/commands/dev/dev.js +1 -2
  4. package/dist/commands/env/env-clone.js +1 -1
  5. package/dist/commands/env/env-set.js +1 -1
  6. package/dist/commands/env/env-unset.js +1 -1
  7. package/dist/commands/functions/functions-create.js +3 -93
  8. package/dist/commands/init/index.js +6 -1
  9. package/dist/commands/init/init.d.ts +3 -1
  10. package/dist/commands/init/init.js +22 -2
  11. package/dist/commands/serve/serve.js +1 -2
  12. package/dist/commands/sites/sites-list.js +1 -1
  13. package/dist/commands/unlink/unlink.js +1 -1
  14. package/dist/lib/edge-functions/registry.js +0 -1
  15. package/dist/lib/exec-fetcher.js +1 -1
  16. package/dist/lib/functions/form-submissions-handler.js +2 -1
  17. package/dist/lib/spinner.d.ts +2 -2
  18. package/dist/lib/spinner.js +1 -1
  19. package/dist/recipes/ai-context/context.js +2 -2
  20. package/dist/utils/deploy/deploy-site.d.ts +32 -12
  21. package/dist/utils/deploy/deploy-site.js +18 -40
  22. package/dist/utils/deploy/drop-api.d.ts +7 -1
  23. package/dist/utils/deploy/drop-api.js +3 -4
  24. package/dist/utils/deploy/file.d.ts +4 -2
  25. package/dist/utils/deploy/hash-config.d.ts +5 -6
  26. package/dist/utils/deploy/hash-config.js +1 -4
  27. package/dist/utils/deploy/hash-files.d.ts +9 -8
  28. package/dist/utils/deploy/hash-fns.d.ts +45 -10
  29. package/dist/utils/deploy/hasher-segments.d.ts +10 -6
  30. package/dist/utils/deploy/hasher-segments.js +0 -1
  31. package/dist/utils/deploy/process-files.d.ts +1 -1
  32. package/dist/utils/deploy/process-files.js +1 -0
  33. package/dist/utils/deploy/types.d.ts +17 -0
  34. package/dist/utils/deploy/types.js +2 -0
  35. package/dist/utils/deploy/upload-files.d.ts +8 -3
  36. package/dist/utils/deploy/upload-files.js +4 -3
  37. package/dist/utils/deploy/upload-source-zip.js +4 -7
  38. package/dist/utils/deploy/util.d.ts +6 -3
  39. package/dist/utils/deploy/util.js +4 -16
  40. package/dist/utils/dev.d.ts +0 -1
  41. package/dist/utils/dev.js +1 -27
  42. package/dist/utils/env/index.js +1 -3
  43. package/dist/utils/get-repo-data.js +1 -1
  44. package/dist/utils/init/agent-skills.d.ts +96 -0
  45. package/dist/utils/init/agent-skills.js +653 -0
  46. package/dist/utils/parse-raw-flags.js +1 -1
  47. package/dist/utils/proxy-server.d.ts +1 -2
  48. package/dist/utils/proxy-server.js +1 -2
  49. package/dist/utils/proxy.d.ts +1 -1
  50. package/dist/utils/proxy.js +7 -35
  51. package/dist/utils/types.d.ts +1 -1
  52. package/functions-templates/javascript/scheduled-function/package.json +1 -1
  53. package/functions-templates/typescript/database/package-lock.json +8 -8
  54. package/functions-templates/typescript/database/package.json +1 -1
  55. package/functions-templates/typescript/database-drizzle/package-lock.json +8 -8
  56. package/functions-templates/typescript/database-drizzle/package.json +1 -1
  57. package/functions-templates/typescript/hello-world/package-lock.json +15 -17
  58. package/functions-templates/typescript/hello-world/package.json +1 -1
  59. package/functions-templates/typescript/scheduled-function/package.json +1 -1
  60. package/package.json +10 -11
  61. package/dist/utils/addons/prepare.d.ts +0 -13
  62. package/dist/utils/addons/prepare.js +0 -26
@@ -0,0 +1,653 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { promises as fs } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { getDrivingAgent } from '../agent-detection.js';
5
+ import { chalk, log, netlifyCommand, version, warn } from '../command-helpers.js';
6
+ export const DEFAULT_SKILLS_HOST = 'https://netlify-agent-skills.netlify.app';
7
+ export const SKILLS_HOST_ENV = 'NETLIFY_SKILLS_HOST';
8
+ export const DEFAULT_SKILLS_DIRECTORY = path.join('.agents', 'skills');
9
+ const AGENT_DIRECTORIES = ['.claude', '.agents', '.grok'];
10
+ const AGENT_DIRECTORY_BY_DRIVING_AGENT = {
11
+ claude: '.claude',
12
+ };
13
+ const SUPPORTED_SCHEMA_VERSION = 1;
14
+ const SKILL_NAME = /^[A-Za-z0-9_][A-Za-z0-9_.-]*$/;
15
+ const STAGING_PREFIX = '.netlify-skill-';
16
+ const STAGING_LEFTOVER = /^\.netlify-skill-(.+)-[A-Za-z0-9]{6}$/;
17
+ const RETIRED_LEFTOVER = /^(.+)\.old-\d+-[0-9a-f]{12}$/;
18
+ const FETCH_TIMEOUT_MS = 10_000;
19
+ const LEFTOVER_MIN_AGE_MS = 10 * 60_000;
20
+ const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
21
+ class SkillsError extends Error {
22
+ }
23
+ class SkillConflictError extends SkillsError {
24
+ }
25
+ const errorMessage = (error) => {
26
+ if (error instanceof Error && (error.name === 'TimeoutError' || error.name === 'AbortError')) {
27
+ return `the download timed out after ${String(FETCH_TIMEOUT_MS / 1000)}s`;
28
+ }
29
+ if (error instanceof Error) {
30
+ return error.cause instanceof Error ? `${error.message}: ${error.cause.message}` : error.message;
31
+ }
32
+ return String(error);
33
+ };
34
+ const sha256 = (bytes) => `sha256:${createHash('sha256').update(bytes).digest('hex')}`;
35
+ export const resolveSkillsHost = (env = process.env) => {
36
+ const raw = env[SKILLS_HOST_ENV];
37
+ if (!raw) {
38
+ return DEFAULT_SKILLS_HOST;
39
+ }
40
+ const url = new URL(raw);
41
+ const loopback = LOOPBACK_HOSTS.has(url.hostname);
42
+ if (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) {
43
+ throw new SkillsError(`${SKILLS_HOST_ENV} must be https:// (http:// is accepted for localhost only): ${raw}`);
44
+ }
45
+ return url.toString().replace(/\/$/, '');
46
+ };
47
+ const urlFor = (host, ...parts) => `${host}/${parts.map((part) => encodeURIComponent(part)).join('/')}`;
48
+ const fetchBytes = async (url) => {
49
+ const response = await fetch(url, {
50
+ headers: { 'user-agent': `NetlifyCLI ${version}` },
51
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
52
+ redirect: 'error',
53
+ });
54
+ if (!response.ok) {
55
+ throw new SkillsError(`${url}: HTTP ${response.status.toString()}`);
56
+ }
57
+ return new Uint8Array(await response.arrayBuffer());
58
+ };
59
+ function assertSkillName(name, what) {
60
+ if (typeof name !== 'string' || !SKILL_NAME.test(name)) {
61
+ throw new SkillsError(`manifest: invalid ${what} ${JSON.stringify(name)}`);
62
+ }
63
+ }
64
+ const isSafeFilePath = (file) => file.length > 0 &&
65
+ !file.includes('\\') &&
66
+ !path.posix.isAbsolute(file) &&
67
+ file.split('/').every((part) => part && part !== '.' && part !== '..');
68
+ const indexManifest = (manifest) => {
69
+ if (!Array.isArray(manifest.skills)) {
70
+ throw new SkillsError('manifest has no skills array');
71
+ }
72
+ const exact = new Map();
73
+ const prior = new Map();
74
+ for (const skill of manifest.skills) {
75
+ assertSkillName(skill.name, 'skill name');
76
+ if (exact.has(skill.name)) {
77
+ throw new SkillsError(`manifest: duplicate skill name ${JSON.stringify(skill.name)}`);
78
+ }
79
+ if (skill.status === 'active' && typeof skill.tree_hash !== 'string') {
80
+ throw new SkillsError(`manifest: active skill ${skill.name} has no tree_hash`);
81
+ }
82
+ exact.set(skill.name, skill);
83
+ for (const name of skill.prior_names ?? []) {
84
+ assertSkillName(name, `prior name of ${skill.name}`);
85
+ if (exact.has(name) || prior.has(name)) {
86
+ throw new SkillsError(`manifest: name ${JSON.stringify(name)} appears more than once`);
87
+ }
88
+ prior.set(name, skill);
89
+ }
90
+ }
91
+ for (const name of exact.keys()) {
92
+ if (prior.has(name)) {
93
+ throw new SkillsError(`manifest: name ${JSON.stringify(name)} is both a skill and a prior name`);
94
+ }
95
+ }
96
+ return { exact, prior };
97
+ };
98
+ const skillUnderName = ({ exact, prior }, name) => exact.get(name) ?? prior.get(name);
99
+ export const fetchSkillsManifest = async (host) => {
100
+ const url = urlFor(host, 'manifest.json');
101
+ const bytes = await fetchBytes(url);
102
+ let manifest;
103
+ try {
104
+ manifest = JSON.parse(Buffer.from(bytes).toString('utf8'));
105
+ }
106
+ catch {
107
+ throw new SkillsError(`${url}: invalid JSON`);
108
+ }
109
+ if (manifest.schema_version !== SUPPORTED_SCHEMA_VERSION) {
110
+ throw new SkillsError(`${url}: manifest schema ${String(manifest.schema_version)} is not supported by this CLI version; update the Netlify CLI`);
111
+ }
112
+ indexManifest(manifest);
113
+ return manifest;
114
+ };
115
+ const historyOf = (skill) => {
116
+ if (Array.isArray(skill.history) && skill.history.length > 0) {
117
+ return skill.history;
118
+ }
119
+ return skill.tree_hash ? [{ version: skill.version, tree_hash: skill.tree_hash }] : [];
120
+ };
121
+ const lastMatching = (skill, treeHash) => treeHash ? historyOf(skill).findLast((entry) => entry.tree_hash === treeHash) : undefined;
122
+ const executableOf = (skill) => new Set(skill.executable ?? []);
123
+ const listRegularFiles = async (dir) => {
124
+ const files = [];
125
+ const walk = async (current, prefix) => {
126
+ const entries = await fs.readdir(current, { withFileTypes: true });
127
+ for (const entry of entries) {
128
+ const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
129
+ if (entry.isDirectory()) {
130
+ await walk(path.join(current, entry.name), relative);
131
+ }
132
+ else if (entry.isFile()) {
133
+ files.push(relative);
134
+ }
135
+ else {
136
+ throw new SkillsError(`${path.join(current, entry.name)}: not a regular file`);
137
+ }
138
+ }
139
+ };
140
+ await walk(dir, '');
141
+ return files.sort();
142
+ };
143
+ const isExecutable = async (absolute, relative, declared) => {
144
+ if (process.platform === 'win32') {
145
+ return declared?.has(relative) ?? false;
146
+ }
147
+ return ((await fs.stat(absolute)).mode & 0o111) !== 0;
148
+ };
149
+ export const hashSkillTree = async (dir, declaredExecutable) => {
150
+ const hash = createHash('sha256');
151
+ for (const relative of await listRegularFiles(dir)) {
152
+ const absolute = path.join(dir, ...relative.split('/'));
153
+ const bytes = await fs.readFile(absolute);
154
+ const mode = (await isExecutable(absolute, relative, declaredExecutable)) ? '100755' : '100644';
155
+ hash.update(`${relative}\0${mode}\0${sha256(bytes).replace(/^sha256:/, '')}\n`);
156
+ }
157
+ return `sha256:${hash.digest('hex')}`;
158
+ };
159
+ const isFile = async (file) => {
160
+ try {
161
+ return (await fs.lstat(file)).isFile();
162
+ }
163
+ catch {
164
+ return false;
165
+ }
166
+ };
167
+ const isDirectory = async (dir) => {
168
+ try {
169
+ return (await fs.lstat(dir)).isDirectory();
170
+ }
171
+ catch {
172
+ return false;
173
+ }
174
+ };
175
+ const exists = async (file) => {
176
+ try {
177
+ await fs.lstat(file);
178
+ return true;
179
+ }
180
+ catch {
181
+ return false;
182
+ }
183
+ };
184
+ const isDirectoryOrLinkToOne = async (dir) => {
185
+ try {
186
+ return (await fs.stat(dir)).isDirectory();
187
+ }
188
+ catch {
189
+ return false;
190
+ }
191
+ };
192
+ const isOlderThan = async (file, ageMs) => {
193
+ try {
194
+ return Date.now() - (await fs.lstat(file)).mtimeMs >= ageMs;
195
+ }
196
+ catch {
197
+ return false;
198
+ }
199
+ };
200
+ const isSameEntry = async (a, b) => {
201
+ try {
202
+ const [statA, statB] = await Promise.all([fs.lstat(a), fs.lstat(b)]);
203
+ return statA.ino !== 0 && statA.ino === statB.ino && statA.dev === statB.dev;
204
+ }
205
+ catch {
206
+ return false;
207
+ }
208
+ };
209
+ const sortByName = (entries) => [...entries].sort((a, b) => a.name.localeCompare(b.name, 'en'));
210
+ const hashIfPossible = async (dir, skill) => {
211
+ try {
212
+ return await hashSkillTree(dir, executableOf(skill));
213
+ }
214
+ catch {
215
+ return null;
216
+ }
217
+ };
218
+ const isUneditedRelease = async (dir, skill) => {
219
+ if (!(await isDirectory(dir))) {
220
+ return false;
221
+ }
222
+ return lastMatching(skill, await hashIfPossible(dir, skill)) !== undefined;
223
+ };
224
+ export const classifySkillsDirectory = async (root, manifest) => {
225
+ const { exact, prior } = indexManifest(manifest);
226
+ const records = [];
227
+ const presentActive = new Set();
228
+ let entries = [];
229
+ if (await isDirectoryOrLinkToOne(root)) {
230
+ entries = sortByName(await fs.readdir(root, { withFileTypes: true }));
231
+ }
232
+ for (const entry of entries) {
233
+ if (entry.name.startsWith(STAGING_PREFIX))
234
+ continue;
235
+ const known = exact.get(entry.name);
236
+ const renamed = prior.get(entry.name);
237
+ const target = known?.status === 'active' ? known : renamed?.status === 'active' ? renamed : null;
238
+ const retired = known?.status === 'deprecated' ? known : renamed?.status === 'deprecated' ? renamed : null;
239
+ if (!entry.isDirectory() && !target && !retired)
240
+ continue;
241
+ const dir = path.join(root, entry.name);
242
+ const hasSkillMd = entry.isDirectory() && (await isFile(path.join(dir, 'SKILL.md')));
243
+ if (!hasSkillMd && known?.status !== 'active')
244
+ continue;
245
+ if (entry.isDirectory() && !hasSkillMd && known?.status === 'active' && (await fs.readdir(dir)).length === 0) {
246
+ continue;
247
+ }
248
+ const subject = target ?? retired;
249
+ const treeHash = hasSkillMd && subject ? await hashIfPossible(dir, subject) : null;
250
+ if (retired) {
251
+ records.push({
252
+ name: entry.name,
253
+ status: 'deprecated',
254
+ replacedBy: retired.deprecated?.replaced_by ?? null,
255
+ modified: lastMatching(retired, treeHash) === undefined,
256
+ });
257
+ continue;
258
+ }
259
+ if (!target) {
260
+ records.push({ name: entry.name, status: 'unknown' });
261
+ continue;
262
+ }
263
+ const match = lastMatching(target, treeHash);
264
+ if (target === renamed) {
265
+ records.push({ name: entry.name, status: 'renamed', currentName: target.name, modified: match === undefined });
266
+ continue;
267
+ }
268
+ presentActive.add(target.name);
269
+ if (treeHash === target.tree_hash) {
270
+ records.push({ name: entry.name, status: 'current', version: target.version });
271
+ }
272
+ else if (match) {
273
+ records.push({ name: entry.name, status: 'stale', version: target.version, have: match.version });
274
+ }
275
+ else {
276
+ records.push({ name: entry.name, status: 'modified', version: target.version });
277
+ }
278
+ }
279
+ const missing = manifest.skills
280
+ .filter((skill) => skill.status === 'active' && !presentActive.has(skill.name))
281
+ .map((skill) => skill.name)
282
+ .sort();
283
+ return { skills: records, missing };
284
+ };
285
+ const replaceDirectory = async (staged, target) => {
286
+ let existing = await fs.lstat(target).catch(() => null);
287
+ if (existing && !existing.isDirectory()) {
288
+ await fs.rm(target, { force: true });
289
+ existing = null;
290
+ }
291
+ const retired = `${target}.old-${process.pid.toString()}-${randomBytes(6).toString('hex')}`;
292
+ if (existing) {
293
+ await fs.rename(target, retired);
294
+ }
295
+ try {
296
+ await fs.rename(staged, target);
297
+ }
298
+ catch (error) {
299
+ if (existing) {
300
+ await fs.rename(retired, target);
301
+ }
302
+ throw error;
303
+ }
304
+ if (existing) {
305
+ await fs.rm(retired, { recursive: true, force: true }).catch(() => undefined);
306
+ }
307
+ };
308
+ const isReplaceableCopy = async (target, skill) => {
309
+ if (!(await isDirectory(target))) {
310
+ return !(await exists(target));
311
+ }
312
+ if ((await fs.readdir(target)).length === 0) {
313
+ return true;
314
+ }
315
+ return isUneditedRelease(target, skill);
316
+ };
317
+ const downloadSkill = async (host, skill) => {
318
+ const downloaded = [];
319
+ for (const file of Object.keys(skill.files ?? {}).sort()) {
320
+ if (!isSafeFilePath(file)) {
321
+ throw new SkillsError(`${skill.name}: unsafe manifest file path: ${file}`);
322
+ }
323
+ let bytes;
324
+ try {
325
+ bytes = await fetchBytes(urlFor(host, 'skills', skill.name, ...file.split('/')));
326
+ }
327
+ catch (error) {
328
+ throw new SkillsError(`${skill.name}/${file}: ${errorMessage(error)}`);
329
+ }
330
+ if (sha256(bytes) !== skill.files?.[file]) {
331
+ throw new SkillsError(`${skill.name}/${file}: hash mismatch`);
332
+ }
333
+ downloaded.push([file, bytes]);
334
+ }
335
+ return downloaded;
336
+ };
337
+ export const installSkill = async (host, dest, skill, { force = false } = {}) => {
338
+ const downloaded = await downloadSkill(host, skill);
339
+ const target = path.join(dest, skill.name);
340
+ if (!force && !(await isReplaceableCopy(target, skill))) {
341
+ throw new SkillConflictError(`${target} already exists and is not an unedited Netlify skill; left in place`);
342
+ }
343
+ await fs.mkdir(dest, { recursive: true });
344
+ const staged = await fs.mkdtemp(path.join(dest, `${STAGING_PREFIX}${skill.name}-`));
345
+ try {
346
+ const executable = executableOf(skill);
347
+ for (const [file, bytes] of downloaded) {
348
+ const output = path.join(staged, ...file.split('/'));
349
+ await fs.mkdir(path.dirname(output), { recursive: true });
350
+ await fs.writeFile(output, bytes, { mode: executable.has(file) ? 0o755 : 0o644 });
351
+ }
352
+ const stagedHash = await hashSkillTree(staged, executable);
353
+ if (stagedHash !== skill.tree_hash) {
354
+ throw new SkillsError(`staged tree hash ${stagedHash} does not match the manifest`);
355
+ }
356
+ if (!force && !(await isReplaceableCopy(target, skill))) {
357
+ throw new SkillConflictError(`${target} changed while downloading; left in place`);
358
+ }
359
+ await replaceDirectory(staged, target);
360
+ }
361
+ catch (error) {
362
+ await fs.rm(staged, { recursive: true, force: true });
363
+ if (error instanceof SkillConflictError)
364
+ throw error;
365
+ throw new SkillsError(`${skill.name}: could not install: ${errorMessage(error)}`);
366
+ }
367
+ return downloaded.length;
368
+ };
369
+ const removeInstallLeftovers = async (root, index) => {
370
+ if (!(await isDirectoryOrLinkToOne(root))) {
371
+ return [];
372
+ }
373
+ const removed = [];
374
+ for (const entry of sortByName(await fs.readdir(root, { withFileTypes: true }))) {
375
+ if (!entry.isDirectory())
376
+ continue;
377
+ const leftover = path.join(root, entry.name);
378
+ const staging = STAGING_LEFTOVER.exec(entry.name);
379
+ if (staging) {
380
+ if (skillUnderName(index, staging[1]) && (await isOlderThan(leftover, LEFTOVER_MIN_AGE_MS))) {
381
+ await fs.rm(leftover, { recursive: true, force: true });
382
+ removed.push(entry.name);
383
+ }
384
+ continue;
385
+ }
386
+ const retired = RETIRED_LEFTOVER.exec(entry.name);
387
+ const skill = retired ? skillUnderName(index, retired[1]) : undefined;
388
+ if (!retired || !skill)
389
+ continue;
390
+ const skillPresent = (await exists(path.join(root, retired[1]))) || (await exists(path.join(root, skill.name)));
391
+ if (skillPresent &&
392
+ (await isOlderThan(leftover, LEFTOVER_MIN_AGE_MS)) &&
393
+ (await isUneditedRelease(leftover, skill))) {
394
+ await fs.rm(leftover, { recursive: true, force: true });
395
+ removed.push(entry.name);
396
+ }
397
+ }
398
+ return removed;
399
+ };
400
+ export const syncSkills = async ({ host, directory, manifest, reset = false, }) => {
401
+ const index = indexManifest(manifest);
402
+ const actions = [];
403
+ const act = (name, action, detail) => {
404
+ actions.push(detail ? { name, action, detail } : { name, action });
405
+ };
406
+ const skillByName = (name) => {
407
+ const skill = skillUnderName(index, name);
408
+ if (!skill) {
409
+ throw new SkillsError(`manifest: unknown skill ${name}`);
410
+ }
411
+ return skill;
412
+ };
413
+ const stillUnedited = async (name, skill) => reset || isUneditedRelease(path.join(directory, name), skill);
414
+ const removeUnlessSame = async (name, keep) => {
415
+ if (await isSameEntry(path.join(directory, name), path.join(directory, keep)))
416
+ return;
417
+ await fs.rm(path.join(directory, name), { recursive: true, force: true });
418
+ };
419
+ const occupantOf = async (name) => {
420
+ if (!(await isDirectoryOrLinkToOne(directory)))
421
+ return undefined;
422
+ for (const entry of await fs.readdir(directory)) {
423
+ if (entry === name)
424
+ continue;
425
+ if (await isSameEntry(path.join(directory, entry), path.join(directory, name)))
426
+ return entry;
427
+ }
428
+ return undefined;
429
+ };
430
+ for (const leftover of await removeInstallLeftovers(directory, index)) {
431
+ act(leftover, 'removed', 'leftover from an interrupted install');
432
+ }
433
+ const before = await classifySkillsDirectory(directory, manifest);
434
+ const installed = new Set();
435
+ const settled = new Set();
436
+ const install = async (skill, options = {}) => {
437
+ if (settled.has(skill.name))
438
+ return false;
439
+ const occupant = await occupantOf(skill.name);
440
+ if (occupant) {
441
+ settled.add(skill.name);
442
+ act(skill.name, 'kept', `${occupant} already uses this name; left in place`);
443
+ return false;
444
+ }
445
+ try {
446
+ await installSkill(host, directory, skill, options);
447
+ installed.add(skill.name);
448
+ return true;
449
+ }
450
+ catch (error) {
451
+ settled.add(skill.name);
452
+ if (error instanceof SkillConflictError) {
453
+ act(skill.name, 'kept', error.message);
454
+ }
455
+ else {
456
+ act(skill.name, 'failed', errorMessage(error));
457
+ }
458
+ return false;
459
+ }
460
+ };
461
+ for (const record of before.skills) {
462
+ switch (record.status) {
463
+ case 'current':
464
+ act(record.name, 'current', record.version ?? undefined);
465
+ break;
466
+ case 'stale': {
467
+ const skill = skillByName(record.name);
468
+ if (await install(skill)) {
469
+ act(record.name, 'updated', `${record.have ?? 'unknown'} -> ${skill.version ?? 'latest'}`);
470
+ }
471
+ break;
472
+ }
473
+ case 'modified': {
474
+ if (!reset) {
475
+ act(record.name, 'kept', 'edited locally');
476
+ break;
477
+ }
478
+ const target = path.join(directory, record.name);
479
+ if ((await isDirectory(target)) && !(await isFile(path.join(target, 'SKILL.md')))) {
480
+ act(record.name, 'kept', 'has no SKILL.md, so it is not a Netlify skill; left in place');
481
+ break;
482
+ }
483
+ const skill = skillByName(record.name);
484
+ if (await install(skill, { force: true })) {
485
+ act(record.name, 'reset', `edited copy replaced with ${skill.version ?? 'latest'}`);
486
+ }
487
+ break;
488
+ }
489
+ case 'renamed': {
490
+ const skill = skillByName(record.currentName);
491
+ const current = before.skills.find((other) => other.name === record.currentName);
492
+ const currentPresent = current !== undefined || installed.has(record.currentName);
493
+ if (!reset && record.modified) {
494
+ act(record.name, 'kept', `edited locally; now called ${record.currentName}`);
495
+ break;
496
+ }
497
+ if (!reset && current?.status === 'modified') {
498
+ act(record.name, 'kept', `${record.currentName} is already installed and edited locally`);
499
+ break;
500
+ }
501
+ const priorDir = path.join(directory, record.name);
502
+ const currentDir = path.join(directory, record.currentName);
503
+ if (!currentPresent && (await isSameEntry(priorDir, currentDir))) {
504
+ await fs.rename(priorDir, currentDir);
505
+ act(record.name, 'renamed', `-> ${record.currentName}`);
506
+ if ((await hashIfPossible(currentDir, skill)) !== skill.tree_hash &&
507
+ (await install(skill, { force: reset }))) {
508
+ if (record.modified) {
509
+ act(record.currentName, 'reset', `edited copy replaced with ${skill.version ?? 'latest'}`);
510
+ }
511
+ else {
512
+ act(record.currentName, 'updated', `-> ${skill.version ?? 'latest'}`);
513
+ }
514
+ }
515
+ installed.add(record.currentName);
516
+ break;
517
+ }
518
+ if (!currentPresent && !(await install(skill))) {
519
+ act(record.name, 'kept', `now called ${record.currentName}, which could not be installed`);
520
+ break;
521
+ }
522
+ if (!(await stillUnedited(record.name, skill))) {
523
+ act(record.name, 'kept', `edited locally; now called ${record.currentName}`);
524
+ break;
525
+ }
526
+ await removeUnlessSame(record.name, record.currentName);
527
+ if (currentPresent) {
528
+ act(record.name, 'removed', `superseded by ${record.currentName}`);
529
+ }
530
+ else {
531
+ act(record.name, 'renamed', `-> ${record.currentName}`);
532
+ }
533
+ break;
534
+ }
535
+ case 'deprecated': {
536
+ const retired = skillByName(record.name);
537
+ const replacement = record.replacedBy ? `; use ${record.replacedBy}` : '';
538
+ if ((record.modified && !reset) || !(await stillUnedited(record.name, retired))) {
539
+ act(record.name, 'kept', `deprecated${replacement}, but edited locally`);
540
+ break;
541
+ }
542
+ await fs.rm(path.join(directory, record.name), { recursive: true, force: true });
543
+ act(record.name, 'removed', `deprecated${replacement}`);
544
+ break;
545
+ }
546
+ case 'unknown':
547
+ act(record.name, 'ignored', 'not a Netlify skill');
548
+ break;
549
+ }
550
+ }
551
+ for (const name of before.missing) {
552
+ if (installed.has(name))
553
+ continue;
554
+ const skill = skillByName(name);
555
+ if (await install(skill)) {
556
+ act(name, 'added', skill.version ?? undefined);
557
+ }
558
+ }
559
+ return { directory, actions };
560
+ };
561
+ export const resolveSkillsDirectories = async (workingDir, env = process.env) => {
562
+ const present = [];
563
+ for (const agentDirectory of AGENT_DIRECTORIES) {
564
+ if (await isDirectoryOrLinkToOne(path.join(workingDir, agentDirectory))) {
565
+ present.push(path.join(agentDirectory, 'skills'));
566
+ }
567
+ }
568
+ if (present.length > 0) {
569
+ return present;
570
+ }
571
+ const drivingAgent = getDrivingAgent(env);
572
+ const agentDirectory = drivingAgent ? AGENT_DIRECTORY_BY_DRIVING_AGENT[drivingAgent.name] : undefined;
573
+ return [agentDirectory ? path.join(agentDirectory, 'skills') : DEFAULT_SKILLS_DIRECTORY];
574
+ };
575
+ const summarize = (actions) => {
576
+ const summary = {
577
+ current: 0,
578
+ added: 0,
579
+ updated: 0,
580
+ reset: 0,
581
+ renamed: 0,
582
+ removed: 0,
583
+ kept: 0,
584
+ ignored: 0,
585
+ failed: 0,
586
+ };
587
+ for (const { action } of actions) {
588
+ summary[action] += 1;
589
+ }
590
+ return summary;
591
+ };
592
+ const describeSync = ({ directory, actions }) => {
593
+ const summary = summarize(actions);
594
+ const location = chalk.underline(directory);
595
+ const changed = summary.added + summary.updated + summary.reset + summary.renamed + summary.removed;
596
+ const parts = [
597
+ summary.added > 0 ? `${summary.added.toString()} added` : '',
598
+ summary.updated > 0 ? `${summary.updated.toString()} updated` : '',
599
+ summary.reset > 0 ? `${summary.reset.toString()} reset` : '',
600
+ summary.renamed > 0 ? `${summary.renamed.toString()} renamed` : '',
601
+ summary.removed > 0 ? `${summary.removed.toString()} removed` : '',
602
+ summary.kept > 0 ? `${summary.kept.toString()} kept` : '',
603
+ summary.failed > 0 ? `${summary.failed.toString()} failed` : '',
604
+ ].filter(Boolean);
605
+ if (changed + summary.failed === 0) {
606
+ return `Netlify skills in ${location} are up to date${parts.length > 0 ? ` (${parts.join(', ')})` : ''}.`;
607
+ }
608
+ if (changed === 0) {
609
+ return summary.current + summary.kept > 0
610
+ ? `Some Netlify skills in ${location} could not be synced (${parts.join(', ')}).`
611
+ : `Could not sync Netlify skills in ${location} (${parts.join(', ')}).`;
612
+ }
613
+ const verb = changed === summary.added && summary.failed === 0 ? 'Installed' : 'Synced';
614
+ return `${verb} Netlify skills in ${location} (${parts.join(', ')}).`;
615
+ };
616
+ const logSync = (directory, result, reset) => {
617
+ log(describeSync({ directory, actions: result.actions }));
618
+ const listed = result.actions.filter(({ action }) => action === 'kept' || action === 'failed');
619
+ for (const { name, action, detail } of listed) {
620
+ log(` ${chalk.dim(name)}: ${detail ?? action}`);
621
+ }
622
+ if (!reset && listed.some(({ action, detail }) => action === 'kept' && detail?.includes('edited locally'))) {
623
+ log(` Run ${chalk.cyanBright.bold(`${netlifyCommand()} init --reset-context`)} to replace edited Netlify skills with the latest release.`);
624
+ }
625
+ };
626
+ export const setupAgentSkills = async ({ workingDir, env = process.env, reset = false, }) => {
627
+ let directories = [];
628
+ try {
629
+ directories = await resolveSkillsDirectories(workingDir, env);
630
+ const host = resolveSkillsHost(env);
631
+ const manifest = await fetchSkillsManifest(host);
632
+ const actions = [];
633
+ for (const directory of directories) {
634
+ const result = await syncSkills({ host, directory: path.resolve(workingDir, directory), manifest, reset });
635
+ actions.push(...result.actions);
636
+ logSync(directory, result, reset);
637
+ }
638
+ const summary = summarize(actions);
639
+ return {
640
+ installed: summary.current + summary.added + summary.updated + summary.reset + summary.renamed + summary.kept > 0,
641
+ directories,
642
+ skillsVersion: manifest.version,
643
+ summary,
644
+ };
645
+ }
646
+ catch (error) {
647
+ const message = errorMessage(error);
648
+ warn(`Could not set up Netlify skills for AI agents: ${message}`);
649
+ log(`Run ${chalk.cyanBright.bold(`${netlifyCommand()} init`)} again later, or use ${chalk.cyan('--skip-agent-setup')} to opt out.`);
650
+ return { installed: false, directories, summary: summarize([]), error: message };
651
+ }
652
+ };
653
+ //# sourceMappingURL=agent-skills.js.map
@@ -3,7 +3,7 @@
3
3
  // Needed for commands using Command.strict = false
4
4
  //
5
5
  // Usage:
6
- // const { flags, raw } = this.parse(addonsCreateCommand)
6
+ // const { flags, raw } = this.parse(someCommand)
7
7
  // // flags = {}
8
8
  // const rawFlags = parseRawFlags(raw)
9
9
  // // rawFlags = {stuff: yay!}
@@ -12,9 +12,8 @@ interface InspectSettings {
12
12
  address?: string;
13
13
  }
14
14
  export declare const generateInspectSettings: (edgeInspect: boolean | string, edgeInspectBrk: boolean | string) => InspectSettings;
15
- export declare const startProxyServer: ({ accountId, addonsUrls, aiGatewayContext, api, blobsContext, command, config, configPath, debug, disableEdgeFunctions, env, functionsRegistry, geoCountry, geolocationMode, getUpdatedConfig, inspectSettings, offline, projectDir, repositoryRoot, settings, site, siteInfo, state, watchIgnore, deployEnvironment, }: {
15
+ export declare const startProxyServer: ({ accountId, aiGatewayContext, api, blobsContext, command, config, configPath, debug, disableEdgeFunctions, env, functionsRegistry, geoCountry, geolocationMode, getUpdatedConfig, inspectSettings, offline, projectDir, repositoryRoot, settings, site, siteInfo, state, watchIgnore, deployEnvironment, }: {
16
16
  accountId: string | undefined;
17
- addonsUrls: $TSFixMe;
18
17
  aiGatewayContext?: AIGatewayContext | null;
19
18
  api?: NetlifyOptions['api'];
20
19
  blobsContext?: BlobsContextWithEdgeAccess;
@@ -17,9 +17,8 @@ export const generateInspectSettings = (edgeInspect, edgeInspectBrk) => {
17
17
  address: getAddress(),
18
18
  };
19
19
  };
20
- export const startProxyServer = async ({ accountId, addonsUrls, aiGatewayContext, api, blobsContext, command, config, configPath, debug, disableEdgeFunctions, env, functionsRegistry, geoCountry, geolocationMode, getUpdatedConfig, inspectSettings, offline, projectDir, repositoryRoot, settings, site, siteInfo, state, watchIgnore, deployEnvironment, }) => {
20
+ export const startProxyServer = async ({ accountId, aiGatewayContext, api, blobsContext, command, config, configPath, debug, disableEdgeFunctions, env, functionsRegistry, geoCountry, geolocationMode, getUpdatedConfig, inspectSettings, offline, projectDir, repositoryRoot, settings, site, siteInfo, state, watchIgnore, deployEnvironment, }) => {
21
21
  const url = await startProxy({
22
- addonsUrls,
23
22
  aiGatewayContext,
24
23
  blobsContext,
25
24
  command,