@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
@@ -66,7 +66,7 @@ globalThis.toggleDark = toggleDark
66
66
  - **Custom Functions**: From `resources/functions/` (counter, dark mode, GPX, geo utilities)
67
67
 
68
68
  ### Server Auto-Imports (100+)
69
- - **All ORM Models**: User, Post, Author, Product, Order, Payment, Customer, etc. (100 models)
69
+ - **All ORM Models**: User, Post, Author, Product, Order, Payment, Customer, etc. (102 models)
70
70
  - **Request Models**: UserRequest, PostRequest, OrderRequest, etc.
71
71
  - **Actions**: Action types and helpers
72
72
  - **Schema**: validation schema builder
@@ -61,6 +61,96 @@ dashboard data Actions.
61
61
  - `/content/comments` - comment moderation
62
62
  - `/content/files`, `/content/blog`, `/content/seo` - files, blog operations, and SEO
63
63
 
64
+ ### The file manager's two layers (stacksjs/stacks#2577)
65
+
66
+ Worth knowing before adding anything to it, because the split is not obvious
67
+ from the endpoints:
68
+
69
+ - **Storage operations** map to a `StorageAdapter` method and go straight to the
70
+ disk: list, upload, create folder, rename, visibility, duplicate, delete.
71
+ - **Metadata** - favourites and tags - has nowhere to live on a disk (extended
72
+ attributes do not survive a copy; S3 object metadata is set at write time, so
73
+ starring a 2 GB video would rewrite 2 GB). It lives in `storage_items`, keyed
74
+ by `(disk, path)`, written by `PUT /files/favorite` and `PUT /files/tags`.
75
+
76
+ **The disk is authoritative and the table is advisory.** The listing comes from
77
+ the disk and rows are joined onto it, so a path with no row is a file with
78
+ nothing recorded - which is most files. Renames and deletes made THROUGH the
79
+ dashboard reconcile eagerly (a folder is a prefix update, because moving a
80
+ folder moves everything under it); a completed listing sweeps rows for paths it
81
+ did not see, which is free because the walk already enumerated them. A TRUNCATED
82
+ listing sweeps nothing - it has not proved a path is absent.
83
+
84
+ A file renamed outside the dashboard loses its metadata, and that is by design:
85
+ a rename and a copy-then-delete are the same two events to a bucket listing, so
86
+ reconciling would be guessing.
87
+
88
+ ### The media pipeline (stacksjs/stacks#2578)
89
+
90
+ None of the three things an upload might need can happen inside the request: a
91
+ transcode is minutes, a vision call is a round trip to a third party. So an
92
+ upload dispatches and the dashboard shows state.
93
+
94
+ - `storage_item_tasks`, one row per `(disk, path, kind)`, kind being
95
+ `optimize` (images, via `ts-images`), `transcode` (video, via `ts-videos`) or
96
+ `tag` (a vision model). They succeed and fail independently, which is why this
97
+ is not a column on `storage_items` - a video whose transcode finished and
98
+ whose tagging failed is a normal state.
99
+ - `dispatchDashboardFileTasks` decides from the CONTENT TYPE what a file needs.
100
+ Most uploads are documents and get nothing. A transcode waits for a video
101
+ profile, because the ladder is derived from the source dimensions.
102
+ - A dispatch failure is RECORDED, not thrown: a queue that is down leaves a
103
+ visible failure rather than an upload that fails or a file that is silently
104
+ never processed.
105
+ - `runTask` owns the queued -> running -> done/failed transitions so the three
106
+ jobs cannot disagree about them. It rethrows after recording, because the row
107
+ and the queue answer different questions - the queue decides whether to retry,
108
+ the row is what somebody looking at the file sees.
109
+ - `POST /files/reprocess` re-runs everything, or the kinds you name.
110
+
111
+ Derivatives are written back to the same disk under `.variants/<path>/`. The
112
+ leading dot keeps them out of the listing, which skips hidden components - a
113
+ folder of thirty derivatives beside every photo makes the browser useless.
114
+
115
+ ### Remote commands (stacksjs/stacks#960)
116
+
117
+ Running a configured operation on a configured host over SSH. Deliberately NOT
118
+ a terminal: the request names a host KEY and a command KEY, both from
119
+ `config/remote.ts`, so there is nothing to escape and no shell to reach. An
120
+ interactive session is tracked separately - `Bun.spawn` has no PTY, and
121
+ `ssh -tt` gives a remote one but cannot propagate a window resize.
122
+
123
+ Four things make it safe to expose, and each is a rule to keep:
124
+
125
+ - **Host keys are verified.** `StrictHostKeyChecking=yes` against the host's
126
+ declared `knownHosts`. Do NOT reuse `sshExec` from `@stacksjs/ts-cloud` for
127
+ anything long-lived: it disables host key checking on purpose, for boxes a
128
+ minute old whose keys cannot be known.
129
+ - **Hosts and commands come from config, never the request.** A `RemoteCommand`
130
+ carries an `argv` ARRAY that is never interpolated.
131
+ - **The routes do NOT use `guard()`.** That helper drops auth entirely under
132
+ `APP_ENV=local|development|test`, which here would be an unauthenticated
133
+ command runner on any dev machine on the network. They use
134
+ `authenticatedGuard`, and `remote-routes.test.ts` asserts it.
135
+ - **Authorization fails CLOSED.** The `run-remote-command` gate receives the
136
+ host and command keys; with no gate defined, every run is refused. This is the
137
+ opposite of the websocket authenticator in `@stacksjs/realtime`, which
138
+ proceeds when none is installed.
139
+
140
+ Runs are recorded before AND after - a run recorded only on completion loses the
141
+ command that hung and the one whose process died with the box. The audit sink
142
+ writes to the application log rather than the dashboard's own database, which is
143
+ the thing an operator with dashboard access could edit.
144
+
145
+ **There is no ffmpeg.** #2578 asked whether video was in scope given the
146
+ external binary, its licensing and its provisioning; `@stacksjs/video` is built
147
+ on `ts-videos`, which encodes itself, so that question was already answered.
148
+
149
+ Tags go through `taggables` + `taggable_models` with `taggable_type =
150
+ 'storage_items'` - the trait the CMS already uses. Do NOT declare a
151
+ `belongsToMany` to the `Tag` model for this: `taggable_models.tag_id` resolves
152
+ against `taggables`, which is a different table from `tags`.
153
+
64
154
  ### Data Management
65
155
  - `/data/dashboard` - data overview
66
156
  - `/data/users` - user management
@@ -11,7 +11,7 @@ allowed-tools: Read Edit Write Bash Grep Glob
11
11
  ## Key Paths
12
12
  - Core ORM package: `storage/framework/core/orm/src/`
13
13
  - ORM implementation: `storage/framework/orm/`
14
- - Model definitions: `storage/framework/defaults/app/Models/` (100 models)
14
+ - Model definitions: `storage/framework/defaults/app/Models/` (102 models)
15
15
  - Application models: `app/Models/`
16
16
  - Default model templates: `storage/framework/defaults/app/Models/`
17
17
  - ORM type globals: `storage/framework/types/orm-globals.d.ts`
@@ -8,7 +8,7 @@ allowed-tools: Read Edit Write Bash Grep Glob
8
8
 
9
9
  # Stacks Storage
10
10
 
11
- File system abstraction with a Laravel-style Storage facade, local/S3 adapters, file upload handling, and low-level file utilities.
11
+ File system abstraction with a Laravel-style Storage facade, local/S3/Azure adapters, file upload handling, and low-level file utilities.
12
12
 
13
13
  ## Key Paths
14
14
  - Core package: `storage/framework/core/storage/src/`
@@ -18,6 +18,8 @@ File system abstraction with a Laravel-style Storage facade, local/S3 adapters,
18
18
  - Filesystem config types: `storage/framework/core/storage/src/types/filesystem.ts`
19
19
  - Local adapter: `storage/framework/core/storage/src/adapters/local.ts`
20
20
  - S3 adapter: `storage/framework/core/storage/src/adapters/s3.ts`
21
+ - Azure adapter: `storage/framework/core/storage/src/adapters/azure.ts`
22
+ - Azure request signing: `storage/framework/core/storage/src/azure-signing.ts`
21
23
  - Memory adapter: `storage/framework/core/storage/src/adapters/memory.ts`
22
24
  - Bun adapter: `storage/framework/core/storage/src/adapters/bun.ts`
23
25
  - File utilities: `storage/framework/core/storage/src/files.ts`
@@ -45,7 +47,7 @@ import { createLocalStorage, LocalStorageAdapter } from '@stacksjs/storage'
45
47
  import { createS3Storage, S3StorageAdapter } from '@stacksjs/storage'
46
48
 
47
49
  // Config helpers
48
- import { localDisk, s3Disk, configFromEnv } from '@stacksjs/storage'
50
+ import { localDisk, s3Disk, azureDisk, r2Disk, gcsDisk, filebaseDisk, backblazeDisk, hetznerDisk, configFromEnv } from '@stacksjs/storage'
49
51
 
50
52
  // Types
51
53
  import type { StorageAdapter, FileContents, StatEntry, DirectoryEntry, DirectoryListing } from '@stacksjs/storage'
@@ -233,6 +235,46 @@ const s3 = new S3StorageAdapter(client, { bucket: 'my-bucket', region: 'us-east-
233
235
  - `list()` supports pagination via continuation tokens; `deep: true` uses `listAllObjects()`
234
236
  - `fileExists()` uses `headObject()` and catches 404/NoSuchKey/NotFound errors
235
237
 
238
+ Every S3-COMPATIBLE provider is an `s3` disk with a different endpoint, and has
239
+ a preset that fills it in: `r2Disk(bucket, accountId)`, `gcsDisk(bucket)`,
240
+ `filebaseDisk(bucket)`, `backblazeDisk(bucket, region)`,
241
+ `hetznerDisk(bucket, location)`. R2 additionally needs `url` set, because its
242
+ API host never serves public objects.
243
+
244
+ ### Azure Adapter (`AzureBlobStorageAdapter`)
245
+
246
+ ```typescript
247
+ import { azureDisk, AzureBlobStorageAdapter } from '@stacksjs/storage'
248
+
249
+ const azure = new AzureBlobStorageAdapter({
250
+ account: 'mystorageaccount',
251
+ accountKey: process.env.AZURE_STORAGE_ACCOUNT_KEY,
252
+ container: 'uploads',
253
+ prefix: 'tenant-7',
254
+ })
255
+ ```
256
+
257
+ Its own driver rather than an `s3` disk with an endpoint, because Azure is the
258
+ one provider with no S3-compatible API.
259
+
260
+ - Speaks the blob REST API over `fetch`; no SDK, and no lazy-client dance
261
+ - Shared Key authorization for server-side calls, service SAS for signed URLs,
262
+ both in `azure-signing.ts` and tested there directly
263
+ - `changeVisibility()` THROWS -- Azure has no per-blob ACL. Public access is a
264
+ container property, so `visibility()` reports the container's level, and a
265
+ per-object grant is `signedUrl()`
266
+ - `putStream()` stages blocks (Put Block) and commits one Put Block List, so the
267
+ write is atomic; block ids are zero-padded to a fixed width because Azure
268
+ rejects unequal-length ids
269
+ - `getStream()` is genuinely incremental, unlike the S3 adapter's buffered one
270
+ - `createDirectory()` is a no-op and `deleteDirectory()` deletes by prefix, same
271
+ as S3
272
+ - `endpoint` defaults to `https://<account>.blob.core.windows.net`; point it at
273
+ `http://127.0.0.1:10000/devstoreaccount1` for Azurite
274
+ - A `sasToken` disk can read and write but cannot sign URLs -- minting a SAS
275
+ needs the account key, and re-serving the configured token would hand out its
276
+ full grant
277
+
236
278
  ## File Uploads (UploadedFile)
237
279
 
238
280
  ```typescript
@@ -490,8 +532,8 @@ interface ChecksumOptions {
490
532
  }
491
533
 
492
534
  // Filesystem config types
493
- type FilesystemDriver = 'local' | 's3'
494
- type DiskConfig = LocalDiskConfig | S3DiskConfig
535
+ type FilesystemDriver = 'local' | 's3' | 'azure'
536
+ type DiskConfig = LocalDiskConfig | S3DiskConfig | AzureDiskConfig
495
537
 
496
538
  interface LocalDiskConfig {
497
539
  driver: 'local'
@@ -512,6 +554,18 @@ interface S3DiskConfig {
512
554
  visibility?: 'public' | 'private'
513
555
  }
514
556
 
557
+ interface AzureDiskConfig {
558
+ driver: 'azure'
559
+ account: string
560
+ container: string
561
+ accountKey?: string
562
+ sasToken?: string
563
+ prefix?: string
564
+ url?: string
565
+ endpoint?: string
566
+ visibility?: 'public' | 'private'
567
+ }
568
+
515
569
  interface FilesystemConfig {
516
570
  default: string
517
571
  disks: Record<string, DiskConfig>
@@ -0,0 +1,25 @@
1
+ import type { RequestInstance } from '@stacksjs/types'
2
+ import { Action } from '@stacksjs/actions'
3
+ import { response } from '@stacksjs/router'
4
+ import { DashboardFileError, setDashboardFileFavorite } from './file-manager'
5
+
6
+ export default new Action({
7
+ name: 'FileFavoriteAction',
8
+ description: 'Stars or unstars a file or directory on a configured storage disk.',
9
+ method: 'PUT',
10
+ async handle(request: RequestInstance) {
11
+ try {
12
+ const result = await setDashboardFileFavorite({
13
+ disk: String(request.get('disk', 'public')),
14
+ path: request.get('path'),
15
+ favorite: request.get('favorite'),
16
+ })
17
+ return response.json(result)
18
+ }
19
+ catch (error) {
20
+ if (error instanceof DashboardFileError)
21
+ return response.json({ message: error.message, fields: error.fields }, error.status)
22
+ throw error
23
+ }
24
+ },
25
+ })
@@ -0,0 +1,31 @@
1
+ import type { RequestInstance } from '@stacksjs/types'
2
+ import { Action } from '@stacksjs/actions'
3
+ import { response } from '@stacksjs/router'
4
+ import { DashboardFileError, reprocessDashboardFile } from './file-manager'
5
+
6
+ export default new Action({
7
+ name: 'FileReprocessAction',
8
+ description: 'Re-runs the background processing for a file: image variants, video renditions, AI tags.',
9
+ method: 'POST',
10
+ async handle(request: RequestInstance) {
11
+ try {
12
+ const result = await reprocessDashboardFile({
13
+ disk: String(request.get('disk', 'public')),
14
+ path: request.get('path'),
15
+ // Omit to run every kind the file's content type calls for; name kinds
16
+ // to re-run just one, which is what a failed transcode beside a
17
+ // successful tagging wants.
18
+ kinds: request.get('kinds'),
19
+ // A transcode derives its ladder from the source dimensions, so it only
20
+ // runs when the caller supplies them.
21
+ videoProfile: request.get('profile'),
22
+ })
23
+ return response.json(result)
24
+ }
25
+ catch (error) {
26
+ if (error instanceof DashboardFileError)
27
+ return response.json({ message: error.message, fields: error.fields }, error.status)
28
+ throw error
29
+ }
30
+ },
31
+ })
@@ -0,0 +1,27 @@
1
+ import type { RequestInstance } from '@stacksjs/types'
2
+ import { Action } from '@stacksjs/actions'
3
+ import { response } from '@stacksjs/router'
4
+ import { DashboardFileError, setDashboardFileTags } from './file-manager'
5
+
6
+ export default new Action({
7
+ name: 'FileTagsAction',
8
+ description: 'Replaces the tags on a file or directory on a configured storage disk.',
9
+ method: 'PUT',
10
+ async handle(request: RequestInstance) {
11
+ try {
12
+ const result = await setDashboardFileTags({
13
+ disk: String(request.get('disk', 'public')),
14
+ path: request.get('path'),
15
+ // The whole set, not a delta: a UI that can add a tag can also remove
16
+ // one, and there is no separate signal for a removal.
17
+ tags: request.get('tags'),
18
+ })
19
+ return response.json(result)
20
+ }
21
+ catch (error) {
22
+ if (error instanceof DashboardFileError)
23
+ return response.json({ message: error.message, fields: error.fields }, error.status)
24
+ throw error
25
+ }
26
+ },
27
+ })
@@ -3,6 +3,7 @@ import { mkdir, mkdtemp, rm } from 'node:fs/promises'
3
3
  import { tmpdir } from 'node:os'
4
4
  import { join } from 'node:path'
5
5
  import { StorageManager } from '@stacksjs/storage'
6
+ import { createMemoryMetadataStore } from './file-metadata'
6
7
  import {
7
8
  createDashboardDirectory,
8
9
  deleteDashboardFile,
@@ -18,8 +19,27 @@ import {
18
19
 
19
20
  let root = ''
20
21
  let manager: StorageManager
22
+ /**
23
+ * The metadata layer, in memory (stacksjs/stacks#2577).
24
+ *
25
+ * These tests build a real disk in a temp directory; the metadata store is the
26
+ * one collaborator that would otherwise pull a database in, so it is injected
27
+ * the same way the storage manager already is.
28
+ */
29
+ let store = createMemoryMetadataStore()
30
+ /**
31
+ * A dispatcher that records instead of queueing (stacksjs/stacks#2578). These
32
+ * tests are about the storage operations, not the pipeline, and a real queue
33
+ * would make them depend on a worker.
34
+ */
35
+ let dispatched: Array<{ job: string, payload: Record<string, unknown> }> = []
36
+ const dispatch = async (job: string, payload: Record<string, unknown>): Promise<void> => {
37
+ dispatched.push({ job, payload })
38
+ }
21
39
 
22
40
  beforeEach(async () => {
41
+ store = createMemoryMetadataStore()
42
+ dispatched = []
23
43
  root = await mkdtemp(join(tmpdir(), 'stacks-dashboard-files-'))
24
44
  await mkdir(join(root, 'public'), { recursive: true })
25
45
  manager = new StorageManager().init({
@@ -47,7 +67,7 @@ describe('dashboard file manager', () => {
47
67
  await disk.write('images/logo.png', new Uint8Array([137, 80, 78, 71]))
48
68
  await disk.write('.hidden/secret.txt', 'not listed')
49
69
 
50
- const snapshot = await getDashboardFileSnapshot({}, manager)
70
+ const snapshot = await getDashboardFileSnapshot({}, manager, store)
51
71
  const documents = snapshot.root.items?.find(item => item.path === 'documents')
52
72
  const readme = documents?.items?.find(item => item.path === 'documents/readme.txt')
53
73
 
@@ -74,7 +94,7 @@ describe('dashboard file manager', () => {
74
94
  await manager.disk('public').write('one.txt', '1')
75
95
  await manager.disk('public').write('two.txt', '2')
76
96
 
77
- const snapshot = await getDashboardFileSnapshot({ maxEntries: 1 }, manager)
97
+ const snapshot = await getDashboardFileSnapshot({ maxEntries: 1 }, manager, store)
78
98
 
79
99
  expect(snapshot.truncated).toBe(true)
80
100
  expect(snapshot.stats.files).toBe(1)
@@ -94,7 +114,7 @@ describe('dashboard file manager', () => {
94
114
  throw new Error('metadata unavailable')
95
115
  }
96
116
 
97
- await expect(getDashboardFileSnapshot({}, manager))
117
+ await expect(getDashboardFileSnapshot({}, manager, store))
98
118
  .rejects
99
119
  .toThrow('Metadata for storage file "unreadable.txt" could not be read')
100
120
  })
@@ -106,7 +126,7 @@ describe('dashboard file manager', () => {
106
126
  throw new Error('URL unavailable')
107
127
  }
108
128
 
109
- const snapshot = await getDashboardFileSnapshot({}, manager)
129
+ const snapshot = await getDashboardFileSnapshot({}, manager, store)
110
130
  expect(snapshot.stats.files).toBe(1)
111
131
  expect(snapshot.warnings).toEqual(['Public URL for "document.txt" could not be resolved.'])
112
132
  })
@@ -116,10 +136,10 @@ describe('dashboard file manager', () => {
116
136
  expect(await manager.disk('public').directoryExists('Product shots')).toBe(true)
117
137
 
118
138
  await manager.disk('public').write('Product shots/photo.jpg', 'photo')
119
- await deleteDashboardFile({ path: 'Product shots/photo.jpg' }, manager)
139
+ await deleteDashboardFile({ path: 'Product shots/photo.jpg' }, manager, store)
120
140
  expect(await manager.disk('public').fileExists('Product shots/photo.jpg')).toBe(false)
121
141
 
122
- await deleteDashboardFile({ path: 'Product shots' }, manager)
142
+ await deleteDashboardFile({ path: 'Product shots' }, manager, store)
123
143
  expect(await manager.disk('public').directoryExists('Product shots')).toBe(false)
124
144
  })
125
145
 
@@ -130,14 +150,14 @@ describe('dashboard file manager', () => {
130
150
  bytes: async () => new TextEncoder().encode(contents),
131
151
  })
132
152
 
133
- const uploaded = await uploadDashboardFiles({ path: 'documents', files: [file('first')] }, manager)
153
+ const uploaded = await uploadDashboardFiles({ path: 'documents', files: [file('first')] }, manager, store, dispatch)
134
154
  expect(uploaded[0]).toMatchObject({
135
155
  path: 'documents/release_notes.txt',
136
156
  url: '/storage/documents/release_notes.txt',
137
157
  size: 5,
138
158
  })
139
159
 
140
- await expect(uploadDashboardFiles({ path: 'documents', files: [file('second')] }, manager))
160
+ await expect(uploadDashboardFiles({ path: 'documents', files: [file('second')] }, manager, store, dispatch))
141
161
  .rejects
142
162
  .toThrow('File already exists: documents/release_notes.txt')
143
163
  expect(await manager.disk('public').readToString('documents/release_notes.txt')).toBe('first')
@@ -153,7 +173,7 @@ describe('dashboard file manager', () => {
153
173
 
154
174
  await expect(uploadDashboardFiles({
155
175
  files: [file('new.txt', 'new'), file('duplicate.txt', 'replacement')],
156
- }, manager)).rejects.toThrow('No files from this upload were kept')
176
+ }, manager, store, dispatch)).rejects.toThrow('No files from this upload were kept')
157
177
 
158
178
  expect(await manager.disk('public').fileExists('new.txt')).toBe(false)
159
179
  expect(await manager.disk('public').readToString('duplicate.txt')).toBe('existing')
@@ -172,7 +192,7 @@ describe('renameDashboardFile', () => {
172
192
  const disk = manager.disk('public')
173
193
  await disk.write('documents/readme.txt', 'hello')
174
194
 
175
- const result = await renameDashboardFile({ path: 'documents/readme.txt', name: 'guide.txt' }, manager)
195
+ const result = await renameDashboardFile({ path: 'documents/readme.txt', name: 'guide.txt' }, manager, store)
176
196
 
177
197
  expect(result).toEqual({ from: 'documents/readme.txt', to: 'documents/guide.txt', type: 'file', moved: 1 })
178
198
  expect(await disk.fileExists('documents/guide.txt')).toBe(true)
@@ -184,7 +204,7 @@ describe('renameDashboardFile', () => {
184
204
  const disk = manager.disk('public')
185
205
  await disk.write('notes.txt', 'top level')
186
206
 
187
- expect(await renameDashboardFile({ path: 'notes.txt', name: 'todo.txt' }, manager))
207
+ expect(await renameDashboardFile({ path: 'notes.txt', name: 'todo.txt' }, manager, store))
188
208
  .toEqual({ from: 'notes.txt', to: 'todo.txt', type: 'file', moved: 1 })
189
209
  expect(await disk.readToString('todo.txt')).toBe('top level')
190
210
  })
@@ -197,9 +217,9 @@ describe('renameDashboardFile', () => {
197
217
  test('refuses a name that is really a path', async () => {
198
218
  await manager.disk('public').write('a/b.txt', 'x')
199
219
 
200
- await expect(renameDashboardFile({ path: 'a/b.txt', name: '../escaped.txt' }, manager))
220
+ await expect(renameDashboardFile({ path: 'a/b.txt', name: '../escaped.txt' }, manager, store))
201
221
  .rejects.toMatchObject({ status: 422 })
202
- await expect(renameDashboardFile({ path: 'a/b.txt', name: 'nested/deep.txt' }, manager))
222
+ await expect(renameDashboardFile({ path: 'a/b.txt', name: 'nested/deep.txt' }, manager, store))
203
223
  .rejects.toMatchObject({ status: 422 })
204
224
  })
205
225
 
@@ -208,7 +228,7 @@ describe('renameDashboardFile', () => {
208
228
  await disk.write('images/logo.png', 'a')
209
229
  await disk.write('images/icons/favicon.png', 'b')
210
230
 
211
- const result = await renameDashboardFile({ path: 'images', name: 'media' }, manager)
231
+ const result = await renameDashboardFile({ path: 'images', name: 'media' }, manager, store)
212
232
 
213
233
  expect(result).toEqual({ from: 'images', to: 'media', type: 'directory', moved: 2 })
214
234
  expect(await disk.readToString('media/logo.png')).toBe('a')
@@ -222,7 +242,7 @@ describe('renameDashboardFile', () => {
222
242
  await disk.write('documents/readme.txt', 'keep me')
223
243
  await disk.write('documents/guide.txt', 'me too')
224
244
 
225
- await expect(renameDashboardFile({ path: 'documents/readme.txt', name: 'guide.txt' }, manager))
245
+ await expect(renameDashboardFile({ path: 'documents/readme.txt', name: 'guide.txt' }, manager, store))
226
246
  .rejects.toMatchObject({ status: 409 })
227
247
  // Neither side moved: a refused rename is not a partial one.
228
248
  expect(await disk.readToString('documents/readme.txt')).toBe('keep me')
@@ -232,12 +252,12 @@ describe('renameDashboardFile', () => {
232
252
  test('says so rather than silently doing nothing when the name is unchanged', async () => {
233
253
  await manager.disk('public').write('a.txt', 'x')
234
254
 
235
- await expect(renameDashboardFile({ path: 'a.txt', name: 'a.txt' }, manager))
255
+ await expect(renameDashboardFile({ path: 'a.txt', name: 'a.txt' }, manager, store))
236
256
  .rejects.toMatchObject({ status: 422 })
237
257
  })
238
258
 
239
259
  test('is a 404 when the item does not exist', async () => {
240
- await expect(renameDashboardFile({ path: 'nope.txt', name: 'yes.txt' }, manager))
260
+ await expect(renameDashboardFile({ path: 'nope.txt', name: 'yes.txt' }, manager, store))
241
261
  .rejects.toMatchObject({ status: 404 })
242
262
  })
243
263
  })
@@ -301,7 +321,7 @@ describe('duplicateDashboardFile', () => {
301
321
  const disk = manager.disk('public')
302
322
  await disk.write('documents/readme.txt', 'hello')
303
323
 
304
- const result = await duplicateDashboardFile({ path: 'documents/readme.txt' }, manager)
324
+ const result = await duplicateDashboardFile({ path: 'documents/readme.txt' }, manager, store)
305
325
 
306
326
  // `readme.txt copy` is a file whose type the OS, the browser and this
307
327
  // dashboard's own type grouping would all read as unknown.
@@ -314,18 +334,18 @@ describe('duplicateDashboardFile', () => {
314
334
  const disk = manager.disk('public')
315
335
  await disk.write('a.txt', 'x')
316
336
 
317
- expect((await duplicateDashboardFile({ path: 'a.txt' }, manager)).to).toBe('a copy.txt')
318
- expect((await duplicateDashboardFile({ path: 'a.txt' }, manager)).to).toBe('a copy 2.txt')
319
- expect((await duplicateDashboardFile({ path: 'a.txt' }, manager)).to).toBe('a copy 3.txt')
337
+ expect((await duplicateDashboardFile({ path: 'a.txt' }, manager, store)).to).toBe('a copy.txt')
338
+ expect((await duplicateDashboardFile({ path: 'a.txt' }, manager, store)).to).toBe('a copy 2.txt')
339
+ expect((await duplicateDashboardFile({ path: 'a.txt' }, manager, store)).to).toBe('a copy 3.txt')
320
340
  })
321
341
 
322
342
  test('takes an explicit name when given one', async () => {
323
343
  await manager.disk('public').write('a.txt', 'x')
324
344
 
325
- expect((await duplicateDashboardFile({ path: 'a.txt', name: 'b.txt' }, manager)).to).toBe('b.txt')
326
- await expect(duplicateDashboardFile({ path: 'a.txt', name: 'b.txt' }, manager))
345
+ expect((await duplicateDashboardFile({ path: 'a.txt', name: 'b.txt' }, manager, store)).to).toBe('b.txt')
346
+ await expect(duplicateDashboardFile({ path: 'a.txt', name: 'b.txt' }, manager, store))
327
347
  .rejects.toMatchObject({ status: 409 })
328
- await expect(duplicateDashboardFile({ path: 'a.txt', name: '../escaped.txt' }, manager))
348
+ await expect(duplicateDashboardFile({ path: 'a.txt', name: '../escaped.txt' }, manager, store))
329
349
  .rejects.toMatchObject({ status: 422 })
330
350
  })
331
351
 
@@ -334,7 +354,7 @@ describe('duplicateDashboardFile', () => {
334
354
  await disk.write('images/logo.png', 'a')
335
355
  await disk.write('images/icons/favicon.png', 'b')
336
356
 
337
- const result = await duplicateDashboardFile({ path: 'images' }, manager)
357
+ const result = await duplicateDashboardFile({ path: 'images' }, manager, store)
338
358
 
339
359
  expect(result).toEqual({ from: 'images', to: 'images copy', type: 'directory', copied: 2 })
340
360
  expect(await disk.readToString('images copy/logo.png')).toBe('a')
@@ -352,12 +372,12 @@ describe('duplicateDashboardFile', () => {
352
372
  await disk.write('media/one.txt', '1')
353
373
  await disk.write('media/two.txt', '2')
354
374
 
355
- expect((await duplicateDashboardFile({ path: 'media' }, manager)).copied).toBe(2)
375
+ expect((await duplicateDashboardFile({ path: 'media' }, manager, store)).copied).toBe(2)
356
376
  expect(await disk.fileExists('media copy/media copy/one.txt')).toBe(false)
357
377
  })
358
378
 
359
379
  test('is a 404 when the item does not exist', async () => {
360
- await expect(duplicateDashboardFile({ path: 'nope.txt' }, manager))
380
+ await expect(duplicateDashboardFile({ path: 'nope.txt' }, manager, store))
361
381
  .rejects.toMatchObject({ status: 404 })
362
382
  })
363
383
  })