diff --git a/lua/pending/sync/s3.lua b/lua/pending/sync/s3.lua index 3c2cee3..d3ad5b5 100644 --- a/lua/pending/sync/s3.lua +++ b/lua/pending/sync/s3.lua @@ -66,6 +66,49 @@ local function ensure_sync_id(task) return sync_id end +local function create_bucket() + local name = util.input({ prompt = 'S3 bucket name: ', default = 'pending.nvim' }) + if not name or name == '' then + log.info('s3: bucket creation cancelled') + return + end + + local region_cmd = base_cmd() + vim.list_extend(region_cmd, { 'configure', 'get', 'region' }) + local region_result = util.system(region_cmd, { text = true }) + local default_region = 'us-east-1' + if region_result.code == 0 and region_result.stdout then + local detected = vim.trim(region_result.stdout) + if detected ~= '' then + default_region = detected + end + end + + local region = util.input({ prompt = 'AWS region: ', default = default_region }) + if not region or region == '' then + region = 'us-east-1' + end + + local cmd = base_cmd() + vim.list_extend(cmd, { 's3api', 'create-bucket', '--bucket', name, '--region', region }) + if region ~= 'us-east-1' then + vim.list_extend(cmd, { '--create-bucket-configuration', 'LocationConstraint=' .. region }) + end + + local result = util.system(cmd, { text = true }) + if result.code == 0 then + log.info( + 's3: bucket created. Add to your config:\n sync = { s3 = { bucket = "' + .. name + .. '", region = "' + .. region + .. '" } }' + ) + else + log.error('s3: bucket creation failed — ' .. (result.stderr or 'unknown error')) + end +end + ---@param args? string ---@return nil function M.auth(args) @@ -96,6 +139,10 @@ function M.auth(args) else log.info('s3: credentials valid') end + local s3cfg = get_config() + if not s3cfg or not s3cfg.bucket then + create_bucket() + end else local stderr = result.stderr or '' if stderr:find('SSO') or stderr:find('sso') then diff --git a/lua/pending/sync/util.lua b/lua/pending/sync/util.lua index 176b91a..269acdf 100644 --- a/lua/pending/sync/util.lua +++ b/lua/pending/sync/util.lua @@ -35,6 +35,21 @@ function M.system(args, opts) return coroutine.yield() --[[@as { code: integer, stdout: string, stderr: string }]] end +---@param opts? {prompt?: string, default?: string} +---@return string? +function M.input(opts) + local co = coroutine.running() + if not co then + error('util.input() must be called inside a coroutine') + end + vim.ui.input(opts or {}, function(result) + vim.schedule(function() + coroutine.resume(co, result) + end) + end) + return coroutine.yield() --[[@as string?]] +end + ---@param name string ---@param fn fun(): nil function M.with_guard(name, fn) diff --git a/spec/s3_spec.lua b/spec/s3_spec.lua index 137b209..c1b4c68 100644 --- a/spec/s3_spec.lua +++ b/spec/s3_spec.lua @@ -99,6 +99,28 @@ describe('s3', function() assert.truthy(msg and msg:find('authenticated')) end) + it('skips bucket creation when bucket is configured', function() + util.system = function(args) + if vim.tbl_contains(args, 'get-caller-identity') then + return { + code = 0, + stdout = '{"Account":"123456","Arn":"arn:aws:iam::user/test"}', + stderr = '', + } + end + return { code = 0, stdout = '', stderr = '' } + end + local orig_input = util.input + local input_called = false + util.input = function() + input_called = true + return nil + end + s3.auth() + util.input = orig_input + assert.is_false(input_called) + end) + it('detects SSO expiry', function() util.system = function(args) if vim.tbl_contains(args, 'get-caller-identity') then @@ -133,6 +155,225 @@ describe('s3', function() end) end) + describe('auth bucket creation', function() + local orig_input + + before_each(function() + vim.g.pending = { data_path = tmpdir .. '/tasks.json', sync = { s3 = {} } } + config.reset() + package.loaded['pending'] = nil + package.loaded['pending.sync.s3'] = nil + pending = require('pending') + s3 = require('pending.sync.s3') + orig_input = util.input + end) + + after_each(function() + util.input = orig_input + end) + + it('prompts for bucket when none configured', function() + local input_calls = {} + util.input = function(opts) + table.insert(input_calls, opts) + if opts.prompt:find('bucket') then + return 'my-bucket' + end + return opts.default + end + local create_args + util.system = function(args) + if vim.tbl_contains(args, 'get-caller-identity') then + return { + code = 0, + stdout = '{"Account":"123","Arn":"arn:aws:iam::user/test"}', + stderr = '', + } + end + if vim.tbl_contains(args, 'configure') then + return { code = 0, stdout = 'us-west-2\n', stderr = '' } + end + if vim.tbl_contains(args, 'create-bucket') then + create_args = args + return { code = 0, stdout = '', stderr = '' } + end + return { code = 0, stdout = '', stderr = '' } + end + local msg + local orig_notify = vim.notify + vim.notify = function(m) + msg = m + end + s3.auth() + vim.notify = orig_notify + assert.equals(2, #input_calls) + assert.is_not_nil(create_args) + assert.truthy(vim.tbl_contains(create_args, 'my-bucket')) + assert.truthy(msg and msg:find('bucket created')) + end) + + it('cancels when user provides empty bucket name', function() + util.input = function(opts) + if opts.prompt:find('bucket') then + return nil + end + return opts.default + end + util.system = function(args) + if vim.tbl_contains(args, 'get-caller-identity') then + return { + code = 0, + stdout = '{"Account":"123","Arn":"arn:aws:iam::user/test"}', + stderr = '', + } + end + return { code = 0, stdout = '', stderr = '' } + end + local msg + local orig_notify = vim.notify + vim.notify = function(m) + msg = m + end + s3.auth() + vim.notify = orig_notify + assert.truthy(msg and msg:find('cancelled')) + end) + + it('omits LocationConstraint for us-east-1', function() + util.input = function(opts) + if opts.prompt:find('bucket') then + return 'my-bucket' + end + if opts.prompt:find('region') then + return 'us-east-1' + end + return opts.default + end + local create_args + util.system = function(args) + if vim.tbl_contains(args, 'get-caller-identity') then + return { + code = 0, + stdout = '{"Account":"123","Arn":"arn:aws:iam::user/test"}', + stderr = '', + } + end + if vim.tbl_contains(args, 'configure') then + return { code = 0, stdout = 'us-east-1\n', stderr = '' } + end + if vim.tbl_contains(args, 'create-bucket') then + create_args = args + return { code = 0, stdout = '', stderr = '' } + end + return { code = 0, stdout = '', stderr = '' } + end + s3.auth() + assert.is_not_nil(create_args) + local joined = table.concat(create_args, ' ') + assert.falsy(joined:find('LocationConstraint')) + end) + + it('includes LocationConstraint for non-us-east-1 regions', function() + util.input = function(opts) + if opts.prompt:find('bucket') then + return 'my-bucket' + end + if opts.prompt:find('region') then + return 'eu-west-1' + end + return opts.default + end + local create_args + util.system = function(args) + if vim.tbl_contains(args, 'get-caller-identity') then + return { + code = 0, + stdout = '{"Account":"123","Arn":"arn:aws:iam::user/test"}', + stderr = '', + } + end + if vim.tbl_contains(args, 'configure') then + return { code = 0, stdout = 'eu-west-1\n', stderr = '' } + end + if vim.tbl_contains(args, 'create-bucket') then + create_args = args + return { code = 0, stdout = '', stderr = '' } + end + return { code = 0, stdout = '', stderr = '' } + end + s3.auth() + assert.is_not_nil(create_args) + assert.truthy(vim.tbl_contains(create_args, 'LocationConstraint=eu-west-1')) + end) + + it('reports error on bucket creation failure', function() + util.input = function(opts) + if opts.prompt:find('bucket') then + return 'bad-bucket' + end + return opts.default + end + util.system = function(args) + if vim.tbl_contains(args, 'get-caller-identity') then + return { + code = 0, + stdout = '{"Account":"123","Arn":"arn:aws:iam::user/test"}', + stderr = '', + } + end + if vim.tbl_contains(args, 'configure') then + return { code = 0, stdout = 'us-east-1\n', stderr = '' } + end + if vim.tbl_contains(args, 'create-bucket') then + return { code = 1, stdout = '', stderr = 'BucketAlreadyExists' } + end + return { code = 0, stdout = '', stderr = '' } + end + local msg + local orig_notify = vim.notify + vim.notify = function(m, level) + if level == vim.log.levels.ERROR then + msg = m + end + end + s3.auth() + vim.notify = orig_notify + assert.truthy(msg and msg:find('bucket creation failed')) + end) + + it('defaults region to us-east-1 when aws configure returns nothing', function() + util.input = function(opts) + if opts.prompt:find('bucket') then + return 'my-bucket' + end + return '' + end + local create_args + util.system = function(args) + if vim.tbl_contains(args, 'get-caller-identity') then + return { + code = 0, + stdout = '{"Account":"123","Arn":"arn:aws:iam::user/test"}', + stderr = '', + } + end + if vim.tbl_contains(args, 'configure') then + return { code = 1, stdout = '', stderr = '' } + end + if vim.tbl_contains(args, 'create-bucket') then + create_args = args + return { code = 0, stdout = '', stderr = '' } + end + return { code = 0, stdout = '', stderr = '' } + end + s3.auth() + assert.is_not_nil(create_args) + assert.truthy(vim.tbl_contains(create_args, 'us-east-1')) + local joined = table.concat(create_args, ' ') + assert.falsy(joined:find('LocationConstraint')) + end) + end) + describe('push', function() it('uploads store to S3', function() local s = pending.store()