mirror of
https://github.com/valeriangalliat/firefox-sync-cli
synced 2024-05-19 13:56:38 +02:00
Initial commit
This commit is contained in:
commit
c0a3b5c1dc
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
/node_modules
|
||||
/package-lock.json
|
34
README.md
Normal file
34
README.md
Normal file
|
@ -0,0 +1,34 @@
|
|||
# Firefox Sync CLI
|
||||
|
||||
> Manage Firefox Sync from the CLI! ✨
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
Usage: ffs [options] [command]
|
||||
|
||||
Options:
|
||||
--version Show version.
|
||||
-c, --creds <file> File to get Firefox Sync creds from (or
|
||||
write to during authentication and
|
||||
refresh).
|
||||
-v, --verbose Output more details.
|
||||
-h, --help Show this screen.
|
||||
|
||||
Commands:
|
||||
auth [email] [password] Sign in using email and password.
|
||||
oauth Sign in using OAuth.
|
||||
collections List available collections.
|
||||
get [options] <collection> [id...] Get some or all items from a collection.
|
||||
When getting all items, pass '--full' to
|
||||
get the full objects.
|
||||
set <collection> Store one or multiple payloads from
|
||||
'stdin' in the given collection.
|
||||
delete <collection> <id...> Delete the given items by ID.
|
||||
quota Get the current usage and storage quota.
|
||||
collection-usage Get the usage in kB of all collections.
|
||||
collection-counts Get the number of items in each
|
||||
collection.
|
||||
configuration Get the storage server configuration.
|
||||
help [command] Display help for command.
|
||||
```
|
132
cli.js
Normal file
132
cli.js
Normal file
|
@ -0,0 +1,132 @@
|
|||
const { Command } = require('commander')
|
||||
const Sync = require('firefox-sync')
|
||||
const { version } = require('./package')
|
||||
|
||||
const auth = {
|
||||
password: require('./password'),
|
||||
oauth: require('./oauth')
|
||||
}
|
||||
|
||||
const program = new Command()
|
||||
|
||||
program
|
||||
.helpOption('-h, --help', 'Show this screen.')
|
||||
.version(version, '--version', 'Show version.')
|
||||
.addHelpCommand('help [command]', 'Display help for command.')
|
||||
.allowExcessArguments(false)
|
||||
|
||||
program
|
||||
.option('-c, --creds <file>', 'File to get Firefox Sync creds from (or write to during authentication and refresh).')
|
||||
.option('-v, --verbose', 'Output more details.')
|
||||
|
||||
program.hook('preAction', (program, command) => {
|
||||
const credsFile = program.opts().creds
|
||||
|
||||
const sync = Sync({
|
||||
credsFile,
|
||||
oauthOptions: {
|
||||
access_type: 'offline'
|
||||
}
|
||||
})
|
||||
|
||||
if (!['password', 'oauth'].includes(command.name())) {
|
||||
if (!credsFile) {
|
||||
program._displayError(1, 'sync.missingCreds', "error: missing creds, see '--creds'")
|
||||
}
|
||||
}
|
||||
|
||||
// Inject Sync instance.
|
||||
command.setOptionValue('sync', sync)
|
||||
})
|
||||
|
||||
async function handleCreds (args, creds) {
|
||||
if (!args['--creds']) {
|
||||
log(creds)
|
||||
return
|
||||
}
|
||||
|
||||
// Library alrady took care of this with `credsFile`.
|
||||
console.error(`Wrote creds to '${args['--creds']}'`)
|
||||
}
|
||||
|
||||
program
|
||||
.command('auth [email] [password]')
|
||||
.description('Sign in using email and password.')
|
||||
.action(async (email, password, options) => handleCreds(options, await auth.password(email, password, options)))
|
||||
|
||||
program
|
||||
.command('oauth')
|
||||
.description('Sign in using OAuth.')
|
||||
.action(async options => handleCreds(options, await auth.oauth(options)))
|
||||
|
||||
function log (object) {
|
||||
console.log(JSON.stringify(object, null, 2))
|
||||
}
|
||||
|
||||
program
|
||||
.command('collections')
|
||||
.description('List available collections.')
|
||||
.action(async options => log(await options.sync.getCollections()))
|
||||
|
||||
program
|
||||
.command('get <collection> [id...]')
|
||||
.description("Get some or all items from a collection. When getting all items, pass '--full' to get the full objects.")
|
||||
.option('--full', 'Retrieve full objects (implicit when selecting specific objects).')
|
||||
.action(async (collection, ids, options) => {
|
||||
const params = {}
|
||||
|
||||
if (options.full) {
|
||||
params.full = true
|
||||
}
|
||||
|
||||
if (ids.length > 0) {
|
||||
params.full = true
|
||||
params.ids = ids
|
||||
}
|
||||
|
||||
log(await options.sync.getCollection(collection, params))
|
||||
})
|
||||
|
||||
program
|
||||
.command('set <collection>')
|
||||
.description("Store one or multiple payloads from 'stdin' in the given collection.")
|
||||
.action((collection, id) => {
|
||||
console.error(`Write methods not yet implemented!
|
||||
Feel free to contribute at <https://github.com/valeriangalliat/firefox-sync-cli>!`)
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
program
|
||||
.command('delete <collection> <id...>')
|
||||
.description('Delete the given items by ID.')
|
||||
.action((collection, id) => {
|
||||
console.error(`Write methods not yet implemented!
|
||||
Feel free to contribute at <https://github.com/valeriangalliat/firefox-sync-cli>!`)
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
program
|
||||
.command('quota')
|
||||
.description('Get the current usage and storage quota.')
|
||||
.action(async options => log(await options.sync.getQuota()))
|
||||
|
||||
program
|
||||
.command('collection-usage')
|
||||
.description('Get the usage in kB of all collections.')
|
||||
.action(async options => log(await options.sync.getCollectionUsage()))
|
||||
|
||||
program
|
||||
.command('collection-counts')
|
||||
.description('Get the number of items in each collection.')
|
||||
.action(async options => log(await options.sync.getCollectionCounts()))
|
||||
|
||||
program
|
||||
.command('configuration')
|
||||
.description('Get the storage server configuration.')
|
||||
.action(async options => log(await options.sync.getConfiguration()))
|
||||
|
||||
function cli (argv) {
|
||||
return program.parseAsync(argv)
|
||||
}
|
||||
|
||||
module.exports = cli
|
5
ffs
Executable file
5
ffs
Executable file
|
@ -0,0 +1,5 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const cli = require('./cli')
|
||||
|
||||
cli(process.argv)
|
123
oauth.js
Normal file
123
oauth.js
Normal file
|
@ -0,0 +1,123 @@
|
|||
const fs = require('fs').promises
|
||||
const path = require('path')
|
||||
const os = require('os')
|
||||
const sqlite3 = require('sqlite3')
|
||||
const sqlite = require('sqlite')
|
||||
const ini = require('ini')
|
||||
|
||||
function getProfiles (dir, store) {
|
||||
return Object.keys(store)
|
||||
.filter(key => key.startsWith('Profile'))
|
||||
.map(key => store[key])
|
||||
.map(profile => ({
|
||||
name: profile.Name,
|
||||
path: path.resolve(dir, profile.Path)
|
||||
}))
|
||||
}
|
||||
|
||||
async function getFirefoxProfilesImpl (dir) {
|
||||
try {
|
||||
return getProfiles(dir, ini.parse(await fs.readFile(path.resolve(dir, 'profiles.ini'), 'utf8')))
|
||||
} catch (err) {
|
||||
if (err.code !== 'ENOENT') {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function getFirefoxProfiles () {
|
||||
return await getFirefoxProfilesImpl(path.resolve(os.homedir(), '.mozilla/firefox')) ||
|
||||
getFirefoxProfilesImpl(path.resolve(os.homedir(), 'Library/Application Support/Firefox'))
|
||||
}
|
||||
|
||||
async function fetchLastRedirectImpl (profile) {
|
||||
const db = await sqlite.open({
|
||||
filename: `file:${path.resolve(profile.path, 'places.sqlite')}?immutable=1`,
|
||||
driver: sqlite3.Database,
|
||||
mode: sqlite3.OPEN_READONLY | sqlite3.OPEN_URI
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await db.get("SELECT url FROM moz_places WHERE url LIKE 'https://lockbox.firefox.com/fxa/android-redirect.html%' ORDER BY last_visit_date DESC LIMIT 1")
|
||||
return result?.url
|
||||
} finally {
|
||||
await db.close()
|
||||
}
|
||||
}
|
||||
|
||||
function fetchLastRedirect (profiles) {
|
||||
return Promise.all(profiles.map(profile => fetchLastRedirectImpl(profile)))
|
||||
}
|
||||
|
||||
async function poll (profiles, initialState) {
|
||||
while (true) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
|
||||
const state = await fetchLastRedirect(profiles)
|
||||
|
||||
for (const [i, url] of state.entries()) {
|
||||
if (initialState[i] !== url) {
|
||||
console.error('\n')
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
process.stderr.write('.')
|
||||
}
|
||||
}
|
||||
|
||||
async function oauth (options) {
|
||||
const { sync, verbose } = options
|
||||
const profiles = await getFirefoxProfiles()
|
||||
|
||||
if (!profiles || profiles.length === 0) {
|
||||
console.error(`Couldn't find Firefox directory.
|
||||
|
||||
This method relies on parsing the Firefox history to retreive the OAuth
|
||||
challenge response and cannot work right now.
|
||||
|
||||
Consider using 'ffs auth password' as a fallback.
|
||||
`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (verbose) {
|
||||
for (const profile of profiles) {
|
||||
console.error(`[verbose] profile ${profile.name}`)
|
||||
}
|
||||
}
|
||||
|
||||
const initialState = await fetchLastRedirect(profiles)
|
||||
|
||||
if (verbose) {
|
||||
for (const [i, url] of initialState.entries()) {
|
||||
console.error(`[verbose] state [${profiles[i].name}] ${url}`)
|
||||
}
|
||||
|
||||
console.error()
|
||||
}
|
||||
|
||||
const challenge = await sync.auth.oauth.challenge()
|
||||
|
||||
console.error(`Visit the following URL in Firefox to sign in:
|
||||
|
||||
${challenge.url}
|
||||
`)
|
||||
|
||||
console.error(`We'll detect the OAuth response automatically.
|
||||
If it hangs, closing Firefox can sometimes help.
|
||||
Otheriwse, consider using 'ffs auth password' as a fallback.
|
||||
`)
|
||||
|
||||
const url = await poll(profiles, initialState)
|
||||
|
||||
if (verbose) {
|
||||
console.error(`[verbose] detected ${url}`)
|
||||
}
|
||||
|
||||
const result = Object.fromEntries(new URL(url).searchParams)
|
||||
|
||||
return sync.auth.oauth.complete(challenge, result)
|
||||
}
|
||||
|
||||
module.exports = oauth
|
32
package.json
Normal file
32
package.json
Normal file
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
"name": "firefox-sync-cli",
|
||||
"version": "0.0.0",
|
||||
"description": "Manage Firefox Sync from the CLI! ✨",
|
||||
"license": "Unlicense",
|
||||
"author": "Val (https://val.codejam.info)",
|
||||
"files": [
|
||||
"cli.js",
|
||||
"ffs",
|
||||
"oauth.js",
|
||||
"password.js",
|
||||
"prompt.js"
|
||||
],
|
||||
"bin": {
|
||||
"ffs": "ffs"
|
||||
},
|
||||
"repository": "valeriangalliat/firefox-sync-cli",
|
||||
"scripts": {
|
||||
"lint": "standard"
|
||||
},
|
||||
"dependencies": {
|
||||
"commander": "^8.2.0",
|
||||
"firefox-sync": "^1.0.0",
|
||||
"ini": "^2.0.0",
|
||||
"prompts": "^2.4.2",
|
||||
"sqlite": "^4.0.23",
|
||||
"sqlite3": "^5.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"standard": "^16.0.4"
|
||||
}
|
||||
}
|
10
password.js
Normal file
10
password.js
Normal file
|
@ -0,0 +1,10 @@
|
|||
const prompt = require('./prompt')
|
||||
|
||||
async function password (email, password, options) {
|
||||
email = email || await prompt({ type: 'text', message: 'Email:' })
|
||||
password = password || await prompt({ type: 'password', message: 'Password:' })
|
||||
|
||||
return options.sync.auth.password(email, password)
|
||||
}
|
||||
|
||||
module.exports = password
|
Loading…
Reference in a new issue