@stacksjs/defaults 0.74.33 → 0.74.35

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 (31) hide show
  1. package/ai/skills/stacks-auto-imports/SKILL.md +1 -1
  2. package/ai/skills/stacks-dashboard/SKILL.md +90 -0
  3. package/ai/skills/stacks-orm/SKILL.md +1 -1
  4. package/ai/skills/stacks-storage/SKILL.md +58 -4
  5. package/app/Actions/Dashboard/Content/FileFavoriteAction.ts +25 -0
  6. package/app/Actions/Dashboard/Content/FileReprocessAction.ts +31 -0
  7. package/app/Actions/Dashboard/Content/FileTagsAction.ts +27 -0
  8. package/app/Actions/Dashboard/Content/file-manager.test.ts +47 -27
  9. package/app/Actions/Dashboard/Content/file-manager.ts +338 -12
  10. package/app/Actions/Dashboard/Content/file-metadata-store.ts +432 -0
  11. package/app/Actions/Dashboard/Content/file-metadata.test.ts +357 -0
  12. package/app/Actions/Dashboard/Content/file-metadata.ts +550 -0
  13. package/app/Actions/Dashboard/Content/file-pipeline.test.ts +344 -0
  14. package/app/Actions/Dashboard/Remote/RemoteCommandIndexAction.ts +29 -0
  15. package/app/Actions/Dashboard/Remote/RemoteCommandRunAction.ts +57 -0
  16. package/app/Actions/Dashboard/Remote/remote-commands.test.ts +272 -0
  17. package/app/Actions/Dashboard/Remote/remote-commands.ts +297 -0
  18. package/app/Actions/Dashboard/Remote/remote-routes.test.ts +58 -0
  19. package/app/Actions/Dashboard/Remote/ssh-runner.ts +94 -0
  20. package/app/Jobs/OptimizeStorageImageJob.ts +74 -0
  21. package/app/Jobs/TagStorageMediaJob.ts +122 -0
  22. package/app/Jobs/TranscodeStorageVideoJob.ts +88 -0
  23. package/app/Models/StorageItem.ts +123 -0
  24. package/app/Models/StorageItemTask.ts +134 -0
  25. package/ide/vscode/package.json +1 -1
  26. package/package.json +2 -2
  27. package/routes/dashboard-api.ts +25 -0
  28. package/vcs/github/workflows/buddy-bot.yml +109 -0
  29. package/vcs/github/workflows/ci.yml +3 -3
  30. package/vcs/github/workflows/release.yml +1 -1
  31. package/vcs/github/renovate.json +0 -5
@@ -0,0 +1,344 @@
1
+ // The media pipeline behind uploads (stacksjs/stacks#2578).
2
+ //
3
+ // What is tested here is the dispatch and the state, not the encoders. Whether
4
+ // `ts-images` produces a correct WebP is that package's business; what this
5
+ // layer has to get right is which work an upload calls for, that a queue being
6
+ // down leaves a visible failure rather than a silently unprocessed file, and
7
+ // that the state survives the file being renamed.
8
+ //
9
+ // The dispatcher is a recording function rather than a real queue, so these run
10
+ // without a worker.
11
+
12
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
13
+ import { mkdtemp, mkdir, rm } from 'node:fs/promises'
14
+ import { tmpdir } from 'node:os'
15
+ import { join } from 'node:path'
16
+ import { StorageManager } from '@stacksjs/storage'
17
+ import type { DashboardFileNode } from './file-manager'
18
+ import {
19
+ deleteDashboardFile,
20
+ dispatchDashboardFileTasks,
21
+ getDashboardFileSnapshot,
22
+ renameDashboardFile,
23
+ reprocessDashboardFile,
24
+ } from './file-manager'
25
+ import {
26
+ aggregateTaskState,
27
+ createMemoryMetadataStore,
28
+ describeError,
29
+ runTask,
30
+ tasksForContentType,
31
+ } from './file-metadata'
32
+
33
+ let root = ''
34
+ let manager: StorageManager
35
+ let store = createMemoryMetadataStore()
36
+ let dispatched: Array<{ job: string, payload: Record<string, unknown> }> = []
37
+ const dispatch = async (job: string, payload: Record<string, unknown>): Promise<void> => {
38
+ dispatched.push({ job, payload })
39
+ }
40
+
41
+ beforeEach(async () => {
42
+ store = createMemoryMetadataStore()
43
+ dispatched = []
44
+ root = await mkdtemp(join(tmpdir(), 'stacks-file-pipeline-'))
45
+ await mkdir(join(root, 'public'), { recursive: true })
46
+ manager = new StorageManager().init({
47
+ default: 'public',
48
+ disks: {
49
+ public: { driver: 'local', root: join(root, 'public'), url: '/storage', visibility: 'public' },
50
+ },
51
+ })
52
+ })
53
+
54
+ afterEach(async () => {
55
+ manager.reset()
56
+ await rm(root, { force: true, recursive: true })
57
+ })
58
+
59
+ function nodeAt(node: DashboardFileNode, path: string): DashboardFileNode | undefined {
60
+ if (node.path === path)
61
+ return node
62
+ for (const child of node.items ?? []) {
63
+ const found = nodeAt(child, path)
64
+ if (found)
65
+ return found
66
+ }
67
+ return undefined
68
+ }
69
+
70
+ describe('tasksForContentType', () => {
71
+ test('an image is optimized and tagged', () => {
72
+ expect(tasksForContentType('image/png')).toEqual(['optimize', 'tag'])
73
+ expect(tasksForContentType('image/jpeg; charset=binary')).toEqual(['optimize', 'tag'])
74
+ })
75
+
76
+ test('a video is transcoded and tagged', () => {
77
+ expect(tasksForContentType('video/mp4')).toEqual(['transcode', 'tag'])
78
+ })
79
+
80
+ test('SVG is left alone, because it is markup rather than a raster', () => {
81
+ // Nothing to re-encode, and running markup through an image decoder is a
82
+ // parser attack surface for no benefit.
83
+ expect(tasksForContentType('image/svg+xml')).toEqual([])
84
+ })
85
+
86
+ test('everything else calls for nothing', () => {
87
+ // Most uploads are documents. A queue entry per PDF that immediately finds
88
+ // nothing to do is noise in the one place somebody looks when a transcode
89
+ // is stuck.
90
+ expect(tasksForContentType('application/pdf')).toEqual([])
91
+ expect(tasksForContentType('text/plain')).toEqual([])
92
+ expect(tasksForContentType(undefined)).toEqual([])
93
+ expect(tasksForContentType('')).toEqual([])
94
+ })
95
+ })
96
+
97
+ describe('aggregateTaskState', () => {
98
+ test('is null when nothing was ever dispatched', () => {
99
+ // Not the same as work that finished: an image uploaded before
100
+ // optimization existed should not claim to have been optimized.
101
+ expect(aggregateTaskState([])).toBeNull()
102
+ })
103
+
104
+ test('reports the worst state, because a failure is what needs surfacing', () => {
105
+ const done = { kind: 'optimize', state: 'done', attempts: 1 } as const
106
+ const failed = { kind: 'tag', state: 'failed', attempts: 2 } as const
107
+ const running = { kind: 'transcode', state: 'running', attempts: 1 } as const
108
+ const queued = { kind: 'tag', state: 'queued', attempts: 0 } as const
109
+
110
+ expect(aggregateTaskState([done, failed])).toBe('failed')
111
+ expect(aggregateTaskState([done, running])).toBe('running')
112
+ expect(aggregateTaskState([done, queued])).toBe('queued')
113
+ expect(aggregateTaskState([done])).toBe('done')
114
+ // A failure outranks work still in flight.
115
+ expect(aggregateTaskState([running, failed])).toBe('failed')
116
+ })
117
+ })
118
+
119
+ describe('dispatchDashboardFileTasks', () => {
120
+ test('queues the jobs an image calls for, and records them', async () => {
121
+ await manager.disk('public').write('photo.png', 'x')
122
+
123
+ const tasks = await dispatchDashboardFileTasks(
124
+ { path: 'photo.png', contentType: 'image/png' },
125
+ manager,
126
+ store,
127
+ dispatch,
128
+ )
129
+
130
+ expect(tasks.map(task => task.kind)).toEqual(['optimize', 'tag'])
131
+ expect(dispatched.map(entry => entry.job)).toEqual(['OptimizeStorageImageJob', 'TagStorageMediaJob'])
132
+ expect(dispatched[0]?.payload).toEqual({ disk: 'public', path: 'photo.png' })
133
+
134
+ const recorded = (await store.tasksUnder('public', '')).get('photo.png')
135
+ expect(recorded?.every(task => task.state === 'queued')).toBeTrue()
136
+ })
137
+
138
+ test('dispatches nothing for a content type with no work', async () => {
139
+ await manager.disk('public').write('notes.pdf', 'x')
140
+
141
+ expect(await dispatchDashboardFileTasks({ path: 'notes.pdf', contentType: 'application/pdf' }, manager, store, dispatch)).toEqual([])
142
+ expect(dispatched).toEqual([])
143
+ expect(await store.tasksUnder('public', '')).toEqual(new Map())
144
+ })
145
+
146
+ test('holds a transcode back until it has a profile to build a ladder from', async () => {
147
+ // A job that guesses the source dimensions builds renditions nobody asked
148
+ // for. Tagging still runs, because it needs nothing.
149
+ await manager.disk('public').write('clip.mp4', 'x')
150
+
151
+ const withoutProfile = await dispatchDashboardFileTasks({ path: 'clip.mp4', contentType: 'video/mp4' }, manager, store, dispatch)
152
+ expect(withoutProfile.map(task => task.kind)).toEqual(['tag'])
153
+
154
+ dispatched = []
155
+ const withProfile = await dispatchDashboardFileTasks(
156
+ { path: 'clip.mp4', contentType: 'video/mp4', videoProfile: { width: 1920, height: 1080 } },
157
+ manager,
158
+ store,
159
+ dispatch,
160
+ )
161
+ expect(withProfile.map(task => task.kind)).toEqual(['transcode', 'tag'])
162
+ expect(dispatched[0]?.payload.profile).toEqual({ width: 1920, height: 1080 })
163
+ })
164
+
165
+ test('records a failure when the queue refuses, rather than losing the work', async () => {
166
+ // The whole reason the state is in a table the dashboard reads: a queue
167
+ // that is down should leave a visible failure, not a file that silently
168
+ // never gets processed.
169
+ await manager.disk('public').write('photo.png', 'x')
170
+
171
+ const tasks = await dispatchDashboardFileTasks(
172
+ { path: 'photo.png', contentType: 'image/png' },
173
+ manager,
174
+ store,
175
+ async () => { throw new Error('queue unavailable') },
176
+ )
177
+
178
+ expect(tasks.every(task => task.state === 'failed')).toBeTrue()
179
+ expect(tasks[0]?.error).toBe('queue unavailable')
180
+ })
181
+
182
+ test('narrows to the kinds asked for', async () => {
183
+ await manager.disk('public').write('photo.png', 'x')
184
+
185
+ const tasks = await dispatchDashboardFileTasks(
186
+ { path: 'photo.png', contentType: 'image/png', only: ['tag'] },
187
+ manager,
188
+ store,
189
+ dispatch,
190
+ )
191
+
192
+ expect(tasks.map(task => task.kind)).toEqual(['tag'])
193
+ expect(dispatched.map(entry => entry.job)).toEqual(['TagStorageMediaJob'])
194
+ })
195
+ })
196
+
197
+ describe('runTask', () => {
198
+ test('moves a task through running to done, counting the attempt', async () => {
199
+ await store.writeTask('public', 'a.png', { kind: 'optimize', state: 'queued', attempts: 0 })
200
+
201
+ const result = await runTask(store, 'public', 'a.png', 'optimize', async () => 'built')
202
+ expect(result).toBe('built')
203
+
204
+ const task = (await store.tasksUnder('public', '')).get('a.png')?.[0]
205
+ expect(task?.state).toBe('done')
206
+ expect(task?.attempts).toBe(1)
207
+ expect(task?.startedAt).toBeTruthy()
208
+ expect(task?.finishedAt).toBeTruthy()
209
+ })
210
+
211
+ test('records the failure and rethrows, so the queue still retries', async () => {
212
+ // The row and the queue answer different questions: the queue decides
213
+ // whether to try again, the row is what somebody looking at the file sees.
214
+ expect(runTask(store, 'public', 'a.png', 'optimize', async () => {
215
+ throw new Error('decode failed')
216
+ })).rejects.toThrow('decode failed')
217
+
218
+ await Bun.sleep(0)
219
+ const task = (await store.tasksUnder('public', '')).get('a.png')?.[0]
220
+ expect(task?.state).toBe('failed')
221
+ expect(task?.error).toBe('decode failed')
222
+ })
223
+
224
+ test('a retry that succeeds clears the previous error', async () => {
225
+ // A green file carrying a red message from two attempts ago is worse than
226
+ // no message.
227
+ await runTask(store, 'public', 'a.png', 'optimize', async () => { throw new Error('transient') }).catch(() => {})
228
+ await runTask(store, 'public', 'a.png', 'optimize', async () => 'ok')
229
+
230
+ const task = (await store.tasksUnder('public', '')).get('a.png')?.[0]
231
+ expect(task?.state).toBe('done')
232
+ expect(task?.error).toBeUndefined()
233
+ expect(task?.attempts).toBe(2)
234
+ })
235
+ })
236
+
237
+ describe('describeError', () => {
238
+ test('bounds what can reach the column', () => {
239
+ expect(describeError(new Error('boom'))).toBe('boom')
240
+ expect(describeError('a string')).toBe('a string')
241
+ expect(describeError(new Error('x'.repeat(5000)))).toHaveLength(2000)
242
+ })
243
+ })
244
+
245
+ describe('the snapshot reports processing', () => {
246
+ test('a queued file shows as queued, and names its tasks', async () => {
247
+ await manager.disk('public').write('photo.png', 'x')
248
+ await dispatchDashboardFileTasks({ path: 'photo.png', contentType: 'image/png' }, manager, store, dispatch)
249
+
250
+ const snapshot = await getDashboardFileSnapshot({}, manager, store)
251
+ const node = nodeAt(snapshot.root, 'photo.png')
252
+
253
+ expect(node?.processing).toBe('queued')
254
+ expect(node?.tasks.map(task => task.kind)).toEqual(['optimize', 'tag'])
255
+ })
256
+
257
+ test('a file nobody processed reports null, not done', async () => {
258
+ await manager.disk('public').write('notes.txt', 'x')
259
+
260
+ const snapshot = await getDashboardFileSnapshot({}, manager, store)
261
+ expect(nodeAt(snapshot.root, 'notes.txt')?.processing).toBeNull()
262
+ })
263
+ })
264
+
265
+ describe('tasks follow the file', () => {
266
+ test('a rename carries them, so a finished transcode is not lost', async () => {
267
+ await manager.disk('public').write('clip.mp4', 'x')
268
+ await store.writeTask('public', 'clip.mp4', { kind: 'transcode', state: 'done', attempts: 1 })
269
+
270
+ await renameDashboardFile({ path: 'clip.mp4', name: 'final.mp4' }, manager, store)
271
+
272
+ const tasks = await store.tasksUnder('public', '')
273
+ expect(tasks.get('clip.mp4')).toBeUndefined()
274
+ expect(tasks.get('final.mp4')?.[0]?.state).toBe('done')
275
+ })
276
+
277
+ test('a folder rename carries the tasks beneath it', async () => {
278
+ await manager.disk('public').write('media/clip.mp4', 'x')
279
+ await store.writeTask('public', 'media/clip.mp4', { kind: 'transcode', state: 'done', attempts: 1 })
280
+
281
+ await renameDashboardFile({ path: 'media', name: 'video' }, manager, store)
282
+
283
+ expect((await store.tasksUnder('public', '')).has('video/clip.mp4')).toBeTrue()
284
+ })
285
+
286
+ test('a delete forgets them', async () => {
287
+ await manager.disk('public').write('clip.mp4', 'x')
288
+ await store.writeTask('public', 'clip.mp4', { kind: 'transcode', state: 'done', attempts: 1 })
289
+
290
+ await deleteDashboardFile({ path: 'clip.mp4' }, manager, store)
291
+
292
+ expect(await store.tasksUnder('public', '')).toEqual(new Map())
293
+ })
294
+
295
+ test('a completed listing sweeps the ones whose file is gone', async () => {
296
+ await manager.disk('public').write('a.txt', 'x')
297
+ await store.writeTask('public', 'gone.png', { kind: 'optimize', state: 'done', attempts: 1 })
298
+
299
+ await getDashboardFileSnapshot({}, manager, store)
300
+
301
+ expect((await store.tasksUnder('public', '')).has('gone.png')).toBeFalse()
302
+ })
303
+ })
304
+
305
+ describe('reprocessDashboardFile', () => {
306
+ test('re-runs every kind the file calls for', async () => {
307
+ await manager.disk('public').write('photo.png', 'x')
308
+ await store.writeTask('public', 'photo.png', { kind: 'optimize', state: 'failed', attempts: 3, error: 'old' })
309
+
310
+ const result = await reprocessDashboardFile({ path: 'photo.png' }, manager, store, dispatch)
311
+
312
+ expect(result.tasks.map(task => task.kind)).toEqual(['optimize', 'tag'])
313
+ // The failed task is reset to queued rather than left showing its old
314
+ // error beside a job that is about to run.
315
+ const optimize = (await store.tasksUnder('public', '')).get('photo.png')?.find(task => task.kind === 'optimize')
316
+ expect(optimize?.state).toBe('queued')
317
+ expect(optimize?.error).toBeUndefined()
318
+ })
319
+
320
+ test('re-runs one kind when asked, leaving the others as they were', async () => {
321
+ await manager.disk('public').write('photo.png', 'x')
322
+ await store.writeTask('public', 'photo.png', { kind: 'optimize', state: 'done', attempts: 1 })
323
+
324
+ await reprocessDashboardFile({ path: 'photo.png', kinds: ['tag'] }, manager, store, dispatch)
325
+
326
+ expect(dispatched.map(entry => entry.job)).toEqual(['TagStorageMediaJob'])
327
+ const optimize = (await store.tasksUnder('public', '')).get('photo.png')?.find(task => task.kind === 'optimize')
328
+ expect(optimize?.state).toBe('done')
329
+ })
330
+
331
+ test('404s for a file that is gone, rather than queueing work against nothing', async () => {
332
+ expect(reprocessDashboardFile({ path: 'missing.png' }, manager, store, dispatch))
333
+ .rejects.toThrow(/was not found/)
334
+ })
335
+
336
+ test('rejects a kind that is not one of the three', async () => {
337
+ await manager.disk('public').write('photo.png', 'x')
338
+
339
+ expect(reprocessDashboardFile({ path: 'photo.png', kinds: ['reticulate'] }, manager, store, dispatch))
340
+ .rejects.toThrow(/Unknown task kind/)
341
+ expect(reprocessDashboardFile({ path: 'photo.png', kinds: 'tag' }, manager, store, dispatch))
342
+ .rejects.toThrow(/must be an array/)
343
+ })
344
+ })
@@ -0,0 +1,29 @@
1
+ import { Action } from '@stacksjs/actions'
2
+ import { commands, hosts } from '~/config/remote'
3
+ import { response } from '@stacksjs/router'
4
+
5
+ export default new Action({
6
+ name: 'RemoteCommandIndexAction',
7
+ description: 'Lists the hosts and operations the dashboard is configured to run.',
8
+ method: 'GET',
9
+ async handle() {
10
+ // The registry, not the credentials. `identityFile` and `knownHosts` are
11
+ // deliberately not returned: a listing endpoint should not disclose which
12
+ // key file a server uses or the fingerprint an attacker would need to
13
+ // impersonate.
14
+ return response.json({
15
+ hosts: hosts.map(host => ({
16
+ key: host.key,
17
+ host: host.host,
18
+ user: host.user,
19
+ port: host.port ?? 22,
20
+ })),
21
+ commands: commands.map(command => ({
22
+ key: command.key,
23
+ description: command.description,
24
+ argv: command.argv,
25
+ hosts: command.hosts ?? null,
26
+ })),
27
+ })
28
+ },
29
+ })
@@ -0,0 +1,57 @@
1
+ import type { UserModel } from '@stacksjs/orm'
2
+ import type { RequestInstance } from '@stacksjs/types'
3
+ import { Action } from '@stacksjs/actions'
4
+ import { Gate } from '@stacksjs/auth'
5
+ import { commands, hosts } from '~/config/remote'
6
+ import { response } from '@stacksjs/router'
7
+ import { RemoteCommandError, resolveCommand, resolveHost, runRemoteCommand } from './remote-commands'
8
+ import { createSshRunner, loggingAuditSink } from './ssh-runner'
9
+
10
+ /**
11
+ * Run one configured operation on one configured host (stacksjs/stacks#960).
12
+ *
13
+ * Routed WITHOUT the dashboard's `guard()` helper. That helper drops auth
14
+ * entirely when `APP_ENV` is local, development or test, which for this surface
15
+ * would be an unauthenticated command runner on every developer machine
16
+ * reachable on the network. See `routes/dashboard-api.ts`.
17
+ */
18
+ export default new Action({
19
+ name: 'RemoteCommandRunAction',
20
+ description: 'Runs a configured operation on a configured host over SSH.',
21
+ method: 'POST',
22
+ async handle(request: RequestInstance) {
23
+ try {
24
+ // `request.user()` answers `AuthenticatedUser | undefined`; the gate and
25
+ // the audit identity both want the model or an explicit null.
26
+ const user = (await request.user() ?? null) as UserModel | null
27
+ const hostKey = request.get('host')
28
+ const commandKey = request.get('command')
29
+
30
+ // Resolved before authorization so the gate is asked about real keys
31
+ // rather than whatever the request said.
32
+ const host = resolveHost(hosts, hostKey)
33
+ const command = resolveCommand(commands, commandKey, host)
34
+
35
+ const result = await runRemoteCommand({ hostKey, commandKey }, {
36
+ user,
37
+ hosts,
38
+ commands,
39
+ // The gate receives both keys, so an application can scope by host, by
40
+ // command, or by both. Undefined when the app has not defined it, and
41
+ // `authorize` refuses in that case rather than proceeding.
42
+ authorizer: Gate.has('run-remote-command')
43
+ ? (candidate, forHost, forCommand) => Gate.allows('run-remote-command', candidate, forHost, forCommand)
44
+ : undefined,
45
+ run: createSshRunner(host, command),
46
+ audit: loggingAuditSink,
47
+ })
48
+
49
+ return response.json(result)
50
+ }
51
+ catch (error) {
52
+ if (error instanceof RemoteCommandError)
53
+ return response.json({ message: error.message }, error.status)
54
+ throw error
55
+ }
56
+ },
57
+ })