mastodon.rake 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637
  1. # frozen_string_literal: true
  2. require 'tty-prompt'
  3. namespace :mastodon do
  4. desc 'Configure the instance for production use'
  5. task :setup do
  6. prompt = TTY::Prompt.new
  7. env = {}
  8. # When the application code gets loaded, it runs `lib/mastodon/redis_configuration.rb`.
  9. # This happens before application environment configuration and sets REDIS_URL etc.
  10. # These variables are then used even when REDIS_HOST etc. are changed, so clear them
  11. # out so they don't interfere with our new configuration.
  12. ENV.delete('REDIS_URL')
  13. ENV.delete('CACHE_REDIS_URL')
  14. ENV.delete('SIDEKIQ_REDIS_URL')
  15. begin
  16. errors = false
  17. prompt.say('Your instance is identified by its domain name. Changing it afterward will break things.')
  18. env['LOCAL_DOMAIN'] = prompt.ask('Domain name:') do |q|
  19. q.required true
  20. q.modify :strip
  21. q.validate(/\A[a-z0-9.-]+\z/i)
  22. q.messages[:valid?] = 'Invalid domain. If you intend to use unicode characters, enter punycode here'
  23. end
  24. prompt.say "\n"
  25. prompt.say('Single user mode disables registrations and redirects the landing page to your public profile.')
  26. env['SINGLE_USER_MODE'] = prompt.yes?('Do you want to enable single user mode?', default: false)
  27. %w(SECRET_KEY_BASE OTP_SECRET).each do |key|
  28. env[key] = SecureRandom.hex(64)
  29. end
  30. vapid_key = Webpush.generate_key
  31. env['VAPID_PRIVATE_KEY'] = vapid_key.private_key
  32. env['VAPID_PUBLIC_KEY'] = vapid_key.public_key
  33. prompt.say "\n"
  34. using_docker = prompt.yes?('Are you using Docker to run Mastodon?')
  35. db_connection_works = false
  36. prompt.say "\n"
  37. loop do
  38. env['DB_HOST'] = prompt.ask('PostgreSQL host:') do |q|
  39. q.required true
  40. q.default using_docker ? 'db' : '/var/run/postgresql'
  41. q.modify :strip
  42. end
  43. env['DB_PORT'] = prompt.ask('PostgreSQL port:') do |q|
  44. q.required true
  45. q.default 5432
  46. q.convert :int
  47. end
  48. env['DB_NAME'] = prompt.ask('Name of PostgreSQL database:') do |q|
  49. q.required true
  50. q.default using_docker ? 'postgres' : 'mastodon_production'
  51. q.modify :strip
  52. end
  53. env['DB_USER'] = prompt.ask('Name of PostgreSQL user:') do |q|
  54. q.required true
  55. q.default using_docker ? 'postgres' : 'mastodon'
  56. q.modify :strip
  57. end
  58. env['DB_PASS'] = prompt.ask('Password of PostgreSQL user:') do |q|
  59. q.echo false
  60. end
  61. # The chosen database may not exist yet. Connect to default database
  62. # to avoid "database does not exist" error.
  63. db_options = {
  64. adapter: :postgresql,
  65. database: 'postgres',
  66. host: env['DB_HOST'],
  67. port: env['DB_PORT'],
  68. user: env['DB_USER'],
  69. password: env['DB_PASS'],
  70. }
  71. begin
  72. ActiveRecord::Base.establish_connection(db_options)
  73. ActiveRecord::Base.connection
  74. prompt.ok 'Database configuration works! 🎆'
  75. db_connection_works = true
  76. break
  77. rescue => e
  78. prompt.error 'Database connection could not be established with this configuration, try again.'
  79. prompt.error e.message
  80. unless prompt.yes?('Try again?')
  81. return prompt.warn 'Nothing saved. Bye!' unless prompt.yes?('Continue anyway?')
  82. errors = true
  83. break
  84. end
  85. end
  86. end
  87. prompt.say "\n"
  88. loop do
  89. env['REDIS_HOST'] = prompt.ask('Redis host:') do |q|
  90. q.required true
  91. q.default using_docker ? 'redis' : 'localhost'
  92. q.modify :strip
  93. end
  94. env['REDIS_PORT'] = prompt.ask('Redis port:') do |q|
  95. q.required true
  96. q.default 6379
  97. q.convert :int
  98. end
  99. env['REDIS_PASSWORD'] = prompt.ask('Redis password:') do |q|
  100. q.required false
  101. q.default nil
  102. q.modify :strip
  103. end
  104. redis_options = {
  105. host: env['REDIS_HOST'],
  106. port: env['REDIS_PORT'],
  107. password: env['REDIS_PASSWORD'],
  108. driver: :hiredis,
  109. }
  110. begin
  111. redis = Redis.new(redis_options)
  112. redis.ping
  113. prompt.ok 'Redis configuration works! 🎆'
  114. break
  115. rescue => e
  116. prompt.error 'Redis connection could not be established with this configuration, try again.'
  117. prompt.error e.message
  118. unless prompt.yes?('Try again?')
  119. return prompt.warn 'Nothing saved. Bye!' unless prompt.yes?('Continue anyway?')
  120. errors = true
  121. break
  122. end
  123. end
  124. end
  125. prompt.say "\n"
  126. if prompt.yes?('Do you want to store uploaded files on the cloud?', default: false)
  127. case prompt.select('Provider', ['DigitalOcean Spaces', 'Amazon S3', 'Wasabi', 'Minio', 'Google Cloud Storage', 'Storj DCS'])
  128. when 'DigitalOcean Spaces'
  129. env['S3_ENABLED'] = 'true'
  130. env['S3_PROTOCOL'] = 'https'
  131. env['S3_BUCKET'] = prompt.ask('Space name:') do |q|
  132. q.required true
  133. q.default "files.#{env['LOCAL_DOMAIN']}"
  134. q.modify :strip
  135. end
  136. env['S3_REGION'] = prompt.ask('Space region:') do |q|
  137. q.required true
  138. q.default 'nyc3'
  139. q.modify :strip
  140. end
  141. env['S3_HOSTNAME'] = prompt.ask('Space endpoint:') do |q|
  142. q.required true
  143. q.default 'nyc3.digitaloceanspaces.com'
  144. q.modify :strip
  145. end
  146. env['S3_ENDPOINT'] = "https://#{env['S3_HOSTNAME']}"
  147. env['AWS_ACCESS_KEY_ID'] = prompt.ask('Space access key:') do |q|
  148. q.required true
  149. q.modify :strip
  150. end
  151. env['AWS_SECRET_ACCESS_KEY'] = prompt.ask('Space secret key:') do |q|
  152. q.required true
  153. q.modify :strip
  154. end
  155. when 'Amazon S3'
  156. env['S3_ENABLED'] = 'true'
  157. env['S3_PROTOCOL'] = 'https'
  158. env['S3_BUCKET'] = prompt.ask('S3 bucket name:') do |q|
  159. q.required true
  160. q.default "files.#{env['LOCAL_DOMAIN']}"
  161. q.modify :strip
  162. end
  163. env['S3_REGION'] = prompt.ask('S3 region:') do |q|
  164. q.required true
  165. q.default 'us-east-1'
  166. q.modify :strip
  167. end
  168. env['S3_HOSTNAME'] = prompt.ask('S3 hostname:') do |q|
  169. q.required true
  170. q.default 's3.us-east-1.amazonaws.com'
  171. q.modify :strip
  172. end
  173. env['AWS_ACCESS_KEY_ID'] = prompt.ask('S3 access key:') do |q|
  174. q.required true
  175. q.modify :strip
  176. end
  177. env['AWS_SECRET_ACCESS_KEY'] = prompt.ask('S3 secret key:') do |q|
  178. q.required true
  179. q.modify :strip
  180. end
  181. when 'Wasabi'
  182. env['S3_ENABLED'] = 'true'
  183. env['S3_PROTOCOL'] = 'https'
  184. env['S3_REGION'] = 'us-east-1'
  185. env['S3_HOSTNAME'] = 's3.wasabisys.com'
  186. env['S3_ENDPOINT'] = 'https://s3.wasabisys.com/'
  187. env['S3_BUCKET'] = prompt.ask('Wasabi bucket name:') do |q|
  188. q.required true
  189. q.default "files.#{env['LOCAL_DOMAIN']}"
  190. q.modify :strip
  191. end
  192. env['AWS_ACCESS_KEY_ID'] = prompt.ask('Wasabi access key:') do |q|
  193. q.required true
  194. q.modify :strip
  195. end
  196. env['AWS_SECRET_ACCESS_KEY'] = prompt.ask('Wasabi secret key:') do |q|
  197. q.required true
  198. q.modify :strip
  199. end
  200. when 'Minio'
  201. env['S3_ENABLED'] = 'true'
  202. env['S3_PROTOCOL'] = 'https'
  203. env['S3_REGION'] = 'us-east-1'
  204. env['S3_ENDPOINT'] = prompt.ask('Minio endpoint URL:') do |q|
  205. q.required true
  206. q.modify :strip
  207. end
  208. env['S3_PROTOCOL'] = env['S3_ENDPOINT'].start_with?('https') ? 'https' : 'http'
  209. env['S3_HOSTNAME'] = env['S3_ENDPOINT'].gsub(%r{\Ahttps?://}, '')
  210. env['S3_BUCKET'] = prompt.ask('Minio bucket name:') do |q|
  211. q.required true
  212. q.default "files.#{env['LOCAL_DOMAIN']}"
  213. q.modify :strip
  214. end
  215. env['AWS_ACCESS_KEY_ID'] = prompt.ask('Minio access key:') do |q|
  216. q.required true
  217. q.modify :strip
  218. end
  219. env['AWS_SECRET_ACCESS_KEY'] = prompt.ask('Minio secret key:') do |q|
  220. q.required true
  221. q.modify :strip
  222. end
  223. when 'Storj DCS'
  224. env['S3_ENABLED'] = 'true'
  225. env['S3_PROTOCOL'] = 'https'
  226. env['S3_REGION'] = 'global'
  227. env['S3_ENDPOINT'] = prompt.ask('Storj DCS endpoint URL:') do |q|
  228. q.required true
  229. q.default 'https://gateway.storjshare.io'
  230. q.modify :strip
  231. end
  232. env['S3_PROTOCOL'] = env['S3_ENDPOINT'].start_with?('https') ? 'https' : 'http'
  233. env['S3_HOSTNAME'] = env['S3_ENDPOINT'].gsub(%r{\Ahttps?://}, '')
  234. env['S3_BUCKET'] = prompt.ask('Storj DCS bucket name:') do |q|
  235. q.required true
  236. q.default "files.#{env['LOCAL_DOMAIN']}"
  237. q.modify :strip
  238. end
  239. env['AWS_ACCESS_KEY_ID'] = prompt.ask('Storj Gateway access key (uplink share --register --readonly=false --not-after=none sj://bucket):') do |q|
  240. q.required true
  241. q.modify :strip
  242. end
  243. env['AWS_SECRET_ACCESS_KEY'] = prompt.ask('Storj Gateway secret key:') do |q|
  244. q.required true
  245. q.modify :strip
  246. end
  247. linksharing_access_key = prompt.ask('Storj Linksharing access key (uplink share --register --public --readonly=true --disallow-lists --not-after=none sj://bucket):') do |q|
  248. q.required true
  249. q.modify :strip
  250. end
  251. env['S3_ALIAS_HOST'] = "link.storjshare.io/raw/#{linksharing_access_key}/#{env['S3_BUCKET']}"
  252. when 'Google Cloud Storage'
  253. env['S3_ENABLED'] = 'true'
  254. env['S3_PROTOCOL'] = 'https'
  255. env['S3_HOSTNAME'] = 'storage.googleapis.com'
  256. env['S3_ENDPOINT'] = 'https://storage.googleapis.com'
  257. env['S3_MULTIPART_THRESHOLD'] = 50.megabytes
  258. env['S3_BUCKET'] = prompt.ask('GCS bucket name:') do |q|
  259. q.required true
  260. q.default "files.#{env['LOCAL_DOMAIN']}"
  261. q.modify :strip
  262. end
  263. env['S3_REGION'] = prompt.ask('GCS region:') do |q|
  264. q.required true
  265. q.default 'us-west1'
  266. q.modify :strip
  267. end
  268. env['AWS_ACCESS_KEY_ID'] = prompt.ask('GCS access key:') do |q|
  269. q.required true
  270. q.modify :strip
  271. end
  272. env['AWS_SECRET_ACCESS_KEY'] = prompt.ask('GCS secret key:') do |q|
  273. q.required true
  274. q.modify :strip
  275. end
  276. end
  277. if prompt.yes?('Do you want to access the uploaded files from your own domain?')
  278. env['S3_ALIAS_HOST'] = prompt.ask('Domain for uploaded files:') do |q|
  279. q.required true
  280. q.default "files.#{env['LOCAL_DOMAIN']}"
  281. q.modify :strip
  282. end
  283. end
  284. end
  285. prompt.say "\n"
  286. loop do
  287. if prompt.yes?('Do you want to send e-mails from localhost?', default: false)
  288. env['SMTP_SERVER'] = 'localhost'
  289. env['SMTP_PORT'] = 25
  290. env['SMTP_AUTH_METHOD'] = 'none'
  291. env['SMTP_OPENSSL_VERIFY_MODE'] = 'none'
  292. env['SMTP_ENABLE_STARTTLS'] = 'auto'
  293. else
  294. env['SMTP_SERVER'] = prompt.ask('SMTP server:') do |q|
  295. q.required true
  296. q.default 'smtp.mailgun.org'
  297. q.modify :strip
  298. end
  299. env['SMTP_PORT'] = prompt.ask('SMTP port:') do |q|
  300. q.required true
  301. q.default 587
  302. q.convert :int
  303. end
  304. env['SMTP_LOGIN'] = prompt.ask('SMTP username:') do |q|
  305. q.modify :strip
  306. end
  307. env['SMTP_PASSWORD'] = prompt.ask('SMTP password:') do |q|
  308. q.echo false
  309. end
  310. env['SMTP_AUTH_METHOD'] = prompt.ask('SMTP authentication:') do |q|
  311. q.required
  312. q.default 'plain'
  313. q.modify :strip
  314. end
  315. env['SMTP_OPENSSL_VERIFY_MODE'] = prompt.select('SMTP OpenSSL verify mode:', %w(none peer client_once fail_if_no_peer_cert))
  316. env['SMTP_ENABLE_STARTTLS'] = prompt.select('Enable STARTTLS:', %w(auto always never))
  317. end
  318. env['SMTP_FROM_ADDRESS'] = prompt.ask('E-mail address to send e-mails "from":') do |q|
  319. q.required true
  320. q.default "Mastodon <notifications@#{env['LOCAL_DOMAIN']}>"
  321. q.modify :strip
  322. end
  323. break unless prompt.yes?('Send a test e-mail with this configuration right now?')
  324. send_to = prompt.ask('Send test e-mail to:', required: true)
  325. begin
  326. enable_starttls = nil
  327. enable_starttls_auto = nil
  328. case env['SMTP_ENABLE_STARTTLS']
  329. when 'always'
  330. enable_starttls = true
  331. when 'never'
  332. enable_starttls = false
  333. when 'auto'
  334. enable_starttls_auto = true
  335. else
  336. enable_starttls_auto = env['SMTP_ENABLE_STARTTLS_AUTO'] != 'false'
  337. end
  338. ActionMailer::Base.smtp_settings = {
  339. port: env['SMTP_PORT'],
  340. address: env['SMTP_SERVER'],
  341. user_name: env['SMTP_LOGIN'].presence,
  342. password: env['SMTP_PASSWORD'].presence,
  343. domain: env['LOCAL_DOMAIN'],
  344. authentication: env['SMTP_AUTH_METHOD'] == 'none' ? nil : env['SMTP_AUTH_METHOD'] || :plain,
  345. openssl_verify_mode: env['SMTP_OPENSSL_VERIFY_MODE'],
  346. enable_starttls: enable_starttls,
  347. enable_starttls_auto: enable_starttls_auto,
  348. }
  349. ActionMailer::Base.default_options = {
  350. from: env['SMTP_FROM_ADDRESS'],
  351. }
  352. mail = ActionMailer::Base.new.mail(
  353. to: send_to,
  354. subject: 'Test', # rubocop:disable Rails/I18nLocaleTexts
  355. body: 'Mastodon SMTP configuration works!'
  356. )
  357. mail.deliver
  358. break
  359. rescue => e
  360. prompt.error 'E-mail could not be sent with this configuration, try again.'
  361. prompt.error e.message
  362. unless prompt.yes?('Try again?')
  363. return prompt.warn 'Nothing saved. Bye!' unless prompt.yes?('Continue anyway?')
  364. errors = true
  365. break
  366. end
  367. end
  368. end
  369. prompt.say "\n"
  370. env['UPDATE_CHECK_URL'] = '' unless prompt.yes?('Do you want Mastodon to periodically check for important updates and notify you? (Recommended)', default: true)
  371. prompt.say "\n"
  372. prompt.say 'This configuration will be written to .env.production'
  373. if prompt.yes?('Save configuration?')
  374. incompatible_syntax = false
  375. env_contents = env.each_pair.map do |key, value|
  376. value = value.to_s
  377. escaped = dotenv_escape(value)
  378. incompatible_syntax = true if value != escaped
  379. "#{key}=#{escaped}"
  380. end.join("\n")
  381. generated_header = generate_header(incompatible_syntax)
  382. Rails.root.join('.env.production').write("#{generated_header}#{env_contents}\n")
  383. if using_docker
  384. prompt.ok 'Below is your configuration, save it to an .env.production file outside Docker:'
  385. prompt.say "\n"
  386. prompt.say "#{generated_header}#{env.each_pair.map { |key, value| "#{key}=#{value}" }.join("\n")}"
  387. prompt.say "\n"
  388. prompt.ok 'It is also saved within this container so you can proceed with this wizard.'
  389. end
  390. prompt.say "\n"
  391. prompt.say 'Now that configuration is saved, the database schema must be loaded.'
  392. prompt.warn 'If the database already exists, this will erase its contents.'
  393. if prompt.yes?('Prepare the database now?')
  394. prompt.say 'Running `RAILS_ENV=production rails db:setup` ...'
  395. prompt.say "\n\n"
  396. if system(env.transform_values(&:to_s).merge({ 'RAILS_ENV' => 'production', 'SAFETY_ASSURED' => '1' }), 'rails db:setup')
  397. prompt.ok 'Done!'
  398. else
  399. prompt.error 'That failed! Perhaps your configuration is not right'
  400. errors = true
  401. end
  402. end
  403. unless using_docker
  404. prompt.say "\n"
  405. prompt.say 'The final step is compiling CSS/JS assets.'
  406. prompt.say 'This may take a while and consume a lot of RAM.'
  407. if prompt.yes?('Compile the assets now?')
  408. prompt.say 'Running `RAILS_ENV=production rails assets:precompile` ...'
  409. prompt.say "\n\n"
  410. if system(env.transform_values(&:to_s).merge({ 'RAILS_ENV' => 'production' }), 'rails assets:precompile')
  411. prompt.say 'Done!'
  412. else
  413. prompt.error 'That failed! Maybe you need swap space?'
  414. errors = true
  415. end
  416. end
  417. end
  418. prompt.say "\n"
  419. if errors
  420. prompt.warn 'Your Mastodon server is set up, but there were some errors along the way, you may have to fix them.'
  421. else
  422. prompt.ok 'All done! You can now power on the Mastodon server 🐘'
  423. end
  424. prompt.say "\n"
  425. if db_connection_works && prompt.yes?('Do you want to create an admin user straight away?')
  426. env.each_pair do |key, value|
  427. ENV[key] = value.to_s
  428. end
  429. require_relative '../../config/environment'
  430. disable_log_stdout!
  431. username = prompt.ask('Username:') do |q|
  432. q.required true
  433. q.default 'admin'
  434. q.validate(/\A[a-z0-9_]+\z/i)
  435. q.modify :strip
  436. end
  437. email = prompt.ask('E-mail:') do |q|
  438. q.required true
  439. q.modify :strip
  440. end
  441. password = SecureRandom.hex(16)
  442. owner_role = UserRole.find_by(name: 'Owner')
  443. user = User.new(email: email, password: password, confirmed_at: Time.now.utc, account_attributes: { username: username }, bypass_invite_request_check: true, role: owner_role)
  444. user.save(validate: false)
  445. Setting.site_contact_username = username
  446. prompt.ok "You can login with the password: #{password}"
  447. prompt.warn 'You can change your password once you login.'
  448. end
  449. else
  450. prompt.warn 'Nothing saved. Bye!'
  451. end
  452. rescue TTY::Reader::InputInterrupt
  453. prompt.ok 'Aborting. Bye!'
  454. end
  455. end
  456. namespace :webpush do
  457. desc 'Generate VAPID key'
  458. task :generate_vapid_key do
  459. vapid_key = Webpush.generate_key
  460. puts "VAPID_PRIVATE_KEY=#{vapid_key.private_key}"
  461. puts "VAPID_PUBLIC_KEY=#{vapid_key.public_key}"
  462. end
  463. end
  464. private
  465. def generate_header(include_warning)
  466. default_message = "# Generated with mastodon:setup on #{Time.now.utc}\n\n"
  467. default_message.tap do |string|
  468. if include_warning
  469. string << "# Some variables in this file will be interpreted differently whether you are\n"
  470. string << "# using docker-compose or not.\n\n"
  471. end
  472. end
  473. end
  474. end
  475. def disable_log_stdout!
  476. dev_null = Logger.new('/dev/null')
  477. Rails.logger = dev_null
  478. ActiveRecord::Base.logger = dev_null
  479. HttpLog.configuration.logger = dev_null
  480. Paperclip.options[:log] = false
  481. end
  482. def dotenv_escape(value)
  483. # Dotenv has its own parser, which unfortunately deviates somewhat from
  484. # what shells actually do.
  485. #
  486. # In particular, we can't use Shellwords::escape because it outputs a
  487. # non-quotable string, while Dotenv requires `#` to always be in quoted
  488. # strings.
  489. #
  490. # Therefore, we need to write our own escape code…
  491. # Dotenv's parser has a *lot* of edge cases, and I think not every
  492. # ASCII string can even be represented into something Dotenv can parse,
  493. # so this is a best effort thing.
  494. #
  495. # In particular, strings with all the following probably cannot be
  496. # escaped:
  497. # - `#`, or ends with spaces, which requires some form of quoting (simply escaping won't work)
  498. # - `'` (single quote), preventing us from single-quoting
  499. # - `\` followed by either `r` or `n`
  500. # No character that would cause Dotenv trouble
  501. return value unless /[\s\#\\"'$]/.match?(value)
  502. # As long as the value doesn't include single quotes, we can safely
  503. # rely on single quotes
  504. return "'#{value}'" unless value.include?("'")
  505. # If the value contains the string '\n' or '\r' we simply can't use
  506. # a double-quoted string, because Dotenv will expand \n or \r no
  507. # matter how much escaping we add.
  508. double_quoting_disallowed = /\\[rn]/.match?(value)
  509. value = value.gsub(double_quoting_disallowed ? /[\\"'\s]/ : /[\\"']/) { |x| "\\#{x}" }
  510. # Dotenv is especially tricky with `$` as unbalanced
  511. # parenthesis will make it not unescape `\$` as `$`…
  512. # Variables
  513. value = value.gsub(/\$(?!\()/) { |x| "\\#{x}" }
  514. # Commands
  515. value = value.gsub(/\$(?<cmd>\((?:[^()]|\g<cmd>)+\))/) { |x| "\\#{x}" }
  516. value = "\"#{value}\"" unless double_quoting_disallowed
  517. value
  518. end