flumli 0.0.1-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,268 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ /**
3
+ * Runs a gh command and returns a consistent result.
4
+ */
5
+ function gh(args, cwd) {
6
+ const proc = spawnSync('gh', args, { encoding: 'utf-8', cwd });
7
+ return {
8
+ ok: proc.status === 0,
9
+ output: (proc.stdout ?? '').trim(),
10
+ error: (proc.stderr ?? '').trim(),
11
+ };
12
+ }
13
+ function git(args, cwd) {
14
+ const proc = spawnSync('git', args, { encoding: 'utf-8', cwd });
15
+ return {
16
+ ok: proc.status === 0,
17
+ output: (proc.stdout ?? '').trim(),
18
+ error: (proc.stderr ?? '').trim(),
19
+ };
20
+ }
21
+ /**
22
+ * Checks if the GitHub CLI is installed.
23
+ */
24
+ export function isGhInstalled() {
25
+ return gh(['--version']).ok;
26
+ }
27
+ /**
28
+ * Checks if the user is authenticated with GitHub.
29
+ */
30
+ export function isGhAuthenticated() {
31
+ return gh(['auth', 'status']).ok;
32
+ }
33
+ /**
34
+ * Returns owner/repo if inside a git repo with a GitHub remote, null otherwise.
35
+ */
36
+ export function getRepoInfo() {
37
+ const { ok, output } = gh(['repo', 'view', '--json', 'nameWithOwner', '-q', '.nameWithOwner']);
38
+ if (!ok)
39
+ return null;
40
+ const [owner, repo] = output.split('/');
41
+ if (owner && repo)
42
+ return { owner, repo };
43
+ return null;
44
+ }
45
+ /**
46
+ * Forks a repository on GitHub.
47
+ */
48
+ export function forkRepository(owner, repo) {
49
+ const { ok, output, error } = gh([
50
+ 'repo',
51
+ 'fork',
52
+ `${owner}/${repo}`,
53
+ '--clone=false',
54
+ '--default-branch-only',
55
+ ]);
56
+ const combined = `${output}\n${error}`.trim();
57
+ const alreadyExists = combined.includes('already exists');
58
+ if (!ok && !alreadyExists) {
59
+ return { status: 'error', message: error || 'Fork failed' };
60
+ }
61
+ const match = combined.match(/[\w-]+\/[\w.-]+/);
62
+ if (match) {
63
+ const [forkOwner, forkRepo] = match[0].split('/');
64
+ return {
65
+ status: alreadyExists ? 'already_exists' : 'forked',
66
+ owner: forkOwner,
67
+ repo: forkRepo,
68
+ };
69
+ }
70
+ return { status: alreadyExists ? 'already_exists' : 'forked', owner, repo };
71
+ }
72
+ /**
73
+ * Clones a repo into the given directory.
74
+ */
75
+ export function cloneRepo(owner, repo, dir) {
76
+ const target = dir ?? repo;
77
+ return gh(['repo', 'clone', `${owner}/${repo}`, target]);
78
+ }
79
+ /**
80
+ * Creates a new branch and switches to it.
81
+ */
82
+ export function createBranch(name) {
83
+ const proc = spawnSync('git', ['checkout', '-b', name], { encoding: 'utf-8' });
84
+ return {
85
+ ok: proc.status === 0,
86
+ output: (proc.stdout ?? '').trim(),
87
+ error: (proc.stderr ?? '').trim(),
88
+ };
89
+ }
90
+ /**
91
+ * Pushes the current branch to the given remote (default: origin).
92
+ */
93
+ export function pushBranch(remote = 'origin') {
94
+ const proc = spawnSync('git', ['push', '-u', remote, 'HEAD'], { encoding: 'utf-8' });
95
+ return {
96
+ ok: proc.status === 0,
97
+ output: (proc.stdout ?? '').trim(),
98
+ error: (proc.stderr ?? '').trim(),
99
+ };
100
+ }
101
+ /**
102
+ * Checks if the current branch has unpushed commits.
103
+ */
104
+ export function hasUnpushedCommits() {
105
+ const proc = spawnSync('git', ['log', '--oneline', '@{u}..HEAD'], { encoding: 'utf-8' });
106
+ // If no upstream exists, everything is unpushed
107
+ if (proc.status !== 0)
108
+ return true;
109
+ return (proc.stdout ?? '').trim().length > 0;
110
+ }
111
+ /**
112
+ * Checks if a PR already exists for the current branch.
113
+ */
114
+ export function prExists() {
115
+ const { ok, output } = gh(['pr', 'view', '--json', 'number', '-q', '.number']);
116
+ return ok && output.length > 0;
117
+ }
118
+ /**
119
+ * Returns the URL of the PR for the current branch, or null.
120
+ */
121
+ export function getPrUrl() {
122
+ const { ok, output } = gh(['pr', 'view', '--json', 'url', '-q', '.url']);
123
+ return ok && output ? output : null;
124
+ }
125
+ /**
126
+ * Creates a draft PR against the upstream repo.
127
+ */
128
+ export function createDraftPR(title, body) {
129
+ return gh(['pr', 'create', '--draft', '--title', title, '--body', body]);
130
+ }
131
+ /**
132
+ * Checks if the current branch has an upstream tracking branch.
133
+ */
134
+ export function hasUpstream() {
135
+ const proc = spawnSync('git', ['rev-parse', '--abbrev-ref', '@{u}'], { encoding: 'utf-8' });
136
+ return proc.status === 0;
137
+ }
138
+ /**
139
+ * Returns the current branch name.
140
+ */
141
+ export function getCurrentBranch() {
142
+ const proc = spawnSync('git', ['branch', '--show-current'], { encoding: 'utf-8' });
143
+ if (proc.status !== 0)
144
+ return null;
145
+ return (proc.stdout ?? '').trim() || null;
146
+ }
147
+ export function checkout(branch, expectedRepo, cwd) {
148
+ // Safety: verify we're in the right repo before touching anything
149
+ if (expectedRepo) {
150
+ const info = getRepoInfoAt(cwd);
151
+ if (!info) {
152
+ return { status: 'error', message: 'Not inside a git repository' };
153
+ }
154
+ const currentRepo = `${info.owner}/${info.repo}`;
155
+ if (currentRepo.toLowerCase() !== expectedRepo.toLowerCase()) {
156
+ return {
157
+ status: 'wrong_repo',
158
+ message: `Wrong repo: CLI is in ${currentRepo}, expected ${expectedRepo}`,
159
+ currentRepo,
160
+ expectedRepo,
161
+ };
162
+ }
163
+ }
164
+ const fetchResult = git(['fetch', 'origin'], cwd);
165
+ if (!fetchResult.ok) {
166
+ return { status: 'error', message: fetchResult.error || 'git fetch failed' };
167
+ }
168
+ const coResult = git(['checkout', branch], cwd);
169
+ if (!coResult.ok) {
170
+ // Branch might not exist locally yet — try checkout -b with tracking
171
+ const cobResult = git(['checkout', '-b', branch, `origin/${branch}`], cwd);
172
+ if (!cobResult.ok) {
173
+ return { status: 'error', message: cobResult.error || 'git checkout failed' };
174
+ }
175
+ }
176
+ return { status: 'ok', message: `Switched to ${branch}` };
177
+ }
178
+ /**
179
+ * Returns repo info for a specific directory (or cwd if not given).
180
+ */
181
+ export function getRepoInfoAt(cwd) {
182
+ const { ok, output } = gh(['repo', 'view', '--json', 'nameWithOwner', '-q', '.nameWithOwner'], cwd);
183
+ if (!ok)
184
+ return null;
185
+ const [owner, repo] = output.split('/');
186
+ if (owner && repo)
187
+ return { owner, repo };
188
+ return null;
189
+ }
190
+ /**
191
+ * Clones a repo and checks out a branch in the target directory.
192
+ */
193
+ export function cloneAndCheckout(repo, branch, targetDir) {
194
+ const cloneResult = gh(['repo', 'clone', repo, targetDir]);
195
+ if (!cloneResult.ok) {
196
+ return { status: 'error', message: cloneResult.error || 'Clone failed' };
197
+ }
198
+ return checkout(branch, undefined, targetDir);
199
+ }
200
+ /**
201
+ * Gathers context about the current working state:
202
+ * repo, branch, linked issue, PR, push state.
203
+ */
204
+ export function getWorkContext() {
205
+ const repoInfo = getRepoInfo();
206
+ const branch = getCurrentBranch();
207
+ if (!repoInfo || !branch)
208
+ return null;
209
+ const repo = `${repoInfo.owner}/${repoInfo.repo}`;
210
+ // Find PR for current branch
211
+ let pr = null;
212
+ const prResult = gh([
213
+ 'pr',
214
+ 'view',
215
+ '--json',
216
+ 'number,title,url',
217
+ '-q',
218
+ '{number: .number, title: .title, url: .url}',
219
+ ]);
220
+ if (prResult.ok && prResult.output) {
221
+ try {
222
+ const parsed = JSON.parse(prResult.output);
223
+ if (parsed.number)
224
+ pr = parsed;
225
+ }
226
+ catch {
227
+ /* no PR */
228
+ }
229
+ }
230
+ // Find linked issue — extract from branch name (issue-123-...)
231
+ let issue = null;
232
+ const issueMatch = branch.match(/^issue-(\d+)/);
233
+ if (issueMatch) {
234
+ const issueNum = issueMatch[1];
235
+ const issueResult = gh([
236
+ 'api',
237
+ `repos/${repo}/issues/${issueNum}`,
238
+ '--jq',
239
+ '{number: .number, title: .title}',
240
+ ]);
241
+ if (issueResult.ok && issueResult.output) {
242
+ try {
243
+ issue = JSON.parse(issueResult.output);
244
+ }
245
+ catch {
246
+ /* ignore */
247
+ }
248
+ }
249
+ }
250
+ // Push state
251
+ const upstream = git(['rev-parse', '--abbrev-ref', '@{u}']).ok;
252
+ let pushState = 'nothing';
253
+ if (!upstream) {
254
+ pushState = 'first_push';
255
+ }
256
+ else {
257
+ const log = git(['log', '--oneline', '@{u}..HEAD']);
258
+ if (log.ok && log.output.length > 0)
259
+ pushState = 'has_commits';
260
+ }
261
+ return { repo, branch, issue, pr, pushState };
262
+ }
263
+ export function getLastCommitMessage() {
264
+ const proc = spawnSync('git', ['log', '-1', '--format=%s'], { encoding: 'utf-8' });
265
+ if (proc.status !== 0)
266
+ return null;
267
+ return (proc.stdout ?? '').trim() || null;
268
+ }
@@ -0,0 +1,60 @@
1
+ import { hasUpstream, hasUnpushedCommits, pushBranch, prExists, getPrUrl, createDraftPR, getLastCommitMessage, } from "./gh.js";
2
+ /**
3
+ * Generates a PR title suggestion from the branch name.
4
+ * e.g. "fix/login-bug" → "fix: login-bug"
5
+ * "feat/add-auth" → "feat: add-auth"
6
+ * "my-feature" → "my-feature"
7
+ */
8
+ export function suggestTitle(branch) {
9
+ const match = branch.match(/^(fix|feat|chore|docs|refactor|test|style|perf|ci|build)[/-](.+)/);
10
+ if (match)
11
+ return `${match[1]}: ${match[2]}`;
12
+ return branch;
13
+ }
14
+ /**
15
+ * Smart push: decides what to do based on the current git state.
16
+ *
17
+ * - No upstream → push + optionally create draft PR
18
+ * - Upstream + unpushed commits → push + optionally create draft PR
19
+ * - Upstream + no changes + no PR → optionally create draft PR
20
+ * - Everything done → nothing
21
+ */
22
+ export function smartPush(opts) {
23
+ const upstream = hasUpstream();
24
+ const unpushed = upstream && hasUnpushedCommits();
25
+ // Push if needed
26
+ if (!upstream || unpushed) {
27
+ const push = pushBranch();
28
+ if (!push.ok) {
29
+ return { status: 'error', message: push.error || 'Push failed' };
30
+ }
31
+ }
32
+ // Create PR if title given and no PR exists yet
33
+ if (opts?.title && !prExists()) {
34
+ const body = opts.issueNumber ? `Closes #${opts.issueNumber}` : (getLastCommitMessage() ?? '');
35
+ const pr = createDraftPR(opts.title, body);
36
+ if (!pr.ok) {
37
+ return { status: 'error', message: pr.error || 'PR creation failed' };
38
+ }
39
+ if (!upstream || unpushed) {
40
+ return { status: 'pushed_with_pr', pr: pr.output };
41
+ }
42
+ return { status: 'created_pr', pr: pr.output };
43
+ }
44
+ if (!upstream || unpushed) {
45
+ return { status: 'pushed' };
46
+ }
47
+ return { status: 'nothing' };
48
+ }
49
+ /**
50
+ * Checks what action smartPush would take without executing it.
51
+ */
52
+ export function checkPushState() {
53
+ const hasPr_ = prExists();
54
+ const prUrl = hasPr_ ? getPrUrl() : null;
55
+ if (!hasUpstream())
56
+ return { pushState: 'first_push', hasPr: false, prUrl: null };
57
+ if (hasUnpushedCommits())
58
+ return { pushState: 'has_commits', hasPr: hasPr_, prUrl };
59
+ return { pushState: 'nothing', hasPr: hasPr_, prUrl };
60
+ }
@@ -0,0 +1,13 @@
1
+ import crypto from 'node:crypto';
2
+ let _token = null;
3
+ export function checkToken(token) {
4
+ return token === _token;
5
+ }
6
+ export function clearToken() {
7
+ _token = null;
8
+ }
9
+ export function generateToken() {
10
+ const token = crypto.randomBytes(16).toString('hex');
11
+ _token = token;
12
+ return token;
13
+ }
package/dist/index.js ADDED
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env node
2
+ import * as p from '@clack/prompts';
3
+ import qrcode from 'qrcode-terminal';
4
+ import { default as clipboard } from 'clipboardy';
5
+ import { execSync } from 'node:child_process';
6
+ import { isGhAuthenticated, isGhInstalled } from "./core/gh.js";
7
+ import { startServer } from "./server.js";
8
+ import { generateToken } from "./core/token.js";
9
+ async function main() {
10
+ p.intro('flumli');
11
+ // Step 1: gh check
12
+ if (!isGhInstalled()) {
13
+ const install = await p.confirm({
14
+ message: 'gh (GitHub CLI) is not installed. Install now?',
15
+ });
16
+ if (install) {
17
+ const s = p.spinner();
18
+ s.start('Installing gh...');
19
+ try {
20
+ execSync('sudo apt install gh -y', { stdio: 'ignore' });
21
+ s.stop('gh installed.');
22
+ }
23
+ catch {
24
+ s.stop('Installation failed.');
25
+ p.log.error('Please install gh manually: https://cli.github.com');
26
+ process.exit(1);
27
+ }
28
+ }
29
+ else {
30
+ p.log.warn('flumli requires gh to function.');
31
+ process.exit(1);
32
+ }
33
+ }
34
+ // Step 2: gh auth check
35
+ if (!isGhAuthenticated()) {
36
+ p.log.info('You are not logged in to GitHub.');
37
+ const login = await p.confirm({
38
+ message: 'Log in with gh auth login now?',
39
+ });
40
+ if (login) {
41
+ try {
42
+ execSync('gh auth login', { stdio: 'inherit' });
43
+ }
44
+ catch {
45
+ p.log.error('Login failed.');
46
+ process.exit(1);
47
+ }
48
+ }
49
+ else {
50
+ p.log.warn('flumli requires a GitHub login.');
51
+ process.exit(1);
52
+ }
53
+ }
54
+ p.log.success('gh is ready.');
55
+ // Token + QR
56
+ const token = generateToken();
57
+ qrcode.generate('https://flumen.dev', { small: true });
58
+ p.note(token, 'Your token');
59
+ try {
60
+ clipboard.writeSync(token);
61
+ p.log.success('Token copied to clipboard.');
62
+ }
63
+ catch {
64
+ p.log.warn('Could not copy to clipboard.');
65
+ }
66
+ startServer();
67
+ }
68
+ main();
package/dist/server.js ADDED
@@ -0,0 +1,179 @@
1
+ import * as p from '@clack/prompts';
2
+ import { H3, handleCors, readBody, serve } from 'h3';
3
+ import { checkToken } from "./core/token.js";
4
+ import { checkPushState, smartPush } from "./core/push.js";
5
+ import { checkout, cloneAndCheckout, getRepoInfoAt, getWorkContext } from "./core/gh.js";
6
+ import { resolve } from 'node:path';
7
+ import { existsSync } from 'node:fs';
8
+ let connected = false;
9
+ const app = new H3()
10
+ .use((event) => {
11
+ const corsRes = handleCors(event, {
12
+ origin: '*',
13
+ methods: '*',
14
+ preflight: { statusCode: 204 },
15
+ });
16
+ if (corsRes !== false)
17
+ return corsRes;
18
+ })
19
+ .get('/state', () => {
20
+ return { connected };
21
+ })
22
+ .post('/connect', async (event) => {
23
+ const body = await readBody(event);
24
+ if (!body?.token || !checkToken(body.token)) {
25
+ p.log.warn('Connection attempt with invalid token.');
26
+ event.res.status = 401;
27
+ return { success: false, error: 'Invalid token' };
28
+ }
29
+ connected = true;
30
+ p.log.success('Flumen connected.');
31
+ showWorkContext();
32
+ return { success: true };
33
+ })
34
+ .post('/disconnect', async () => {
35
+ connected = false;
36
+ p.log.info('Flumen disconnected.');
37
+ return { success: true };
38
+ })
39
+ .get('/push', () => {
40
+ return checkPushState();
41
+ })
42
+ .post('/push', async (event) => {
43
+ const body = await readBody(event);
44
+ const s = p.spinner();
45
+ s.start(body?.title ? `Pushing & creating PR: ${body.title}` : 'Pushing...');
46
+ const result = smartPush(body?.title ? { title: body.title, issueNumber: body.issueNumber } : undefined);
47
+ if (result.status === 'error') {
48
+ s.stop(`Push failed: ${result.message}`);
49
+ p.log.error(result.message);
50
+ event.res.status = 500;
51
+ }
52
+ else if (result.status === 'pushed_with_pr') {
53
+ s.stop('Pushed & PR created.');
54
+ p.log.success(`PR: ${result.pr}`);
55
+ }
56
+ else if (result.status === 'created_pr') {
57
+ s.stop('PR created.');
58
+ p.log.success(`PR: ${result.pr}`);
59
+ }
60
+ else if (result.status === 'pushed') {
61
+ s.stop('Pushed.');
62
+ p.log.success('Commits pushed to remote.');
63
+ }
64
+ else {
65
+ s.stop('Nothing to push.');
66
+ p.log.info('Everything up to date.');
67
+ }
68
+ showNextSteps();
69
+ return result;
70
+ })
71
+ .post('/checkout', async (event) => {
72
+ const body = await readBody(event);
73
+ if (!body?.branch) {
74
+ p.log.error('Checkout requested without branch name.');
75
+ event.res.status = 400;
76
+ return { status: 'error', message: 'Missing branch name' };
77
+ }
78
+ p.log.step(`Checkout requested: ${body.branch}`);
79
+ if (body.repo) {
80
+ p.log.info(`Repository: ${body.repo}`);
81
+ }
82
+ const s = p.spinner();
83
+ s.start('Fetching & checking out...');
84
+ const result = checkout(body.branch, body.repo);
85
+ if (result.status === 'wrong_repo') {
86
+ s.stop('Wrong repository.');
87
+ p.log.warn(result.message);
88
+ // Respond immediately — handle interactively after
89
+ handleWrongRepo(body.branch, result.expectedRepo);
90
+ return { status: 'ok', message: 'Handling in CLI — check your terminal' };
91
+ }
92
+ if (result.status === 'error') {
93
+ s.stop('Checkout failed.');
94
+ p.log.error(result.message);
95
+ showNextSteps();
96
+ return result;
97
+ }
98
+ s.stop(`On branch ${body.branch}`);
99
+ p.log.success(result.message);
100
+ showWorkContext();
101
+ showNextSteps();
102
+ return result;
103
+ });
104
+ async function handleWrongRepo(branch, repo) {
105
+ const repoName = repo.split('/')[1] ?? repo;
106
+ const pathInput = await p.text({
107
+ message: `Where is ${repo}? Enter path to the repo, or a parent directory to clone into:`,
108
+ placeholder: `~/projects/${repoName}`,
109
+ });
110
+ if (p.isCancel(pathInput) || !pathInput.trim()) {
111
+ p.log.info('Skipped.');
112
+ showNextSteps();
113
+ return;
114
+ }
115
+ let targetDir = resolve(pathInput.trim().replace(/^~/, process.env.HOME ?? '~'));
116
+ if (!existsSync(targetDir)) {
117
+ p.log.error(`Path does not exist: ${targetDir}`);
118
+ showNextSteps();
119
+ return;
120
+ }
121
+ // Check if path points to the right repo
122
+ const info = getRepoInfoAt(targetDir);
123
+ if (info && `${info.owner}/${info.repo}`.toLowerCase() === repo.toLowerCase()) {
124
+ // Right repo — just checkout
125
+ const s = p.spinner();
126
+ s.start('Fetching & checking out...');
127
+ const result = checkout(branch, undefined, targetDir);
128
+ if (result.status === 'error') {
129
+ s.stop('Checkout failed.');
130
+ p.log.error(result.message);
131
+ showNextSteps();
132
+ return;
133
+ }
134
+ s.stop(`On branch ${branch}`);
135
+ }
136
+ else {
137
+ // Not the repo — clone into this directory
138
+ targetDir = resolve(targetDir, repoName);
139
+ const s = p.spinner();
140
+ s.start(`Cloning ${repo} into ${targetDir}...`);
141
+ const result = cloneAndCheckout(repo, branch, targetDir);
142
+ if (result.status === 'error') {
143
+ s.stop('Clone failed.');
144
+ p.log.error(result.message);
145
+ showNextSteps();
146
+ return;
147
+ }
148
+ s.stop('Cloned & checked out.');
149
+ }
150
+ p.log.success(`Ready at ${targetDir}`);
151
+ p.note(`cd ${targetDir}\n\nThen start flumli there to continue.`, 'Next steps');
152
+ }
153
+ function showWorkContext() {
154
+ const ctx = getWorkContext();
155
+ if (!ctx) {
156
+ p.note('Not inside a git repository.', 'Status');
157
+ return;
158
+ }
159
+ const lines = [`Repository: ${ctx.repo}`, `Branch: ${ctx.branch}`];
160
+ if (ctx.issue) {
161
+ lines.push(`Issue: #${ctx.issue.number} ${ctx.issue.title}`);
162
+ }
163
+ if (ctx.pr) {
164
+ lines.push(`PR: #${ctx.pr.number} ${ctx.pr.title}`);
165
+ }
166
+ if (ctx.pushState === 'first_push') {
167
+ lines.push('', 'Branch not yet pushed to remote.');
168
+ }
169
+ else if (ctx.pushState === 'has_commits') {
170
+ lines.push('', 'You have unpushed commits.');
171
+ }
172
+ p.note(lines.join('\n'), 'Status');
173
+ }
174
+ function showNextSteps() {
175
+ p.note('Waiting for commands from Flumen...\n\nCtrl+C to quit', 'Ready');
176
+ }
177
+ export function startServer(port = 31420) {
178
+ return serve(app, { port, silent: true });
179
+ }
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "flumli",
3
+ "version": "0.0.1-alpha.1",
4
+ "description": "",
5
+ "keywords": [],
6
+ "license": "ISC",
7
+ "author": "",
8
+ "bin": {
9
+ "flumli": "dist/index.js"
10
+ },
11
+ "files": [
12
+ "dist"
13
+ ],
14
+ "type": "module",
15
+ "main": "dist/index.js",
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "dependencies": {
20
+ "@clack/prompts": "^1.0.1",
21
+ "citty": "^0.2.1",
22
+ "clipboardy": "^5.3.0",
23
+ "h3": "2.0.1-rc.14",
24
+ "qrcode-terminal": "^0.12.0"
25
+ },
26
+ "devDependencies": {
27
+ "@types/node": "^24.10.13",
28
+ "@types/qrcode-terminal": "^0.12.2"
29
+ },
30
+ "scripts": {
31
+ "dev": "node --watch src/index.ts",
32
+ "build": "pnpm exec tsc -p tsconfig.build.json",
33
+ "test": "echo \"Error: no test specified\" && exit 1"
34
+ }
35
+ }