oneclickpush 0.1.0-next.1 → 0.2.0

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.
Files changed (5) hide show
  1. package/README.md +139 -0
  2. package/cli.js +79 -1
  3. package/index.d.ts +67 -0
  4. package/index.js +111 -1
  5. package/package.json +12 -7
package/README.md CHANGED
@@ -1,3 +1,142 @@
1
1
  # oneclickpush
2
2
 
3
3
  > One-click GitHub Pages deployer
4
+
5
+ Replace the contents of a remote git branch (like `gh-pages`) with the contents of a local directory
6
+ in one command.
7
+
8
+ [`gh-pages`](https://github.com/tschaub/gh-pages) is
9
+ [complicated](https://github.com/tschaub/gh-pages/tree/main/lib),
10
+ [buggy](https://github.com/tschaub/gh-pages/issues), and
11
+ [unmaintained](https://github.com/tschaub/gh-pages/issues/657#issue-4979248775); `oneclickpush` is
12
+ none of these, has zero dependencies and only uses built-in Node APIs + `git`.
13
+
14
+ ## Quick Start
15
+
16
+ Deploy your build directory (e.g. `dist`) to the `gh-pages` branch:
17
+
18
+ ```text
19
+ $ cd dist
20
+ $ npx oneclickpush
21
+ ```
22
+
23
+ ## CLI Usage
24
+
25
+ ```text
26
+ Usage: oneclickpush [options]
27
+
28
+ Replace the contents of a remote git branch with that of a local directory.
29
+
30
+ Options:
31
+ -d, --dist <dir> Directory to publish (default: ".")
32
+ -e, --exclude <glob> Paths to leave out, relative to dist (default: "**/.DS_Store")
33
+ -b, --branch <name> Target branch (default: "gh-pages")
34
+ -m, --message <msg> Commit message (default: "Updates")
35
+ -r, --remote <name|url> Remote name or repo URL to publish to (default: "origin")
36
+ -a, --author <author> Commit author, as "Name <email>"
37
+ -f, --force Force push new commit with no history
38
+ -n, --dry-run Show what would change, without committing or pushing
39
+ -v, --verbose Log git commands
40
+ -V, --version Print version
41
+ -h, --help Show this help
42
+ ```
43
+
44
+ > [!TIP]
45
+ > `--exclude` takes glob patterns (`*`, `**`, `?`) and can be repeated. Patterns are matched against
46
+ > the full path, so use `**/name` to match at any depth.
47
+
48
+ ### Examples
49
+
50
+ ```bash
51
+ # Preview what would be deployed without pushing
52
+ npx oneclickpush -d build --dry-run
53
+
54
+ # Custom branch and commit message
55
+ npx oneclickpush -d public -b release -m "deploy: v1.0.0"
56
+
57
+ # Exclude source maps and test files
58
+ npx oneclickpush -d dist -e "**/*.map" -e "**/tests/**"
59
+
60
+ # Deploy to a different remote or repository URL
61
+ npx oneclickpush -d dist -r upstream
62
+ npx oneclickpush -d dist -r git@github.com:user/repo.git
63
+
64
+ # Set commit author (useful in CI environments)
65
+ npx oneclickpush -d dist -a "Deploy Bot <bot@example.com>"
66
+
67
+ # Force push fresh history
68
+ npx oneclickpush -d dist --force
69
+ ```
70
+
71
+ ## Programmatic API
72
+
73
+ You can also use `oneclickpush` as an ES module:
74
+
75
+ ```bash
76
+ # Install the library
77
+ npm i oneclickpush
78
+ ```
79
+
80
+ ```javascript
81
+ import { publish } from 'oneclickpush'
82
+
83
+ const changes = await publish({
84
+ dist: 'dist',
85
+ exclude: ['**/.DS_Store', '**/*.map'],
86
+ branch: 'gh-pages',
87
+ message: 'Deploy updates',
88
+ remote: 'origin',
89
+ author: 'Deploy Bot <bot@example.com>',
90
+ force: false,
91
+ dryRun: false,
92
+ verbose: false
93
+ })
94
+
95
+ if (changes) {
96
+ console.log('Published changes:\n' + changes)
97
+ } else {
98
+ console.log('Nothing to publish')
99
+ }
100
+ ```
101
+
102
+ ### Options
103
+
104
+ | Option | Type | Default | Description |
105
+ | --------- | ---------- | ------------------ | --------------------------------------------------- |
106
+ | `dist` | `string` | `"."` | Directory to publish |
107
+ | `exclude` | `string[]` | `["**/.DS_Store"]` | Paths to exclude relative to `dist` |
108
+ | `branch` | `string` | `"gh-pages"` | Target git branch |
109
+ | `message` | `string` | `"Updates"` | Git commit message |
110
+ | `remote` | `string` | `"origin"` | Remote name or repository URL/path to publish to |
111
+ | `author` | `string` | — | Commit author and committer, as `"Name <email>"` |
112
+ | `force` | `boolean` | `false` | Force push fresh commit with no previous history |
113
+ | `dryRun` | `boolean` | `false` | Return staged changes without committing or pushing |
114
+ | `verbose` | `boolean` | `false` | Log executed git commands |
115
+
116
+ ## How It Works
117
+
118
+ 1. Resolves the target repository URL from git remote configuration or uses the provided remote
119
+ URL/path directly.
120
+ 2. Creates an isolated temporary directory and fetches the target branch (or initializes it if it
121
+ doesn't exist).
122
+ 3. Clears the target branch index and stages the contents of `dist` (minus `exclude` patterns)
123
+ directly via Git `work-tree`.
124
+ 4. Checks for differences (`git diff --cached --name-status`). If there are changes, commits and
125
+ pushes back to the remote repository.
126
+ 5. Cleans up the temporary directory.
127
+
128
+ > [!WARNING]
129
+ > Requires Node 18.3+ and Git 2.28+.
130
+
131
+ ### Notes
132
+
133
+ - Dotfiles (except `.DS_Store`) are included by default; use `-e` to exclude.
134
+ - Contents in target branch are emptied and replaced with the contents of `dist` (WYSIWYG).
135
+ - If `.nojekyll` or `CNAME` files are required, add them to `dist`; use a post-build step if needed.
136
+ - If the target branch does not exist, it will be created.
137
+ - When `--remote` is a repository URL or path, running from within an existing git repository is not
138
+ required.
139
+
140
+ ## License
141
+
142
+ ISC
package/cli.js CHANGED
@@ -1,3 +1,81 @@
1
1
  #!/usr/bin/env node
2
+ import { parseArgs } from 'node:util'
3
+ import { readFileSync } from 'node:fs'
4
+ import { publish } from './index.js'
2
5
 
3
- console.log('noop')
6
+ const HELP = `Usage: oneclickpush [options]
7
+
8
+ Replace the contents of a remote git branch with that of a
9
+ local directory.
10
+
11
+ Options:
12
+ -d, --dist <dir> Directory to publish (default: ".")
13
+ -e, --exclude <glob> Paths to leave out, relative to dist
14
+ (default: "**/.DS_Store")
15
+ -b, --branch <name> Target branch (default: "gh-pages")
16
+ -m, --message <msg> Commit message (default: "Updates")
17
+ -r, --remote <name|url> Remote name or repo URL to publish to
18
+ (default: "origin")
19
+ -a, --author <author> Commit author, as "Name <email>"
20
+ -f, --force Force push new commit with no history
21
+ -n, --dry-run Show what would change, without
22
+ committing or pushing
23
+ -v, --verbose Log git commands
24
+ -V, --version Print version
25
+ -h, --help Show this help
26
+
27
+ --exclude takes glob patterns (*, **, ?) and can be repeated.
28
+ Patterns are matched against the full path, so use **/name to
29
+ match at any depth.`
30
+
31
+ const { values: v } = parseArgs({
32
+ options: {
33
+ dist: { type: 'string', short: 'd', default: '.' },
34
+ exclude: { type: 'string', short: 'e', multiple: true, default: ['**/.DS_Store'] },
35
+ branch: { type: 'string', short: 'b', default: 'gh-pages' },
36
+ message: { type: 'string', short: 'm', default: 'Updates' },
37
+ remote: { type: 'string', short: 'r', default: 'origin' },
38
+ author: { type: 'string', short: 'a' },
39
+ force: { type: 'boolean', short: 'f', default: false },
40
+ 'dry-run': { type: 'boolean', short: 'n', default: false },
41
+ verbose: { type: 'boolean', short: 'v', default: false },
42
+ version: { type: 'boolean', short: 'V', default: false },
43
+ help: { type: 'boolean', short: 'h', default: false }
44
+ }
45
+ })
46
+
47
+ if (v.help) {
48
+ console.log(HELP)
49
+ process.exit(0)
50
+ }
51
+
52
+ if (v.version) {
53
+ const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8'))
54
+ console.log(pkg.version)
55
+ process.exit(0)
56
+ }
57
+
58
+ // Dead simple spinner (skipped when verbose or piped)
59
+ const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
60
+ let i = 0
61
+ const spin =
62
+ process.stderr.isTTY && !v.verbose
63
+ ? setInterval(() => process.stderr.write(`\r${frames[i++ % frames.length]}`), 100)
64
+ : null
65
+ const stop = () => spin && (clearInterval(spin), process.stderr.write('\r \r'))
66
+
67
+ try {
68
+ const changes = await publish({ ...v, dryRun: v['dry-run'] })
69
+ stop()
70
+ console.log(
71
+ !changes
72
+ ? 'Nothing to publish'
73
+ : v['dry-run']
74
+ ? `Dry run, would publish:\n${changes}`
75
+ : 'Published'
76
+ )
77
+ } catch (/** @type {any} */ e) {
78
+ stop()
79
+ console.error(v.verbose ? e : e.message)
80
+ process.exit(1)
81
+ }
package/index.d.ts ADDED
@@ -0,0 +1,67 @@
1
+ export type OneclickpushOptions = {
2
+ /**
3
+ * Directory to publish
4
+ */
5
+ dist?: string
6
+ /**
7
+ * Paths to leave out, relative to dist (default: `**\/.DS_Store`)
8
+ */
9
+ exclude?: string[]
10
+ /**
11
+ * Target branch
12
+ */
13
+ branch?: string
14
+ /**
15
+ * Commit message
16
+ */
17
+ message?: string
18
+ /**
19
+ * Remote name of the current repo, or a repository URL or path
20
+ */
21
+ remote?: string
22
+ /**
23
+ * Commit author and committer as "Name <email>"
24
+ */
25
+ author?: string
26
+ /**
27
+ * Force push new commit with no history
28
+ */
29
+ force?: boolean
30
+ /**
31
+ * Show what would change, without committing or pushing
32
+ */
33
+ dryRun?: boolean
34
+ /**
35
+ * Log git commands
36
+ */
37
+ verbose?: boolean
38
+ }
39
+ /**
40
+ * @typedef {object} OneclickpushOptions
41
+ * @prop {string} [dist="."] Directory to publish
42
+ * @prop {string[]} [exclude] Paths to leave out, relative to dist (default: `**\/.DS_Store`)
43
+ * @prop {string} [branch="gh-pages"] Target branch
44
+ * @prop {string} [message="Updates"] Commit message
45
+ * @prop {string} [remote="origin"] Remote name of the current repo, or a repository URL or path
46
+ * @prop {string} [author] Commit author and committer as "Name <email>"
47
+ * @prop {boolean} [force=false] Force push new commit with no history
48
+ * @prop {boolean} [dryRun=false] Show what would change, without committing or pushing
49
+ * @prop {boolean} [verbose=false] Log git commands
50
+ */
51
+ /**
52
+ * Replace the contents of a git branch with the contents of a directory.
53
+ * Returns the staged changes as `git diff --name-status` text or "" if nothing changed.
54
+ * @param {OneclickpushOptions} options
55
+ * @returns {Promise<string>}
56
+ */
57
+ export declare function publish({
58
+ dist,
59
+ exclude,
60
+ branch,
61
+ message,
62
+ remote,
63
+ author,
64
+ force,
65
+ dryRun,
66
+ verbose
67
+ }: OneclickpushOptions): Promise<string>
package/index.js CHANGED
@@ -1 +1,111 @@
1
- console.log('noop')
1
+ import { execFile } from 'node:child_process'
2
+ import { promisify } from 'node:util'
3
+ import { mkdtemp, rm } from 'node:fs/promises'
4
+ import { tmpdir } from 'node:os'
5
+ import { join, resolve } from 'node:path'
6
+
7
+ /**
8
+ * @typedef {object} OneclickpushOptions
9
+ * @prop {string} [dist="."] Directory to publish
10
+ * @prop {string[]} [exclude] Paths to leave out, relative to dist (default: `**\/.DS_Store`)
11
+ * @prop {string} [branch="gh-pages"] Target branch
12
+ * @prop {string} [message="Updates"] Commit message
13
+ * @prop {string} [remote="origin"] Remote name of the current repo, or a repository URL or path
14
+ * @prop {string} [author] Commit author and committer as "Name <email>"
15
+ * @prop {boolean} [force=false] Force push new commit with no history
16
+ * @prop {boolean} [dryRun=false] Show what would change, without committing or pushing
17
+ * @prop {boolean} [verbose=false] Log git commands
18
+ */
19
+
20
+ /**
21
+ * Replace the contents of a git branch with the contents of a directory.
22
+ * Returns the staged changes as `git diff --name-status` text or "" if nothing changed.
23
+ * @param {OneclickpushOptions} options
24
+ * @returns {Promise<string>}
25
+ */
26
+ export async function publish({
27
+ dist = '.',
28
+ exclude = ['**/.DS_Store'],
29
+ branch = 'gh-pages',
30
+ message = 'Updates',
31
+ remote = 'origin',
32
+ author,
33
+ force = false,
34
+ dryRun = false,
35
+ verbose = false
36
+ }) {
37
+ dist = resolve(dist)
38
+ const run = promisify(execFile)
39
+
40
+ // Env vars beat git config, so this also overrides any identity already set in CI
41
+ /** @type {Record<string, string>} */
42
+ let identity = {}
43
+ if (author) {
44
+ const m = /^(.+?)\s*<(.+)>$/.exec(author)
45
+ if (!m) throw new Error('--author must look like "Name <email>"')
46
+ identity = {
47
+ GIT_AUTHOR_NAME: m[1],
48
+ GIT_AUTHOR_EMAIL: m[2],
49
+ GIT_COMMITTER_NAME: m[1],
50
+ GIT_COMMITTER_EMAIL: m[2]
51
+ }
52
+ }
53
+
54
+ /** @type {(args: string[], cwd?: string, env?: Record<string, string>) => Promise<string>} */
55
+ const git = async (args, cwd, env) => {
56
+ if (verbose) console.error(`$ git ${args.join(' ')}`)
57
+ return (await run('git', args, { cwd, env: { ...process.env, ...env } })).stdout.trim()
58
+ }
59
+
60
+ // A remote name resolves to its url; anything else is used as a url or path as-is
61
+ let url = remote
62
+ try {
63
+ url = await git(['remote', 'get-url', remote])
64
+ } catch {
65
+ if (/^[\w-]+$/.test(remote)) throw new Error(`Remote "${remote}" not found`)
66
+ }
67
+
68
+ const tmp = await mkdtemp(join(tmpdir(), 'oneclickpush-'))
69
+
70
+ try {
71
+ // Reuse existing branch history unless forcing; otherwise start fresh
72
+ let cloned = false
73
+ if (!force) {
74
+ try {
75
+ await git(['clone', '--branch', branch, '--single-branch', '--depth', '1', url, tmp])
76
+ cloned = true
77
+ // eslint-disable-next-line no-empty
78
+ } catch {}
79
+ }
80
+ if (!cloned) {
81
+ await git(['init', '-q', '-b', branch], tmp)
82
+ await git(['remote', 'add', 'origin', url], tmp)
83
+ }
84
+
85
+ // Empty the index, then stage everything from dist except excluded paths
86
+ await git(['read-tree', '--empty'], tmp)
87
+ await git(
88
+ [
89
+ '--git-dir',
90
+ join(tmp, '.git'),
91
+ '--work-tree',
92
+ dist,
93
+ 'add',
94
+ '--force',
95
+ '--',
96
+ '.',
97
+ ...exclude.map((p) => `:(exclude,glob)${p}`)
98
+ ],
99
+ dist
100
+ )
101
+
102
+ const status = await git(['diff', '--cached', '--name-status'], tmp)
103
+ if (!status || dryRun) return status
104
+
105
+ await git(['commit', '-q', '-m', message], tmp, identity)
106
+ await git(['push', '-q', ...(force ? ['--force'] : []), 'origin', branch], tmp)
107
+ return status
108
+ } finally {
109
+ await rm(tmp, { recursive: true, force: true })
110
+ }
111
+ }
package/package.json CHANGED
@@ -1,28 +1,33 @@
1
1
  {
2
2
  "name": "oneclickpush",
3
- "version": "0.1.0-next.1",
3
+ "version": "0.2.0",
4
4
  "description": "One-click GitHub Pages deployer",
5
5
  "author": "Jason Lee <jason@zerodevx.com>",
6
6
  "scripts": {
7
- "lint": "prettier --check . && eslint && npx publint",
8
- "test": "echo \"Error: no test specified\" && exit 1"
9
- },
10
- "dependencies": {
11
- "zx": "^8.8.5"
7
+ "prepublishOnly": "npm run lint",
8
+ "emit": "tsc index.js -d --emitDeclarationOnly --allowJs && prettier --write index.d.ts",
9
+ "lint": "npm run emit && prettier --check . && eslint && npx publint",
10
+ "test": "node --test"
12
11
  },
13
12
  "devDependencies": {
14
13
  "@eslint/js": "^10.0.1",
15
14
  "eslint": "^10.12.0",
16
15
  "globals": "^17.13.0",
17
- "prettier": "^3.9.10"
16
+ "prettier": "^3.9.10",
17
+ "typescript": "^7.0.2"
18
18
  },
19
19
  "type": "module",
20
20
  "exports": "./index.js",
21
+ "types": "./index.d.ts",
21
22
  "bin": "cli.js",
22
23
  "files": [
23
24
  "index.js",
25
+ "index.d.ts",
24
26
  "cli.js"
25
27
  ],
28
+ "engines": {
29
+ "node": ">=18.3.0"
30
+ },
26
31
  "license": "ISC",
27
32
  "repository": "github:zerodevx/oneclickpush",
28
33
  "keywords": [