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