backuparcgis-item

Init commit

2/4/2019 5:18:21 PM

Changes

.gitignore 85(+85 -0)

.nvmrc 1(+1 -0)

.vscode/launch.json 20(+20 -0)

index.js 320(+320 -0)

lib/fs-promise.js 39(+39 -0)

package.json 28(+28 -0)

package-lock.json 49(+49 -0)

readme.md 99(+99 -0)

test/secure.js 46(+46 -0)

test/terminal.sh 4(+4 -0)

Details

.gitignore 85(+85 -0)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..e9c5933
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,85 @@
+# test output
+test/require/archive/**/*
+test/terminal/archive/**/*
+test/secure/archive/**/*
+
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+# Runtime data
+pids
+*.pid
+*.seed
+*.pid.lock
+
+# Directory for instrumented libs generated by jscoverage/JSCover
+lib-cov
+
+# Coverage directory used by tools like istanbul
+coverage
+
+# nyc test coverage
+.nyc_output
+
+# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
+.grunt
+
+# Bower dependency directory (https://bower.io/)
+bower_components
+
+# node-waf configuration
+.lock-wscript
+
+# Compiled binary addons (https://nodejs.org/api/addons.html)
+build/Release
+
+# Dependency directories
+node_modules/
+jspm_packages/
+
+# TypeScript v1 declaration files
+typings/
+
+# Optional npm cache directory
+.npm
+
+# Optional eslint cache
+.eslintcache
+
+# Optional REPL history
+.node_repl_history
+
+# Output of 'npm pack'
+*.tgz
+
+# Yarn Integrity file
+.yarn-integrity
+
+# dotenv environment variables file
+.env
+.env.test
+
+# parcel-bundler cache (https://parceljs.org/)
+.cache
+
+# next.js build output
+.next
+
+# nuxt.js build output
+.nuxt
+
+# vuepress build output
+.vuepress/dist
+
+# Serverless directories
+.serverless/
+
+# FuseBox cache
+.fusebox/
+
+# DynamoDB Local files
+.dynamodb/
\ No newline at end of file

.nvmrc 1(+1 -0)

diff --git a/.nvmrc b/.nvmrc
new file mode 100644
index 0000000..f9fb144
--- /dev/null
+++ b/.nvmrc
@@ -0,0 +1 @@
+10.15.0

.vscode/launch.json 20(+20 -0)

diff --git a/.vscode/launch.json b/.vscode/launch.json
new file mode 100644
index 0000000..60825c7
--- /dev/null
+++ b/.vscode/launch.json
@@ -0,0 +1,20 @@
+{
+    // Use IntelliSense to learn about possible attributes.
+    // Hover to view descriptions of existing attributes.
+    // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
+    "version": "0.2.0",
+    "configurations": [
+        {
+            "type": "node",
+            "request": "launch",
+            "name": "Launch Program",
+            "program": "${workspaceFolder}/index.js",
+            "args": [
+                "682fac79087c4e159962444de9b823c5",
+                "./test/terminal/",
+                "USERNAME",
+                "TOKEN"
+            ]
+        }
+    ]
+}
\ No newline at end of file

index.js 320(+320 -0)

diff --git a/index.js b/index.js
new file mode 100644
index 0000000..ad77081
--- /dev/null
+++ b/index.js
@@ -0,0 +1,320 @@
+#!/usr/bin/env node
+
+const axios = require('axios')
+const httpsPromise = require('./lib/https-promise')
+const querystring = require('querystring')
+const url = require('url')
+const fs = require('fs')
+const fsPromises = require('./lib/fs-promise')
+
+class BackupArcGISItem {
+
+    constructor(itemId, dir, username, token) {
+
+        if(!itemId || !dir || !username || !token ) {
+            throw new Error('4 Parameters Required (ItemID, Working Directory Path, Username, Token)')
+        }
+
+        // MIN SIZE FOR NEW DOWNLOAD
+        this._bytes = 100
+
+        // ArcGIS Online account for storing item
+        this.username = username
+        this.token = token
+
+        // original item in arcgis
+        this.itemUrl = `https://www.arcgis.com/home/item?id=${itemId}`
+        this.itemId = itemId
+        this.itemDetails = undefined
+
+        // tmp item in arcgis for download
+        this.exportItemDetails = undefined
+
+        // storage 
+        this.workingDir = dir.slice(-1) == '/' ? dir.slice(0,-1) : dir
+
+        
+        // versioning
+        this.duplicate = false
+        
+    }
+
+    set bytes(val) {
+        
+        this._bytes = val
+
+    } 
+
+    // Given an itemId, return item details json
+    // returns item details object
+    async getItemDetails(itemId) {
+
+        try {
+            const response = await axios.get(`https://www.arcgis.com/sharing/rest/content/items/${itemId}?f=json&token=${this.token}/status`)
+            if(response.data.error) {
+                throw(response.data.error.message)
+            }
+            return response.data
+        } catch (err) {
+            process.exitCode = 1
+            throw(err)
+        }
+
+    }
+
+    // Given a jobId and itemId of new ArcGIS Item, returns new item details
+    // returns item details object
+    async getJobStatus(jobId, exportItemId) {
+ 
+        const status_url = `https://www.arcgis.com/sharing/rest/content/users/${this.username}/items/${exportItemId}/status`
+        const params = {
+            token: this.token,
+            jobType: 'export',
+            f: 'json',
+            jobId: jobId
+        }
+
+        try {
+            const response = await axios({
+                method: 'get',
+                headers: {
+                    'Content-Type': 'form-data'
+                },
+                url: status_url,
+                params: params,
+                responseType: 'json'
+            })
+      
+            if(response.data.status !== 'completed') {
+                return false
+            }
+            return response.data
+        } catch (err) {
+            throw(err)
+        }
+    }
+
+    async exportItem() {
+
+        return new Promise(async (resolve, reject) => {
+            
+            const username = this.itemDetails.owner
+            const usercontent_url = `https://www.arcgis.com/sharing/rest/content/users/${this.username}/export`
+    
+            const params = {
+                token: this.token,
+                f: 'json',
+                itemId: this.itemId,
+                exportFormat: 'File Geodatabase',
+                title: `${Date.now()}_${this.itemId}`
+            }
+    
+            try {
+    
+                const response = await axios({
+                    method: 'post',
+                    headers: {
+                        'Content-Type': 'form-data',
+                    },
+                    url: usercontent_url,
+                    params: params,
+                    responseType: 'json',
+                })
+        
+                if(response.status == 200) {
+    
+                    var jobStatusResponse = false
+                    var checkingId = setInterval( await checkResponse.bind(this), 1000)
+    
+                    async function checkResponse() {
+                        jobStatusResponse = await this.getJobStatus(response.data.jobId, response.data.exportItemId)
+            
+                        if(jobStatusResponse) {
+                            clearInterval(checkingId)
+                            resolve(jobStatusResponse)
+                        }
+                    }
+    
+                } else {
+                    throw('Problem Creating Export')
+                }
+            } catch (err) {
+                process.exitCode = 1
+                throw(err)
+            }
+
+        })
+
+    }
+
+    async downloadItem() {
+
+        const download_url = `https://www.arcgis.com/sharing/rest/content/items/${this.exportItemDetails.id}/data?token=${this.token}`
+        var archive_url = ''
+        // get s3 signed URL for download
+        try {
+
+            const response = await axios.get(download_url)
+            archive_url = `https://${response.request._headers.host}${response.request.path}`
+
+        } catch (err) {
+
+            throw(err)
+
+        }
+        
+        const path = `${this.workingDir}/archive/tmp/${this.itemId}.zip`
+        try {
+            await httpsPromise(archive_url, path)
+
+            // Get File Size and get returned size
+            const tmpStats = await fsPromises.stat(path)
+
+            return {
+                tmpPath: path,
+                tmpSize: tmpStats.size
+            }
+        } catch (err) {
+            throw(err)
+        }
+
+    }
+
+    async compareLatest(itemId, tmpSize) {
+
+        // get latest file in directory
+        const files = await fsPromises.readdir(`${this.workingDir}/archive/${itemId}`)
+        files.sort()
+        const latest = files[files.length - 1]
+        const latestStats = await fsPromises.stat(`${this.workingDir}/archive/${itemId}/${latest}`)
+        const latestSize = latestStats.size
+
+        // ASSUMPTION -> NOT HASH COMPARISON... SET THRESHOLD
+        if(Math.abs(tmpSize - latestSize) < this._bytes) {
+            return true
+        }
+        return false
+
+    }
+
+    async deleteDownload(itemId) {
+
+        const delete_url = `https://www.arcgis.com/sharing/rest/content/users/${this.username}/items/${this.exportItemDetails.id}/delete`
+
+        const params = {
+            token: this.token,
+            f: 'json'
+        }
+
+        try {
+
+            const response = await axios({
+                method: 'post',
+                headers: {
+                    'Content-Type': 'form-data',
+                },
+                url: delete_url,
+                params: params,
+                responseType: 'json',
+            })
+    
+            if(response.status == 200 && response.data.success) {
+                return response.data
+            } else {
+                throw('Problem Deleting Item in ArcGIS Online')
+            }
+        } catch (err) {
+            process.exitCode = 1
+            throw(err)
+        }
+    }
+
+    async run() {
+      
+        // check for archive and tmp directory
+        const archiveExists = await fsPromises.exists(`${this.workingDir}/archive`)
+        if(!archiveExists) {
+            await fs.mkdir(`${this.workingDir}/archive`, (err) => {
+                if(!(err && err.code === 'EEXIST')){
+                    handleErr(err)
+                } 
+            })
+        }
+   
+        const tmpExists = await fsPromises.exists(`${this.workingDir}/archive/tmp`)
+        if(!tmpExists) {
+            await fs.mkdir(`${this.workingDir}/archive/tmp`, (err) => {
+                if(!(err && err.code === 'EEXIST')){
+                    handleErr(err)
+                } 
+            })
+        }
+    
+        this.itemDetails = await this.getItemDetails(this.itemId)
+       
+        const exportJob = await this.exportItem()
+        this.exportItemDetails = await this.getItemDetails(exportJob.itemId)
+  
+        const {tmpPath, tmpSize} = await this.downloadItem(exportJob.itemId)
+
+        // delete download item from agol
+        await this.deleteDownload()
+
+        const pathExists = await fsPromises.exists(`${this.workingDir}/archive/${this.itemId}`)
+        const outFileName = `${this.workingDir}/archive/${this.itemId}/${Date.now()}.zip`
+
+        if(pathExists) {
+            // path already exists, check file sizes
+            const exists = await this.compareLatest(this.itemId, tmpSize)
+            if(!exists) {
+                await fs.rename(tmpPath, outFileName, handleErr)
+            } else {
+                // no updates to file, duplicate
+                this.duplicate = true
+                await fs.unlink(tmpPath, handleErr)
+            }
+        } else {
+            // async version is causing issues
+            fs.mkdirSync(`${this.workingDir}/archive/${this.itemId}`, handleErr)
+            await fs.rename(tmpPath, outFileName, handleErr)
+        }
+
+        return {
+            filename: this.duplicate ? false : outFileName,
+            itemDetails: this.itemDetails,
+            duplicate: this.duplicate
+        }
+
+    }
+}
+
+function handleErr(err) {
+
+    if(err) throw err
+
+}
+
+if(require.main == module) {
+
+    // if run as node process
+    if ( process.argv.length < 6 ) {
+        console.log('ArcGIS Item URL, working directory path, username, and token required')
+        console.log('example: backuparcgis-item https://www.arcgis.com/home/item.html?id=c31146ae5a7d4299a08dd4407526625d ./ {username} {token}')
+        process.exitCode = 1
+        return
+    }
+
+    const backup = new BackupArcGISItem(process.argv[2], process.argv[3], process.argv[4], process.argv[5])
+    backup.run().then((resp) => {
+        if(!resp.duplicate) {
+            console.log(`${resp.itemDetails.title} completed: ${resp.filename}`)
+        } else {
+            console.log(`No updates to ${resp.itemDetails.title}.`)
+        }
+    }).catch((err) => {
+        console.log(err)
+    })
+} else {
+    // if run by require()
+    module.exports = BackupArcGISItem
+}
\ No newline at end of file

lib/fs-promise.js 39(+39 -0)

diff --git a/lib/fs-promise.js b/lib/fs-promise.js
new file mode 100644
index 0000000..f069ebb
--- /dev/null
+++ b/lib/fs-promise.js
@@ -0,0 +1,39 @@
+const fs = require('fs')
+
+function exists(path) {
+    return new Promise(function (resolve, reject) {
+        try {
+            fs.exists(path, (exists) => {
+                resolve(exists)
+            })
+        } catch (err) {
+            reject(err)
+        }
+    })
+}
+
+function readdir(path) {
+    return new Promise(function (resolve, reject) {
+        fs.readdir(path, (err, files) => {
+            if(err) {
+                reject(err)
+            }
+            resolve([...files])
+        })
+    })
+}
+
+function stat(path) {
+    return new Promise(function (resolve, reject) {
+        fs.stat(path, (err, stats) => {
+            if(err) {
+                reject(err)
+            }
+            resolve(stats)
+        })
+    })
+}
+
+module.exports = {
+    exists, readdir, stat
+}
\ No newline at end of file
diff --git a/lib/https-promise.js b/lib/https-promise.js
new file mode 100644
index 0000000..697c506
--- /dev/null
+++ b/lib/https-promise.js
@@ -0,0 +1,23 @@
+const https = require('https')
+const fs = require('fs')
+
+function httpsPromise(url, path) {
+    const outfile = fs.createWriteStream(path)
+    return new Promise(function(resolve, reject) {
+        var req = https.get(url, function (res) {
+            res.pipe(outfile)
+
+            res.on('end', function () {
+                resolve()
+            })
+        })
+
+        req.on('error', function (err) {
+            reject(err)
+        })
+
+        req.end()
+    })
+}
+
+module.exports = httpsPromise
\ No newline at end of file

package.json 28(+28 -0)

diff --git a/package.json b/package.json
new file mode 100644
index 0000000..1527885
--- /dev/null
+++ b/package.json
@@ -0,0 +1,28 @@
+{
+  "name": "backuparcgis-item",
+  "homepage": "https://github.com/tannerjt/backuparcgis-item#readme",
+  "version": "1.0.0",
+  "engines": {
+    "node": ">8.9.0"
+  },
+  "description": "Async module to backup items from ArcGIS Online hosted feature services to FileGDB.",
+  "main": "index.js",
+  "bin": {
+    "backuparcgis-item": "./index.js"
+  },
+  "scripts": {
+    "test": "echo \"Error: no test specified\" && exit 1"
+  },
+  "repository": {
+    "type": "git",
+    "url": "github:tannerjt/backuparcgis-item"
+  },
+  "author": "Joshua Tanner (@Tanner_GEO)",
+  "license": "ISC",
+  "dependencies": {
+    "axios": "^0.18.0"
+  },
+  "devDependencies": {
+    "dotenv": "^6.2.0"
+  }
+}

package-lock.json 49(+49 -0)

diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..a627ce9
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,49 @@
+{
+  "name": "backuparcgis-item",
+  "version": "1.0.0",
+  "lockfileVersion": 1,
+  "requires": true,
+  "dependencies": {
+    "axios": {
+      "version": "0.18.0",
+      "resolved": "https://registry.npmjs.org/axios/-/axios-0.18.0.tgz",
+      "integrity": "sha1-MtU+SFHv3AoRmTts0AB4nXDAUQI=",
+      "requires": {
+        "follow-redirects": "1.6.1",
+        "is-buffer": "1.1.6"
+      }
+    },
+    "debug": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz",
+      "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==",
+      "requires": {
+        "ms": "2.0.0"
+      }
+    },
+    "dotenv": {
+      "version": "6.2.0",
+      "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-6.2.0.tgz",
+      "integrity": "sha512-HygQCKUBSFl8wKQZBSemMywRWcEDNidvNbjGVyZu3nbZ8qq9ubiPoGLMdRDpfSrpkkm9BXYFkpKxxFX38o/76w==",
+      "dev": true
+    },
+    "follow-redirects": {
+      "version": "1.6.1",
+      "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.6.1.tgz",
+      "integrity": "sha512-t2JCjbzxQpWvbhts3l6SH1DKzSrx8a+SsaVf4h6bG4kOXUuPYS/kg2Lr4gQSb7eemaHqJkOThF1BGyjlUkO1GQ==",
+      "requires": {
+        "debug": "3.1.0"
+      }
+    },
+    "is-buffer": {
+      "version": "1.1.6",
+      "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
+      "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w=="
+    },
+    "ms": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+      "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
+    }
+  }
+}

readme.md 99(+99 -0)

diff --git a/readme.md b/readme.md
new file mode 100644
index 0000000..24ac07d
--- /dev/null
+++ b/readme.md
@@ -0,0 +1,99 @@
+## backuparcgis-item
+backuparcgis-item is a nodejs library used to backup items from ArcGIS Online (hosted feature services) items to a file geodatabase.
+
+## About
+
+Hosted Feature Services in ArcGIS Online are a great way to host and serve geospatial data into your online maps.  The infrastructure of ArcGIS Online has proven to be performant and highly available.  There is, however, always a chance your mission critical services will run into issues.  ArcGIS Online outages, accidental deletion, or service corruption can all happen.  It's best to be prepared and at least have a backup of your data on hand.
+
+This library, *backuparcgis-item*, is a performant asynchronous streaming nodejs library that can be imported as a nodejs module or run directly from command line.
+
+## Features
+
+### Asynchronous 
+
+backuparcgis-item is asynchronous and returns a promise when complete.
+
+### Versioned
+
+backuparcgis-item only stores a backup of the data if changes has been made.  It uses the file size of file geodatabase archives to dtermine if the source data has been altered.  The minimum threshold (in bytes) can be changed if neeeded.
+
+### Streaming
+
+backuparcgis-item streams feature service content directly to a file and does not store it in memory.  This allows the script to run faster and avoid memory issues with larger datasets.
+
+## Use require()
+
+```bash
+$ npm install backuparcgis-item
+```
+
+```javascript
+const BackupArcGISService = require('backuparcgis-item')
+
+// new BackupArcGIS(itemId, archiveDirectory, username, ?token)
+const Backup = new BackupArcGISService('682fac79087c4e159962444de9b823c5', outDir, username, token)
+
+Backup.run().then((resp) => {
+    if(!resp.duplicate) {
+        console.log(`${resp.serviceDetails.title} completed: ${resp.filename}`)
+    } else {
+        console.log(`No updates to ${resp.serviceDetails.title}.`)
+    }
+})
+```
+
+## Run from command line
+
+```bash
+$ npm install backuparcgis-item --global
+```
+
+**Format:**
+
+*backuparcgis-item serviceUrl archiveDirectory*
+
+```bash
+#!/bin/bash
+
+backuparcgis-item 682fac79087c4e159962444de9b823c5 ./terminal john.appleseed jkfdla9udfajklsafjda9eu232-fds_fjdsla..
+```
+
+## Response
+
+The library will respond with a promise with the following object:
+
+```json
+{
+    "duplicate": "boolean",
+    "itemDetails": "object",
+    "filename": "string"
+}
+```
+
+## File Storage Format
+
+A new archive directory will be created in your specified output directory.  Within the archive directory, a new folder will be created for each feature service, which is named the same as the item id in ArcGIS Online.  Data will be versioned by timestamp, only storing new datasets that are different from the previous export (sha256 hash comparisons).
+
+```
+archive  
+└───arcgis_item_id
+    │--timestamp.zip
+    │--timestamp.zip
+```
+
+## Change Minimum Size Change Threshold
+
+This library compares file sizes (in bytes) to determine if updates have been made to the hosted feature service.  Under normal circumstances, I would use a cryptographic hash comparison, like `md5` or `sha256`.  However, file geodatabases containing the same content but created at different times will produce different unique hashes.  The default minimum byte threshold for determining if an item has been altered is `100 bytes`.  This can be changed by using the setter `bytes`.
+
+Example:
+
+```javascript
+const BackupArcGISService = require('backuparcgis-item')
+
+// new BackupArcGIS(serviceURL, archiveDirectory, username, ?token)
+const Backup = new BackupArcGISService('682fac79087c4e159962444de9b823c5', outDir, username, token)
+
+Backup.bytes(250)
+```
+
+Changing this value will ensure new versions are only created if the absolute value of the difference between the datasets is > 250 bytes.
\ No newline at end of file
diff --git a/test/.env.example b/test/.env.example
new file mode 100644
index 0000000..70cfb2f
--- /dev/null
+++ b/test/.env.example
@@ -0,0 +1,2 @@
+ARCGIS_USERNAME=
+ARCGIS_PASSWORD=

test/secure.js 46(+46 -0)

diff --git a/test/secure.js b/test/secure.js
new file mode 100644
index 0000000..1c20857
--- /dev/null
+++ b/test/secure.js
@@ -0,0 +1,46 @@
+const BackupArcGISService = require('../index.js')
+const axios = require('axios')
+require('dotenv').config()
+
+if(require.main == module) {
+    axios({
+        method: 'POST',
+        headers: {
+            'Content-Type': 'application/x-www-form-urlencoded'
+        },
+        url: 'https://www.arcgis.com/sharing/rest/generateToken',
+        params: {
+            'username': `${process.env.ARCGIS_USERNAME}`,
+            'password': `${process.env.ARCGIS_PASSWORD}`,
+            'referer': 'localhost',
+            'f': 'json'
+        },
+        responseType: 'json',
+    }).then(function (response) {
+        if(!response.data.token) {
+            console.log('No Token Returned.  Check Credentials')
+        } else {
+            const outDir = './secure'
+            const Backup1 = new BackupArcGISService('682fac79087c4e159962444de9b823c5', outDir, process.env.ARCGIS_USERNAME, response.data.token)
+            Backup1.run().then((resp) => {
+                if(!resp.duplicate) {
+                    console.log(`${resp.itemDetails.title} completed: ${resp.filename}`)
+                } else {
+                    console.log(`No updates to ${resp.itemDetails.title}.`)
+                }
+            }).catch((err) => {
+                console.log(err)
+            })
+
+            const Backup2 = new BackupArcGISService('c31146ae5a7d4299a08dd4407526625d', outDir, process.env.ARCGIS_USERNAME, response.data.token)
+            Backup2.run().then((resp) => {
+                if(!resp.duplicate) {
+                    console.log(`${resp.itemDetails.title} completed: ${resp.filename}`)
+                } else {
+                    console.log(`No updates to ${resp.itemDetails.title}.`)
+                }
+            })
+        }
+    })
+    
+}
\ No newline at end of file
diff --git a/test/secure/contents.txt b/test/secure/contents.txt
new file mode 100644
index 0000000..ed50db2
--- /dev/null
+++ b/test/secure/contents.txt
@@ -0,0 +1 @@
+archive file goes here
\ No newline at end of file

test/terminal.sh 4(+4 -0)

diff --git a/test/terminal.sh b/test/terminal.sh
new file mode 100755
index 0000000..783a6ac
--- /dev/null
+++ b/test/terminal.sh
@@ -0,0 +1,4 @@
+#!/bin/bash
+
+node ../index.js c31146ae5a7d4299a08dd4407526625d ./terminal USERNAME TOKEN
+node ../index.js 682fac79087c4e159962444de9b823c5 ./terminal USERNAME TOKEN
\ No newline at end of file
diff --git a/test/terminal/contents.txt b/test/terminal/contents.txt
new file mode 100644
index 0000000..ed50db2
--- /dev/null
+++ b/test/terminal/contents.txt
@@ -0,0 +1 @@
+archive file goes here
\ No newline at end of file