2
0
mirror of https://github.com/frappe/books.git synced 2025-02-02 12:08:27 +00:00

feat: File selector for multi-company switching

- Use config file to store preferences
This commit is contained in:
Faris Ansari 2020-01-28 13:50:01 +05:30
parent 045f38bb96
commit f6abaa1cab
8 changed files with 247 additions and 73 deletions

View File

@ -18,6 +18,7 @@
"main": "background.js",
"dependencies": {
"core-js": "^3.4.3",
"electron-store": "^5.1.0",
"frappe-charts": "^1.3.0",
"frappejs": "http://github.com/frappe/frappejs",
"knex": "^0.20.4",

View File

@ -28,6 +28,7 @@ import DatabaseSelector from './pages/DatabaseSelector';
import Settings from '@/pages/Settings/Settings.vue';
import WindowsTitleBar from '@/components/WindowsTitleBar';
import { remote } from 'electron';
import config from '@/config';
import { connectToLocalDatabase } from '@/utils';
import { getMainWindowSize } from '@/screenSize';
@ -65,11 +66,11 @@ export default {
WindowsTitleBar
},
async mounted() {
let dbPath = localStorage.dbPath;
if (!dbPath) {
let lastSelectedFilePath = config.get('lastSelectedFilePath', null);
if (!lastSelectedFilePath) {
this.activeScreen = 'DatabaseSelector';
} else {
await connectToLocalDatabase(dbPath);
await connectToLocalDatabase(lastSelectedFilePath);
this.showSetupWizardOrDesk();
}
},

4
src/config.js Normal file
View File

@ -0,0 +1,4 @@
import Store from 'electron-store';
let config = new Store();
export default config;

View File

@ -1,49 +1,101 @@
<template>
<div class="px-12 py-10 flex-1 bg-white window-drag">
<h1 class="text-2xl font-semibold">
{{ _('Welcome to Frappe Books') }}
</h1>
<p class="text-gray-600">
{{ _('Create a new file or load an existing one from your computer') }}
</p>
<div class="flex mt-10 window-no-drag">
<div
@click="newDatabase"
class="w-1/2 border rounded-xl flex flex-col items-center py-8 px-5 cursor-pointer hover:shadow"
>
<div class="w-14 h-14 rounded-full bg-blue-200 relative flex-center">
<div class="w-12 h-12 absolute rounded-full bg-blue-500 flex-center">
<feather-icon name="plus" class="text-white w-5 h-5" />
</div>
</div>
<div class="mt-5 font-medium">{{ _('New File') }}</div>
<div class="mt-2 text-sm text-gray-600 text-center">
{{ _('Create a new file and store it in your computer.') }}
</div>
<div class="py-10 flex-1 bg-white window-drag">
<div class="w-full">
<div class="px-12">
<h1 class="text-2xl font-semibold">
{{ _('Welcome to Frappe Books') }}
</h1>
<p class="text-gray-600 text-base" v-if="!showFiles">
{{
_('Create a new file or select an existing one from your computer')
}}
</p>
<p class="text-gray-600 text-base" v-if="showFiles">
{{ _('Select a file to load the company transactions') }}
</p>
</div>
<div
@click="existingDatabase"
class="ml-6 w-1/2 border rounded-xl flex flex-col items-center py-8 px-5 cursor-pointer hover:shadow"
>
<div class="w-14 h-14 rounded-full bg-green-200 relative flex-center">
<div class="w-12 h-12 rounded-full bg-green-500 flex-center">
<feather-icon name="upload" class="w-4 h-4 text-white" />
<div class="px-12 mt-10 window-no-drag" v-if="!showFiles">
<div class="flex">
<div
@click="newDatabase"
class="w-1/2 border rounded-xl flex flex-col items-center py-8 px-5 cursor-pointer hover:shadow"
>
<div
class="w-14 h-14 rounded-full bg-blue-200 relative flex-center"
>
<div
class="w-12 h-12 absolute rounded-full bg-blue-500 flex-center"
>
<feather-icon name="plus" class="text-white w-5 h-5" />
</div>
</div>
<div class="mt-5 font-medium">{{ _('New File') }}</div>
<div class="mt-2 text-sm text-gray-600 text-center">
{{ _('Create a new file and store it in your computer.') }}
</div>
</div>
<div
@click="existingDatabase"
class="ml-6 w-1/2 border rounded-xl flex flex-col items-center py-8 px-5 cursor-pointer hover:shadow"
>
<div
class="w-14 h-14 rounded-full bg-green-200 relative flex-center"
>
<div class="w-12 h-12 rounded-full bg-green-500 flex-center">
<feather-icon name="upload" class="w-4 h-4 text-white" />
</div>
</div>
<div class="mt-5 font-medium">{{ _('Existing File') }}</div>
<div class="mt-2 text-sm text-gray-600 text-center">
{{ _('Load an existing .db file from your computer.') }}
</div>
</div>
</div>
<div class="mt-5 font-medium">{{ _('Existing File') }}</div>
<div class="mt-2 text-sm text-gray-600 text-center">
{{ _('Load an existing .db file from your computer.') }}
<a
v-if="files.length > 0"
class="text-brand text-sm mt-4 inline-block cursor-pointer"
@click="showFiles = true"
>
Select from existing files
</a>
</div>
<div v-if="showFiles">
<div class="px-12 mt-6">
<div
class="py-2 px-4 text-sm flex justify-between items-center hover:bg-gray-100 cursor-pointer border-b"
:class="{ 'border-t': i === 0 }"
v-for="(file, i) in files"
:key="file.filePath"
@click="connectToDatabase(file.filePath)"
>
<div class="flex items-baseline">
<span>
{{ file.companyName }}
</span>
</div>
<div class="text-gray-700">
{{ getFileLastModified(file.filePath) }}
</div>
</div>
</div>
<div class="px-12 mt-4">
<a
class="text-brand text-sm cursor-pointer"
@click="showFiles = false"
>
Select file manually
</a>
</div>
</div>
</div>
<p class="mt-4 flex-center text-sm text-gray-600">
<feather-icon name="info" class="-ml-8 mr-1 w-4 h-4 inline" />
<!-- prettier-ignore -->
{{ _('This file will be used as a database to store data like Customers, Invoices and Settings.') }}
</p>
</div>
</template>
<script>
import fs from 'fs';
import config from '@/config';
import { DateTime } from 'luxon';
import {
createNewDatabase,
loadExistingDatabase,
@ -52,6 +104,16 @@ import {
export default {
name: 'DatabaseSelector',
data() {
return {
showFiles: false,
files: []
};
},
mounted() {
this.files = config.get('files', []);
this.showFiles = this.files.length > 0;
},
methods: {
async newDatabase() {
let filePath = await createNewDatabase();
@ -64,6 +126,10 @@ export default {
async connectToDatabase(filePath) {
await connectToLocalDatabase(filePath);
this.$emit('database-connect');
},
getFileLastModified(filePath) {
let stats = fs.statSync(filePath);
return DateTime.fromJSDate(stats.mtime).toRelative();
}
}
};

View File

@ -1,29 +1,28 @@
<template>
<div>
<div class="flex items-center">
<svg class="h-12" viewBox="0 0 40 48" xmlns="http://www.w3.org/2000/svg">
<path
d="M37.73 0c1.097 0 1.986.89 1.986 1.986v43.688c0 1.096-.889 1.986-1.986 1.986H1.986A1.986 1.986 0 010 45.674V1.986C0 .889.89 0 1.986 0zm-7.943 27.404c-2.283 1.688-6.156 2.383-9.929 2.383-3.773 0-7.645-.695-9.929-2.383v4.369l.006.156c.196 2.575 5.25 3.816 9.923 3.816 4.766 0 9.93-1.291 9.93-3.972zm0-7.943c-2.283 1.688-6.156 2.383-9.929 2.383-3.773 0-7.645-.695-9.929-2.383v4.369l.006.156c.196 2.575 5.25 3.815 9.923 3.815 4.766 0 9.93-1.29 9.93-3.971zm-9.929-7.546c-4.766 0-9.929 1.29-9.929 3.972 0 2.68 5.163 3.971 9.93 3.971 4.765 0 9.928-1.29 9.928-3.971s-5.163-3.972-9.929-3.972z"
fill="#2490EF"
fill-rule="evenodd"
/>
</svg>
<div class="ml-4 flex flex-col">
<span class="font-semibold">{{ companyName }}</span>
<span class="text-xs text-gray-600">{{ dbPath }}</span>
<div class="flex items-center justify-between">
<div class="flex items-center">
<svg
class="h-12"
viewBox="0 0 40 48"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M37.73 0c1.097 0 1.986.89 1.986 1.986v43.688c0 1.096-.889 1.986-1.986 1.986H1.986A1.986 1.986 0 010 45.674V1.986C0 .889.89 0 1.986 0zm-7.943 27.404c-2.283 1.688-6.156 2.383-9.929 2.383-3.773 0-7.645-.695-9.929-2.383v4.369l.006.156c.196 2.575 5.25 3.816 9.923 3.816 4.766 0 9.93-1.291 9.93-3.972zm0-7.943c-2.283 1.688-6.156 2.383-9.929 2.383-3.773 0-7.645-.695-9.929-2.383v4.369l.006.156c.196 2.575 5.25 3.815 9.923 3.815 4.766 0 9.93-1.29 9.93-3.971zm-9.929-7.546c-4.766 0-9.929 1.29-9.929 3.972 0 2.68 5.163 3.971 9.93 3.971 4.765 0 9.928-1.29 9.928-3.971s-5.163-3.972-9.929-3.972z"
fill="#2490EF"
fill-rule="evenodd"
/>
</svg>
<div class="ml-4 flex flex-col w-56 truncate">
<span class="font-semibold">{{ companyName }}</span>
<span class="text-xs text-gray-600">{{ dbPath }}</span>
</div>
</div>
</div>
<div class="mt-4 flex text-sm">
<Button @click="newDatabase">{{ _('New File') }}</Button>
<Button class="ml-2" @click="existingDatabase">
<Button class="text-sm" @click="changeFile">
{{ _('Change File') }}
</Button>
</div>
<p class="-ml-3 mt-4 flex items-start text-sm text-gray-600">
<feather-icon name="info" class="mr-1 w-4 h-4 inline" />
<!-- prettier-ignore -->
{{ _('Create a new file to start accounting for a new company, or change file to open accounting for an existing company.') }}
</p>
<TwoColumnForm
class="mt-6"
v-if="doc"
@ -50,7 +49,7 @@ import frappe from 'frappejs';
import TwoColumnForm from '@/components/TwoColumnForm';
import FormControl from '@/components/Controls/FormControl';
import Button from '@/components/Button';
import { createNewDatabase, loadExistingDatabase } from '@/utils';
import config from '@/config';
import { remote } from 'electron';
export default {
@ -71,15 +70,8 @@ export default {
this.companyName = frappe.AccountingSettings.companyName;
},
methods: {
async newDatabase() {
let filePath = await createNewDatabase();
localStorage.dbPath = filePath;
frappe.events.trigger('reload-main-window');
remote.getCurrentWindow().close();
},
async existingDatabase() {
let filePath = await loadExistingDatabase();
localStorage.dbPath = filePath;
changeFile() {
config.set('lastSelectedFilePath', null);
frappe.events.trigger('reload-main-window');
remote.getCurrentWindow().close();
}
@ -90,7 +82,7 @@ export default {
return meta.getQuickEditFields();
},
dbPath() {
return localStorage.dbPath;
return frappe.db.dbPath;
},
AccountingSettings() {
return frappe.AccountingSettings;

View File

@ -1,5 +1,6 @@
import frappe from 'frappejs';
import countryList from '~/fixtures/countryInfo.json';
import config from '@/config';
export default async function setupCompany(setupWizardValues) {
const {
@ -36,6 +37,7 @@ export default async function setupCompany(setupWizardValues) {
await setupGlobalCurrencies(countryList);
await setupChartOfAccounts(bankName);
await setupRegionalChanges(country);
updateCompanyNameInConfig();
await frappe.GetStarted.update({ systemSetup: 1, companySetup: 1 });
await accountingSettings.update({ setupComplete: 1 });
@ -101,3 +103,14 @@ async function setupRegionalChanges(country) {
await frappe.db.migrate();
}
}
function updateCompanyNameInConfig() {
let filePath = frappe.db.dbPath;
let files = config.get('files', []);
files.forEach(file => {
if (file.filePath === filePath) {
file.companyName = frappe.AccountingSettings.companyName;
}
});
config.set('files', files);
}

View File

@ -6,6 +6,7 @@ import SQLite from 'frappejs/backends/sqlite';
import postStart from '../server/postStart';
import router from '@/router';
import Avatar from '@/components/Avatar';
import config from '@/config';
export function createNewDatabase() {
return new Promise(resolve => {
@ -70,8 +71,22 @@ export async function connectToLocalDatabase(filepath) {
await frappe.db.connect();
await frappe.db.migrate();
await postStart();
// cache dbpath in localstorage
localStorage.dbPath = filepath;
// set file info in config
let files = config.get('files') || [];
if (!files.find(file => file.filePath === filepath)) {
files = [
{
companyName: frappe.AccountingSettings.companyName,
filePath: filepath
},
...files
];
config.set('files', files);
}
// set last selected file
config.set('lastSelectedFilePath', filepath);
}
export function showMessageDialog({ message, description, buttons = [] }) {

View File

@ -2872,6 +2872,22 @@ concat-stream@1.6.2, concat-stream@^1.5.0, concat-stream@^1.5.2:
readable-stream "^2.2.2"
typedarray "^0.0.6"
conf@^6.2.0:
version "6.2.0"
resolved "https://registry.yarnpkg.com/conf/-/conf-6.2.0.tgz#274d37a0a2e50757ffb89336e954d08718eb359a"
integrity sha512-fvl40R6YemHrFsNiyP7TD0tzOe3pQD2dfT2s20WvCaq57A1oV+RImbhn2Y4sQGDz1lB0wNSb7dPcPIvQB69YNA==
dependencies:
ajv "^6.10.2"
debounce-fn "^3.0.1"
dot-prop "^5.0.0"
env-paths "^2.2.0"
json-schema-typed "^7.0.1"
make-dir "^3.0.0"
onetime "^5.1.0"
pkg-up "^3.0.1"
semver "^6.2.0"
write-file-atomic "^3.0.0"
configstore@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/configstore/-/configstore-4.0.0.tgz#5933311e95d3687efb592c528b922d9262d227e7"
@ -3401,6 +3417,13 @@ de-indent@^1.0.2:
resolved "https://registry.yarnpkg.com/de-indent/-/de-indent-1.0.2.tgz#b2038e846dc33baa5796128d0804b455b8c1e21d"
integrity sha1-sgOOhG3DO6pXlhKNCAS0VbjB4h0=
debounce-fn@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/debounce-fn/-/debounce-fn-3.0.1.tgz#034afe8b904d985d1ec1aa589cd15f388741d680"
integrity sha512-aBoJh5AhpqlRoHZjHmOzZlRx+wz2xVwGL9rjs+Kj0EWUrL4/h4K7OD176thl2Tdoqui/AaA4xhHrNArGLAaI3Q==
dependencies:
mimic-fn "^2.1.0"
debug@2.6.9, debug@^2.1.3, debug@^2.2.0, debug@^2.3.3, debug@^2.6.8, debug@^2.6.9:
version "2.6.9"
resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
@ -3752,6 +3775,13 @@ dot-prop@^4.1.0, dot-prop@^4.1.1:
dependencies:
is-obj "^1.0.0"
dot-prop@^5.0.0:
version "5.2.0"
resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-5.2.0.tgz#c34ecc29556dc45f1f4c22697b6f4904e0cc4fcb"
integrity sha512-uEUyaDKoSQ1M4Oq8l45hSE26SnTxL6snNnqvK/VWx5wJhmff5z0FUVJDKDanor/6w3kzE3i7XZOk+7wC0EXr1A==
dependencies:
is-obj "^2.0.0"
dotenv-expand@^5.1.0:
version "5.1.0"
resolved "https://registry.yarnpkg.com/dotenv-expand/-/dotenv-expand-5.1.0.tgz#3fbaf020bfd794884072ea26b1e9791d45a629f0"
@ -3911,6 +3941,14 @@ electron-publish@21.2.0:
lazy-val "^1.0.4"
mime "^2.4.4"
electron-store@^5.1.0:
version "5.1.0"
resolved "https://registry.yarnpkg.com/electron-store/-/electron-store-5.1.0.tgz#0b3cb66b15d0002678fc5c13e8b0c38a8678d670"
integrity sha512-uhAF/4+zDb+y0hWqlBirEPEAR4ciCZDp4fRWGFNV62bG+ArdQPpXk7jS0MEVj3CfcG5V7hx7Dpq5oD+1j6GD8Q==
dependencies:
conf "^6.2.0"
type-fest "^0.7.1"
electron-to-chromium@^1.3.322:
version "1.3.322"
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.322.tgz#a6f7e1c79025c2b05838e8e344f6e89eb83213a8"
@ -4064,6 +4102,11 @@ env-paths@^1.0.0:
resolved "https://registry.yarnpkg.com/env-paths/-/env-paths-1.0.0.tgz#4168133b42bb05c38a35b1ae4397c8298ab369e0"
integrity sha1-QWgTO0K7BcOKNbGuQ5fIKYqzaeA=
env-paths@^2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/env-paths/-/env-paths-2.2.0.tgz#cdca557dc009152917d6166e2febe1f039685e43"
integrity sha512-6u0VYSCo/OW6IoD5WCLLy9JUGARbamfSavcNXry/eu8aHVFei6CD3Sw+VGX5alea1i9pgPHW0mbu6Xj0uBh7gA==
errno@^0.1.3, errno@~0.1.7:
version "0.1.7"
resolved "https://registry.yarnpkg.com/errno/-/errno-0.1.7.tgz#4684d71779ad39af177e3f007996f7c67c852618"
@ -6062,6 +6105,11 @@ is-obj@^1.0.0, is-obj@^1.0.1:
resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-1.0.1.tgz#3e4729ac1f5fde025cd7d83a896dab9f4f67db0f"
integrity sha1-PkcprB9f3gJc19g6iW2rn09n2w8=
is-obj@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-2.0.0.tgz#473fb05d973705e3fd9620545018ca8e22ef4982"
integrity sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==
is-observable@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/is-observable/-/is-observable-1.1.0.tgz#b3e986c8f44de950867cab5403f5a3465005975e"
@ -6165,7 +6213,7 @@ is-symbol@^1.0.2:
dependencies:
has-symbols "^1.0.1"
is-typedarray@~1.0.0:
is-typedarray@^1.0.0, is-typedarray@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a"
integrity sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=
@ -6322,6 +6370,11 @@ json-schema-traverse@^0.4.1:
resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660"
integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==
json-schema-typed@^7.0.1:
version "7.0.3"
resolved "https://registry.yarnpkg.com/json-schema-typed/-/json-schema-typed-7.0.3.tgz#23ff481b8b4eebcd2ca123b4fa0409e66469a2d9"
integrity sha512-7DE8mpG+/fVw+dTpjbxnx47TaMnDfOI1jwft9g1VybltZCduyRQPJPvc+zzKY9WPHxhPWczyFuYa6I8Mw4iU5A==
json-schema@0.2.3:
version "0.2.3"
resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13"
@ -8181,6 +8234,13 @@ pkg-dir@^4.1.0:
dependencies:
find-up "^4.0.0"
pkg-up@^3.0.1:
version "3.1.0"
resolved "https://registry.yarnpkg.com/pkg-up/-/pkg-up-3.1.0.tgz#100ec235cc150e4fd42519412596a28512a0def5"
integrity sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==
dependencies:
find-up "^3.0.0"
please-upgrade-node@^3.1.1:
version "3.2.0"
resolved "https://registry.yarnpkg.com/please-upgrade-node/-/please-upgrade-node-3.2.0.tgz#aeddd3f994c933e4ad98b99d9a556efa0e2fe942"
@ -10619,6 +10679,11 @@ type-fest@^0.6.0:
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.6.0.tgz#8d2a2370d3df886eb5c90ada1c5bf6188acf838b"
integrity sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==
type-fest@^0.7.1:
version "0.7.1"
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.7.1.tgz#8dda65feaf03ed78f0a3f9678f1869147f7c5c48"
integrity sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==
type-is@^1.6.4, type-is@~1.6.17, type-is@~1.6.18:
version "1.6.18"
resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131"
@ -10627,6 +10692,13 @@ type-is@^1.6.4, type-is@~1.6.17, type-is@~1.6.18:
media-typer "0.3.0"
mime-types "~2.1.24"
typedarray-to-buffer@^3.1.5:
version "3.1.5"
resolved "https://registry.yarnpkg.com/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz#a97ee7a9ff42691b9f783ff1bc5112fe3fca9080"
integrity sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==
dependencies:
is-typedarray "^1.0.0"
typedarray@^0.0.6:
version "0.0.6"
resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
@ -11328,6 +11400,16 @@ write-file-atomic@^2.0.0:
imurmurhash "^0.1.4"
signal-exit "^3.0.2"
write-file-atomic@^3.0.0:
version "3.0.1"
resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-3.0.1.tgz#558328352e673b5bb192cf86500d60b230667d4b"
integrity sha512-JPStrIyyVJ6oCSz/691fAjFtefZ6q+fP6tm+OS4Qw6o+TGQxNp1ziY2PgS+X/m0V8OWhZiO/m4xSj+Pr4RrZvw==
dependencies:
imurmurhash "^0.1.4"
is-typedarray "^1.0.0"
signal-exit "^3.0.2"
typedarray-to-buffer "^3.1.5"
write@1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/write/-/write-1.0.3.tgz#0800e14523b923a387e415123c865616aae0f5c3"