@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.
- package/ai/skills/stacks-auto-imports/SKILL.md +1 -1
- package/ai/skills/stacks-dashboard/SKILL.md +90 -0
- package/ai/skills/stacks-orm/SKILL.md +1 -1
- package/ai/skills/stacks-storage/SKILL.md +58 -4
- package/app/Actions/Dashboard/Content/FileFavoriteAction.ts +25 -0
- package/app/Actions/Dashboard/Content/FileReprocessAction.ts +31 -0
- package/app/Actions/Dashboard/Content/FileTagsAction.ts +27 -0
- package/app/Actions/Dashboard/Content/file-manager.test.ts +47 -27
- package/app/Actions/Dashboard/Content/file-manager.ts +338 -12
- package/app/Actions/Dashboard/Content/file-metadata-store.ts +432 -0
- package/app/Actions/Dashboard/Content/file-metadata.test.ts +357 -0
- package/app/Actions/Dashboard/Content/file-metadata.ts +550 -0
- package/app/Actions/Dashboard/Content/file-pipeline.test.ts +344 -0
- package/app/Actions/Dashboard/Remote/RemoteCommandIndexAction.ts +29 -0
- package/app/Actions/Dashboard/Remote/RemoteCommandRunAction.ts +57 -0
- package/app/Actions/Dashboard/Remote/remote-commands.test.ts +272 -0
- package/app/Actions/Dashboard/Remote/remote-commands.ts +297 -0
- package/app/Actions/Dashboard/Remote/remote-routes.test.ts +58 -0
- package/app/Actions/Dashboard/Remote/ssh-runner.ts +94 -0
- package/app/Jobs/OptimizeStorageImageJob.ts +74 -0
- package/app/Jobs/TagStorageMediaJob.ts +122 -0
- package/app/Jobs/TranscodeStorageVideoJob.ts +88 -0
- package/app/Models/StorageItem.ts +123 -0
- package/app/Models/StorageItemTask.ts +134 -0
- package/ide/vscode/package.json +1 -1
- package/package.json +2 -2
- package/routes/dashboard-api.ts +25 -0
- package/vcs/github/workflows/buddy-bot.yml +109 -0
- package/vcs/github/workflows/ci.yml +3 -3
- package/vcs/github/workflows/release.yml +1 -1
- 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
|
+
})
|