@peterseibel/hug 0.1.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.
@@ -0,0 +1,20 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "WebFetch(domain:registry.npmjs.org)",
5
+ "WebFetch(domain:script.googleapis.com)",
6
+ "WebFetch(domain:oauth2.googleapis.com)",
7
+ "Bash(./node_modules/.bin/clasp clone:*)",
8
+ "WebFetch(domain:developers.google.com)",
9
+ "WebFetch(domain:github.com)",
10
+ "WebSearch",
11
+ "WebFetch(domain:www.npmjs.com)",
12
+ "Bash(./node_modules/.bin/clasp --version && ./node_modules/.bin/clasp deploy --help 2>&1; ./node_modules/.bin/clasp redeploy --help 2>&1)",
13
+ "Bash(./node_modules/.bin/clasp list-versions:*)",
14
+ "Bash(./node_modules/.bin/clasp create-version:*)",
15
+ "Bash(./node_modules/.bin/clasp list-deployments:*)",
16
+ "WebFetch(domain:raw.githubusercontent.com)",
17
+ "Bash(gh api:*)"
18
+ ]
19
+ }
20
+ }
package/CLAUDE.md ADDED
@@ -0,0 +1,72 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## What this is
6
+
7
+ `hug` is a lightweight bash CLI that wraps [clasp](https://github.com/google/clasp) to provide opinionated project management for Google Apps Script projects. It handles project creation (from templates or by importing existing projects), forking projects for branch-based workflows, and a push→version→deploy workflow.
8
+
9
+ ## Project structure
10
+
11
+ ```
12
+ bin/hug # CLI entry point (bash, subcommand dispatch)
13
+ lib/common.sh # Shared functions (clasp resolution, deployment helpers)
14
+ templates/blank/ # Minimal Apps Script project template
15
+ templates/webapp/ # Webapp template (doGet + index.html)
16
+ package.json # npm package with bin field pointing to bin/hug
17
+ ```
18
+
19
+ ## Key commands
20
+
21
+ ```bash
22
+ ./bin/hug init [--template blank|webapp] [name] # create new project from template
23
+ ./bin/hug init --scriptId <id> [name] # import existing project
24
+ ./bin/hug fork # new Apps Script project from current code
25
+ ./bin/hug config set KEY=VALUE # manage config.js
26
+ ./bin/hug deploy "description" # push + version + deploy
27
+ ./bin/hug deploy --rollback <version> # roll back
28
+ ./bin/hug push / pull [-f] / open # clasp passthrough (pull checks git status)
29
+ ./bin/hug versions / deployments # list versions/deployments
30
+ ```
31
+
32
+ ## Design decisions
33
+
34
+ ### Deployments
35
+
36
+ Apps Script deployments are mainly useful for web apps (stable URL) and API
37
+ executables. Hug takes an opinionated approach: one deployment per project,
38
+ managed via `hug deploy`. If multiple deployments exist (e.g. created directly
39
+ via clasp), hug handles them gracefully — `select_deployment` in `common.sh`
40
+ prompts the user to pick one. But hug doesn't provide commands to create or
41
+ delete individual deployments.
42
+
43
+ Multiple deployments on a single script share script properties, so they all
44
+ operate on the same underlying data (e.g. the same spreadsheet). This makes
45
+ them suitable for variant UIs against the same data, but not for dev/prod
46
+ separation. For dev/prod, use `hug fork` + git branches instead — each branch
47
+ gets its own script project with independent properties and deployments.
48
+
49
+ ### Config
50
+
51
+ `hug config` manages a local `config.js` file (JS constants object) that gets
52
+ pushed with the code. This avoids the heavy GCP setup required by `clasp run`
53
+ for setting script properties. Config values are in source/git, which is fine
54
+ for spreadsheet IDs etc. but not for secrets. Each git branch can have different
55
+ config values, pairing well with `hug fork`.
56
+
57
+ ### Auth
58
+
59
+ Hug does not wrap `clasp login`. Auth credentials are stored globally in
60
+ `~/.clasprc.json` and persist across projects. Instead, all clasp invocations
61
+ go through `run_clasp` which detects auth errors and suggests running
62
+ `npx clasp login`.
63
+
64
+ ## Development
65
+
66
+ There is no build step or test suite. The CLI is pure bash. To test locally:
67
+
68
+ ```bash
69
+ ./bin/hug --help
70
+ ```
71
+
72
+ clasp is a dependency (`@google/clasp`). The CLI finds it at `./node_modules/.bin/clasp` first, then falls back to a global `clasp`.
package/DEVELOPMENT.md ADDED
@@ -0,0 +1,86 @@
1
+ # Development
2
+
3
+ ## Local testing
4
+
5
+ There's no build step or test suite. To test locally, run commands directly:
6
+
7
+ ```bash
8
+ ./bin/hug --help
9
+ ./bin/hug init --template webapp /tmp/test-project
10
+ ```
11
+
12
+ To test as if installed globally, link the package:
13
+
14
+ ```bash
15
+ npm link
16
+ hug --help
17
+ ```
18
+
19
+ To unlink:
20
+
21
+ ```bash
22
+ npm unlink -g @peterseibel/hug
23
+ ```
24
+
25
+ ## Project structure
26
+
27
+ - `bin/hug` — CLI entry point (bash). All subcommands live here.
28
+ - `lib/common.sh` — Shared functions sourced by `bin/hug`.
29
+ - `templates/` — Project templates copied by `hug init`.
30
+ - `plans/` — Implementation plans. `plans/done/` holds completed plans.
31
+
32
+ ## Publishing to npm
33
+
34
+ The package is scoped as `@peterseibel/hug`.
35
+
36
+ ### First time
37
+
38
+ Make sure you're logged in to npm and that your scope is configured:
39
+
40
+ ```bash
41
+ npm login
42
+ ```
43
+
44
+ Scoped packages are private by default. To publish as public:
45
+
46
+ ```bash
47
+ npm publish --access public
48
+ ```
49
+
50
+ ### Subsequent releases
51
+
52
+ 1. Bump the version in `package.json` (or use `npm version`):
53
+
54
+ ```bash
55
+ npm version patch # 0.1.0 -> 0.1.1
56
+ npm version minor # 0.1.1 -> 0.2.0
57
+ npm version major # 0.2.0 -> 1.0.0
58
+ ```
59
+
60
+ `npm version` creates a git commit and tag automatically.
61
+
62
+ 2. Publish:
63
+
64
+ ```bash
65
+ npm publish
66
+ ```
67
+
68
+ 3. Push the version commit and tag:
69
+
70
+ ```bash
71
+ git push && git push --tags
72
+ ```
73
+
74
+ ## Dependencies
75
+
76
+ The only runtime dependency is `@google/clasp`. It's listed in `package.json`
77
+ so it gets installed when users `npm install -g @peterseibel/hug`.
78
+
79
+ In user projects created by `hug init`, clasp is installed as a dev dependency
80
+ via `ensure_clasp` in `lib/common.sh`.
81
+
82
+ ## Auth
83
+
84
+ Clasp credentials live in `~/.clasprc.json` (global, not per-project). To test
85
+ commands that talk to the Apps Script API, you need to have run `clasp login`
86
+ at least once.
package/README.md ADDED
@@ -0,0 +1,111 @@
1
+ # hug
2
+
3
+ A lightweight wrapper around [clasp](https://github.com/google/clasp) for managing Google Apps Script projects.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install -g @peterseibel/hug
9
+ ```
10
+
11
+ Or clone this repo and link it:
12
+
13
+ ```bash
14
+ git clone <repo-url> && cd hug
15
+ npm install && npm link
16
+ ```
17
+
18
+ ## Prerequisites
19
+
20
+ You need to be logged in to clasp:
21
+
22
+ ```bash
23
+ npx clasp login
24
+ ```
25
+
26
+ ## Commands
27
+
28
+ ### Create a new project
29
+
30
+ ```bash
31
+ hug init my-app # blank project
32
+ hug init --template webapp my-app # webapp with doGet + index.html
33
+ ```
34
+
35
+ This creates the directory, copies template files, installs clasp, and creates the Apps Script project.
36
+
37
+ ### Import an existing project
38
+
39
+ ```bash
40
+ hug init --scriptId <scriptId> my-project
41
+ ```
42
+
43
+ Imports an existing Apps Script project into a new directory and sets up npm/clasp.
44
+
45
+ ### Fork a project
46
+
47
+ ```bash
48
+ hug fork
49
+ ```
50
+
51
+ Creates a new Apps Script project from the current local code. Useful with git branches — fork on a branch to get a separate Apps Script project you can develop against independently.
52
+
53
+ ### Configure
54
+
55
+ ```bash
56
+ hug config # list config values
57
+ hug config set SPREADSHEET_ID=1Bx.. # set a value
58
+ hug config set FOO=bar BAZ=qux # set multiple values
59
+ hug config unset FOO # remove a value
60
+ ```
61
+
62
+ Manages a `config.js` file that gets pushed with your code. Apps Script code can access values via `CONFIG.SPREADSHEET_ID`, etc. Useful for pointing different branches/forks at different resources.
63
+
64
+ Note: config values are stored in source. Don't put secrets here.
65
+
66
+ ### Push / Pull / Open
67
+
68
+ ```bash
69
+ hug push # push local files to Apps Script
70
+ hug pull # pull remote files (refuses if uncommitted changes)
71
+ hug pull -f # pull even with uncommitted changes
72
+ hug open # open in the Apps Script editor
73
+ ```
74
+
75
+ ### Deploy
76
+
77
+ ```bash
78
+ hug deploy "description of changes"
79
+ ```
80
+
81
+ Pushes code, creates a version, and updates the existing deployment (or creates one if none exists).
82
+
83
+ ### Roll back
84
+
85
+ ```bash
86
+ hug deploy --rollback <versionNumber>
87
+ ```
88
+
89
+ ### List versions and deployments
90
+
91
+ ```bash
92
+ hug versions
93
+ hug deployments
94
+ ```
95
+
96
+ ## Templates
97
+
98
+ - **blank** — minimal `appsscript.json` + empty `Code.js`
99
+ - **webapp** — `doGet()` serving an `index.html`, with webapp config in the manifest
100
+
101
+ ## Branch-per-environment pattern
102
+
103
+ Use `hug fork` with git branches to maintain separate Apps Script projects:
104
+
105
+ ```bash
106
+ git checkout -b staging
107
+ hug fork # new Apps Script project, updates .clasp.json
108
+ hug config set SPREADSHEET_ID=1Bx.. # point at a staging spreadsheet
109
+ hug deploy # deploys to the staging project
110
+ git checkout main # .clasp.json and config.js switch back to production
111
+ ```
package/bin/hug ADDED
@@ -0,0 +1,458 @@
1
+ #!/usr/bin/env bash
2
+
3
+ set -euo pipefail
4
+
5
+ HUG_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
6
+ source "$HUG_ROOT/lib/common.sh"
7
+
8
+ VERSION="0.1.0"
9
+
10
+ usage() {
11
+ cat <<EOF
12
+ hug $VERSION — a lightweight wrapper around clasp
13
+
14
+ Usage: hug <command> [options]
15
+
16
+ Project commands:
17
+ init [--template blank|webapp] [name] Create a new project from a template
18
+ init --scriptId <id> [name] Import an existing Apps Script project
19
+ fork Create a new Apps Script project from current code
20
+
21
+ Development commands:
22
+ push Push local files to Apps Script
23
+ pull [-f|--force] Pull remote files (refuses if uncommitted changes)
24
+ open Open the project in the Apps Script editor
25
+
26
+ Configuration:
27
+ config List config values
28
+ config set KEY=VALUE ... Set config values (writes config.js)
29
+ config unset KEY ... Remove config values
30
+
31
+ Deployment commands:
32
+ deploy [description] Push, version, and update deployment
33
+ deploy --rollback <version> Roll back to a previous version
34
+ versions List versions
35
+ deployments List deployments
36
+
37
+ Run 'hug <command> --help' for details on a specific command.
38
+ EOF
39
+ }
40
+
41
+ # ─── init ────────────────────────────────────────────────────────────────────
42
+
43
+ cmd_init() {
44
+ local template="" name="" script_id="" force=false
45
+
46
+ while [ $# -gt 0 ]; do
47
+ case "$1" in
48
+ --template) template="$2"; shift 2 ;;
49
+ --scriptId) script_id="$2"; shift 2 ;;
50
+ -f|--force) force=true; shift ;;
51
+ --help)
52
+ echo "Usage: hug init [--template blank|webapp] [-f|--force] [name]"
53
+ echo " hug init --scriptId <id> [-f|--force] [name]"
54
+ echo ""
55
+ echo "Creates a new Apps Script project. If name is given, creates a directory."
56
+ echo "Refuses if the directory already exists (use -f to override)."
57
+ echo ""
58
+ echo "Options:"
59
+ echo " --template Start from a template: blank (default), webapp"
60
+ echo " --scriptId Import an existing Apps Script project by ID"
61
+ echo " -f, --force Use an existing directory even if it already exists"
62
+ return 0 ;;
63
+ *) name="$1"; shift ;;
64
+ esac
65
+ done
66
+
67
+ # Validate flags
68
+ if [ -n "$script_id" ] && [ -n "$template" ]; then
69
+ echo "Error: --scriptId and --template are mutually exclusive" >&2
70
+ return 1
71
+ fi
72
+
73
+ local project_dir="."
74
+ if [ -n "$name" ]; then
75
+ project_dir="$name"
76
+ if [ -d "$project_dir" ] && [ "$force" = false ]; then
77
+ echo "Error: directory '$project_dir' already exists. Use -f to override." >&2
78
+ return 1
79
+ fi
80
+ mkdir -p "$project_dir"
81
+ elif [ -f .clasp.json ] && [ "$force" = false ]; then
82
+ echo "Error: .clasp.json already exists in this directory. Use -f to override." >&2
83
+ return 1
84
+ fi
85
+
86
+ cd "$project_dir"
87
+ ensure_clasp
88
+ local clasp
89
+ clasp=$(find_clasp)
90
+
91
+ if [ -n "$script_id" ]; then
92
+ # Import mode: clone an existing project
93
+ echo "Importing project $script_id..."
94
+ run_clasp "$clasp" clone "$script_id"
95
+
96
+ echo ""
97
+ echo "Project imported. Next steps:"
98
+ echo " hug pull Fetch the latest code"
99
+ echo " hug open Open in the Apps Script editor"
100
+ else
101
+ # Template mode: create a new project
102
+ template="${template:-blank}"
103
+
104
+ local template_dir="$HUG_ROOT/templates/$template"
105
+ if [ ! -d "$template_dir" ]; then
106
+ echo "Error: unknown template '$template'. Available: blank, webapp" >&2
107
+ return 1
108
+ fi
109
+
110
+ echo "Copying $template template..."
111
+ cp -r "$template_dir"/* ./
112
+
113
+ local title="${name:-$(basename "$(pwd)")}"
114
+ local type="standalone"
115
+ if [ "$template" = "webapp" ]; then
116
+ type="webapp"
117
+ fi
118
+
119
+ echo "Creating Apps Script project '$title' (type: $type)..."
120
+ run_clasp "$clasp" create --type "$type" --title "$title"
121
+
122
+ echo ""
123
+ echo "Project ready. Next steps:"
124
+ echo " hug push Push code to Apps Script"
125
+ echo " hug open Open in the Apps Script editor"
126
+ if [ "$template" = "webapp" ]; then
127
+ echo " hug deploy Push, version, and create a deployment"
128
+ fi
129
+ fi
130
+ }
131
+
132
+ # ─── fork ─────────────────────────────────────────────────────────────────────
133
+
134
+ cmd_fork() {
135
+ local force=false
136
+ while [ $# -gt 0 ]; do
137
+ case "$1" in
138
+ -f|--force) force=true; shift ;;
139
+ --help)
140
+ echo "Usage: hug fork [-f|--force]"
141
+ echo ""
142
+ echo "Creates a new Apps Script project from the current local code."
143
+ echo "Replaces .clasp.json with a new project, then pushes code to it."
144
+ echo ""
145
+ echo "Refuses if the current project is container-bound (use -f to override)."
146
+ echo ""
147
+ echo "Useful with git branches: fork on a branch to get a separate"
148
+ echo "Apps Script project for each branch."
149
+ return 0 ;;
150
+ *) shift ;;
151
+ esac
152
+ done
153
+
154
+ if [ ! -f .clasp.json ]; then
155
+ echo "Error: no .clasp.json found. Run 'hug init' first." >&2
156
+ return 1
157
+ fi
158
+
159
+ if [ "$force" = false ] && grep -q '"parentId"' .clasp.json 2>/dev/null; then
160
+ local parent_id
161
+ parent_id=$(grep '"parentId"' .clasp.json | sed 's/.*"parentId"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/')
162
+ echo "Error: this is a container-bound script. Forking will create a standalone" >&2
163
+ echo "project, but the code may depend on its container (e.g. by calling." >&2
164
+ echo "getActiveSpreadsheet())." >&2
165
+ echo "" >&2
166
+ echo "Container: https://drive.google.com/file/d/$parent_id" >&2
167
+ echo "" >&2
168
+ echo "Use 'hug fork --force' to fork anyway." >&2
169
+ return 1
170
+ fi
171
+
172
+ local clasp
173
+ clasp=$(find_clasp)
174
+
175
+ local title
176
+ title="$(basename "$(pwd)")"
177
+
178
+ echo "Creating new Apps Script project '$title'..."
179
+ rm -f .clasp.json
180
+ run_clasp "$clasp" create --type standalone --title "$title"
181
+
182
+ echo "Pushing code to new project..."
183
+ run_clasp "$clasp" push
184
+
185
+ echo ""
186
+ echo "Fork complete. This directory now points to a new Apps Script project."
187
+ }
188
+
189
+ # ─── deploy ──────────────────────────────────────────────────────────────────
190
+
191
+ cmd_deploy() {
192
+ if [ $# -gt 0 ] && [ "$1" = "--help" ]; then
193
+ echo "Usage: hug deploy [description]"
194
+ echo " hug deploy --rollback <version>"
195
+ echo ""
196
+ echo "Pushes code, creates a version, and updates the deployment."
197
+ echo "If no deployment exists, creates one."
198
+ return 0
199
+ fi
200
+
201
+ local clasp
202
+ clasp=$(find_clasp)
203
+
204
+ # Rollback mode
205
+ if [ "${1:-}" = "--rollback" ]; then
206
+ if [ $# -ne 2 ]; then
207
+ echo "Usage: hug deploy --rollback <version>" >&2
208
+ return 1
209
+ fi
210
+ local version="$2"
211
+ local line
212
+ line=$(select_deployment "$clasp")
213
+ if [ -z "$line" ]; then
214
+ echo "Error: no non-HEAD deployment found to roll back" >&2
215
+ return 1
216
+ fi
217
+ local id desc
218
+ id=$(deployment_id "$line")
219
+ desc=$(deployment_desc "$line")
220
+ echo "Rolling back to version $version (deployment $id)..."
221
+ update_deployment "$clasp" "$id" "$version" "$desc"
222
+ echo "Done."
223
+ return 0
224
+ fi
225
+
226
+ local description="${*:-}"
227
+
228
+ echo "Pushing..."
229
+ run_clasp "$clasp" push
230
+
231
+ echo "Creating version..."
232
+ local version_output
233
+ version_output=$(run_clasp "$clasp" create-version "$description")
234
+ echo "$version_output"
235
+ local version
236
+ version=$(echo "$version_output" | grep -oE 'version [0-9]+' | grep -oE '[0-9]+')
237
+
238
+ if [ -z "$version" ]; then
239
+ echo "Error: could not parse version number from: $version_output" >&2
240
+ return 1
241
+ fi
242
+
243
+ local line
244
+ line=$(select_deployment "$clasp") || true
245
+
246
+ if [ -z "$line" ]; then
247
+ echo "No existing deployment found. Creating one..."
248
+ run_clasp "$clasp" create-deployment -V "$version" -d "$description"
249
+ else
250
+ local id desc
251
+ id=$(deployment_id "$line")
252
+ desc=$(deployment_desc "$line")
253
+ echo "Updating deployment $id to version $version..."
254
+ update_deployment "$clasp" "$id" "$version" "$desc"
255
+ fi
256
+
257
+ echo "Done."
258
+ }
259
+
260
+ # ─── config ──────────────────────────────────────────────────────────────────
261
+
262
+ # Read config.js and output associative key-value lines: KEY=VALUE
263
+ _read_config() {
264
+ if [ ! -f config.js ]; then
265
+ return 1
266
+ fi
267
+ grep -E "^ [A-Za-z_][A-Za-z0-9_]*:" config.js | sed "s/^ \([A-Za-z_][A-Za-z0-9_]*\): *['\"]\\(.*\\)['\"],\$/\\1=\\2/"
268
+ }
269
+
270
+ # Write config.js from KEY=VALUE lines on stdin
271
+ _write_config() {
272
+ local entries=""
273
+ while IFS='=' read -r key value; do
274
+ entries="${entries} ${key}: '${value}',
275
+ "
276
+ done
277
+
278
+ if [ -z "$entries" ]; then
279
+ rm -f config.js
280
+ return
281
+ fi
282
+
283
+ cat > config.js <<EOF
284
+ const CONFIG = {
285
+ ${entries}};
286
+ EOF
287
+ }
288
+
289
+ cmd_config() {
290
+ if [ "${1:-}" = "--help" ]; then
291
+ echo "Usage: hug config"
292
+ echo " hug config set KEY=VALUE ..."
293
+ echo " hug config unset KEY ..."
294
+ echo ""
295
+ echo "Manages config.js, a JS constants file pushed with your code."
296
+ echo "Apps Script code can read values via CONFIG.KEY."
297
+ echo ""
298
+ echo "Note: config values are stored in source. Don't put secrets here."
299
+ return 0
300
+ fi
301
+
302
+ local subcmd="${1:-}"
303
+
304
+ case "$subcmd" in
305
+ "")
306
+ # List config
307
+ if [ ! -f config.js ]; then
308
+ echo "No config.js found. Use 'hug config set KEY=VALUE' to create one."
309
+ return 0
310
+ fi
311
+ _read_config | while IFS='=' read -r key value; do
312
+ echo "$key=$value"
313
+ done
314
+ ;;
315
+
316
+ set)
317
+ shift
318
+ if [ $# -eq 0 ]; then
319
+ echo "Usage: hug config set KEY=VALUE ..." >&2
320
+ return 1
321
+ fi
322
+
323
+ # Load existing config
324
+ local existing=""
325
+ if [ -f config.js ]; then
326
+ existing=$(_read_config)
327
+ fi
328
+
329
+ # Parse new values and merge
330
+ local new_pairs=""
331
+ for arg in "$@"; do
332
+ if ! echo "$arg" | grep -qE '^[A-Za-z_][A-Za-z0-9_]*='; then
333
+ echo "Error: invalid format '$arg'. Use KEY=VALUE." >&2
334
+ return 1
335
+ fi
336
+ local key="${arg%%=*}"
337
+ local value="${arg#*=}"
338
+ # Remove existing entry for this key if present
339
+ existing=$(echo "$existing" | grep -v "^${key}=" || true)
340
+ new_pairs="${new_pairs}${key}=${value}
341
+ "
342
+ done
343
+
344
+ # Combine and write
345
+ { printf '%s\n%s' "$existing" "$new_pairs" | grep -v '^$' || true; } | _write_config
346
+ echo "Updated config.js."
347
+ ;;
348
+
349
+ unset)
350
+ shift
351
+ if [ $# -eq 0 ]; then
352
+ echo "Usage: hug config unset KEY ..." >&2
353
+ return 1
354
+ fi
355
+
356
+ if [ ! -f config.js ]; then
357
+ echo "No config.js found." >&2
358
+ return 1
359
+ fi
360
+
361
+ local remaining
362
+ remaining=$(_read_config)
363
+ for key in "$@"; do
364
+ remaining=$(echo "$remaining" | grep -v "^${key}=" || true)
365
+ done
366
+
367
+ { echo "$remaining" | grep -v '^$' || true; } | _write_config
368
+
369
+ if [ ! -f config.js ]; then
370
+ echo "Removed config.js (no keys remaining)."
371
+ else
372
+ echo "Updated config.js."
373
+ fi
374
+ ;;
375
+
376
+ *)
377
+ echo "Error: unknown config subcommand '$subcmd'" >&2
378
+ echo "Run 'hug config --help' for usage." >&2
379
+ return 1
380
+ ;;
381
+ esac
382
+ }
383
+
384
+ # ─── passthrough commands ────────────────────────────────────────────────────
385
+
386
+ cmd_push() {
387
+ local clasp; clasp=$(find_clasp)
388
+ run_clasp "$clasp" push "$@"
389
+ }
390
+
391
+ cmd_pull() {
392
+ local force=false
393
+ while [ $# -gt 0 ]; do
394
+ case "$1" in
395
+ -f|--force) force=true; shift ;;
396
+ --help)
397
+ echo "Usage: hug pull [-f|--force]"
398
+ echo ""
399
+ echo "Pulls remote files from Apps Script, overwriting local files."
400
+ echo "Refuses to run if there are uncommitted changes (use -f to override)."
401
+ return 0 ;;
402
+ *) break ;;
403
+ esac
404
+ done
405
+
406
+ if [ "$force" = false ] && ! git diff --quiet 2>/dev/null; then
407
+ echo "Error: you have uncommitted changes. Commit or stash them first," >&2
408
+ echo "or use 'hug pull --force' to overwrite." >&2
409
+ return 1
410
+ fi
411
+
412
+ local clasp; clasp=$(find_clasp)
413
+ run_clasp "$clasp" pull "$@"
414
+ }
415
+
416
+ cmd_open() {
417
+ local clasp; clasp=$(find_clasp)
418
+ run_clasp "$clasp" open "$@"
419
+ }
420
+
421
+ cmd_versions() {
422
+ local clasp; clasp=$(find_clasp)
423
+ run_clasp "$clasp" list-versions "$@"
424
+ }
425
+
426
+ cmd_deployments() {
427
+ local clasp; clasp=$(find_clasp)
428
+ run_clasp "$clasp" list-deployments "$@"
429
+ }
430
+
431
+ # ─── dispatch ────────────────────────────────────────────────────────────────
432
+
433
+ if [ $# -eq 0 ]; then
434
+ usage
435
+ exit 1
436
+ fi
437
+
438
+ command="$1"
439
+ shift
440
+
441
+ case "$command" in
442
+ init) cmd_init "$@" ;;
443
+ fork) cmd_fork "$@" ;;
444
+ config) cmd_config "$@" ;;
445
+ deploy) cmd_deploy "$@" ;;
446
+ push) cmd_push "$@" ;;
447
+ pull) cmd_pull "$@" ;;
448
+ open) cmd_open "$@" ;;
449
+ versions) cmd_versions "$@" ;;
450
+ deployments) cmd_deployments "$@" ;;
451
+ --version|-v) echo "hug $VERSION" ;;
452
+ --help|-h) usage ;;
453
+ *)
454
+ echo "Error: unknown command '$command'" >&2
455
+ echo "Run 'hug --help' for usage." >&2
456
+ exit 1
457
+ ;;
458
+ esac
package/lib/common.sh ADDED
@@ -0,0 +1,108 @@
1
+ # common.sh — shared functions for hug CLI
2
+
3
+ # Ensure package.json exists and clasp is installed locally
4
+ ensure_clasp() {
5
+ if [ ! -f package.json ]; then
6
+ echo "Initializing npm project..."
7
+ npm init -y --quiet >/dev/null
8
+ fi
9
+ if [ ! -d node_modules/.bin ] || [ ! -x node_modules/.bin/clasp ]; then
10
+ echo "Installing clasp..."
11
+ npm install --save-dev @google/clasp --quiet >/dev/null
12
+ fi
13
+ }
14
+
15
+ # Resolve the path to clasp, preferring local install
16
+ find_clasp() {
17
+ if [ -x "./node_modules/.bin/clasp" ]; then
18
+ echo "./node_modules/.bin/clasp"
19
+ elif command -v clasp &>/dev/null; then
20
+ echo "clasp"
21
+ else
22
+ echo "Error: clasp not found. Run 'npm install @google/clasp' first." >&2
23
+ return 1
24
+ fi
25
+ }
26
+
27
+ # Run clasp and check for auth errors on failure
28
+ run_clasp() {
29
+ local clasp="$1"
30
+ shift
31
+
32
+ local output
33
+ output=$("$clasp" "$@" 2>&1) && { echo "$output"; return 0; }
34
+ local exit_code=$?
35
+
36
+ echo "$output" >&2
37
+
38
+ if echo "$output" | grep -qiE "authorize|unauthorized|unauthenticated|login|credential|ENOENT.*clasprc|401"; then
39
+ echo "" >&2
40
+ echo "Hint: you may need to log in. Run 'npx clasp login' to authenticate." >&2
41
+ fi
42
+
43
+ return $exit_code
44
+ }
45
+
46
+ # Resolve the hug package root (where templates/ lives)
47
+ hug_root() {
48
+ local source="${BASH_SOURCE[0]}"
49
+ while [ -L "$source" ]; do
50
+ local dir
51
+ dir="$(cd -P "$(dirname "$source")" && pwd)"
52
+ source="$(readlink "$source")"
53
+ [[ "$source" != /* ]] && source="$dir/$source"
54
+ done
55
+ cd -P "$(dirname "$source")/.." && pwd
56
+ }
57
+
58
+ # Get all non-HEAD deployment lines, select one interactively if multiple
59
+ select_deployment() {
60
+ local clasp="$1"
61
+
62
+ local lines
63
+ lines=$(run_clasp "$clasp" list-deployments | grep '^-' | grep -v '@HEAD')
64
+
65
+ if [ -z "$lines" ]; then
66
+ echo ""
67
+ return
68
+ fi
69
+
70
+ local count
71
+ count=$(echo "$lines" | wc -l | tr -d ' ')
72
+
73
+ if [ "$count" -eq 1 ]; then
74
+ echo "$lines"
75
+ else
76
+ echo "Multiple deployments found. Choose one:" >&2
77
+ echo "$lines" | nl -w1 -s') ' >&2
78
+ printf "Choice: " >&2
79
+ read -r choice
80
+ local selected
81
+ selected=$(echo "$lines" | sed -n "${choice}p")
82
+ if [ -z "$selected" ]; then
83
+ echo "Error: invalid choice" >&2
84
+ return 1
85
+ fi
86
+ echo "$selected"
87
+ fi
88
+ }
89
+
90
+ # Extract deployment ID from a deployment line
91
+ deployment_id() {
92
+ echo "$1" | awk '{print $2}'
93
+ }
94
+
95
+ # Extract description from a deployment line (may be empty)
96
+ deployment_desc() {
97
+ echo "$1" | sed -n 's/^- [^ ]* @[^ ]* - \(.*\)/\1/p'
98
+ }
99
+
100
+ # Update a deployment, preserving its description if it has one
101
+ update_deployment() {
102
+ local clasp="$1" id="$2" version="$3" desc="$4"
103
+ if [ -n "$desc" ]; then
104
+ run_clasp "$clasp" update-deployment "$id" -V "$version" -d "$desc"
105
+ else
106
+ run_clasp "$clasp" update-deployment "$id" -V "$version"
107
+ fi
108
+ }
package/package.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "@peterseibel/hug",
3
+ "version": "0.1.0",
4
+ "description": "A lightweight wrapper around clasp for managing Google Apps Script projects",
5
+ "bin": {
6
+ "hug": "bin/hug"
7
+ },
8
+ "keywords": ["google-apps-script", "clasp", "gas"],
9
+ "license": "ISC",
10
+ "dependencies": {
11
+ "@google/clasp": "^3.2.0"
12
+ }
13
+ }
@@ -0,0 +1,132 @@
1
+ # Plan: Transform this repo into `hug` — a general-purpose clasp wrapper
2
+
3
+ ## Context
4
+
5
+ This repo currently contains a specific Apps Script app (BHS LC lottery form). We're turning it into `hug`, a lightweight CLI tool that wraps `clasp` to provide opinionated project management for Google Apps Script projects. The app-specific code will be removed; the deployment workflow in the `hug` script becomes the foundation.
6
+
7
+ ## Phase 1: Clean up — remove app-specific files
8
+
9
+ **Delete:**
10
+ - `Code.js` — app business logic
11
+ - `index.html` — app UI
12
+ - `appsscript.json` — app-specific manifest
13
+ - `dev.clasp.json`, `prod.clasp.json` — app-specific project configs
14
+ - `.clasp.json` — app-specific (will be recreated per-project by hug)
15
+ - `plans/multi-project-deployment.md` — app-specific plan (concepts will be incorporated into hug's design)
16
+
17
+ **Keep:**
18
+ - `hug` script (will be rewritten)
19
+ - `package.json` (will be rewritten)
20
+ - `CLAUDE.md`, `README.md` (will be rewritten)
21
+ - `.gitignore`
22
+ - `.claude/` directory
23
+
24
+ ## Phase 2: Project structure
25
+
26
+ ```
27
+ hug/
28
+ ├── bin/
29
+ │ └── hug # CLI entry point (bash)
30
+ ├── templates/
31
+ │ ├── blank/
32
+ │ │ ├── appsscript.json
33
+ │ │ └── Code.js # empty/minimal
34
+ │ └── webapp/
35
+ │ ├── appsscript.json # with webapp config
36
+ │ ├── Code.js # doGet() stub
37
+ │ └── index.html # minimal HTML page
38
+ ├── lib/
39
+ │ └── common.sh # shared bash functions (clasp path resolution, error handling, deployment helpers)
40
+ ├── package.json # bin field points to bin/hug, clasp as dependency
41
+ ├── .gitignore
42
+ ├── CLAUDE.md
43
+ └── README.md
44
+ ```
45
+
46
+ ## Phase 3: CLI commands
47
+
48
+ The `hug` script becomes a subcommand-based CLI. All commands assume clasp is available (bundled as a dependency).
49
+
50
+ ### `hug init [--template blank|webapp] [name]`
51
+ - Creates a new Apps Script project directory (or uses current dir)
52
+ - Copies template files into it
53
+ - Runs `npm init -y` and `npm install @google/clasp`
54
+ - Runs `clasp create --type standalone --title <name>` (or `--type webapp` for webapp template)
55
+ - Result: a ready-to-push project
56
+
57
+ ### `hug import`
58
+ - For adopting an existing Apps Script project
59
+ - Runs `clasp clone <scriptId>` into current directory
60
+ - Sets up the standard project structure (package.json with clasp dep, .gitignore)
61
+
62
+ ### `hug clone <scriptId> [directory]`
63
+ - Creates a new Apps Script project, then pushes existing code to it
64
+ - Basically: `clasp create` + copy files + `clasp push`
65
+ - Useful for forking an existing project into a new script
66
+
67
+ ### `hug deploy [description]`
68
+ - The current push→version→update-deployment workflow (what `hug` does now)
69
+ - If no deployment exists yet, creates one
70
+ - Preserves existing deployment descriptions
71
+
72
+ ### `hug deploy --rollback <version>`
73
+ - Roll back a deployment to a previous version (existing functionality)
74
+
75
+ ### `hug push`
76
+ - Thin wrapper: just runs `clasp push`
77
+ - Convenience so you don't need to remember the clasp path
78
+
79
+ ### `hug pull`
80
+ - Thin wrapper: just runs `clasp pull`
81
+
82
+ ### `hug open`
83
+ - Thin wrapper: runs `clasp open`
84
+
85
+ ### `hug versions`
86
+ - Lists versions (`clasp list-versions`)
87
+
88
+ ### `hug deployments`
89
+ - Lists deployments (`clasp list-deployments`)
90
+
91
+ ## Phase 4: Rewrite `bin/hug`
92
+
93
+ - Move current `hug` script to `bin/hug`
94
+ - Refactor into subcommand dispatch (case statement)
95
+ - Extract shared logic (clasp path resolution, deployment selection) into `lib/common.sh`
96
+ - Add `--help` for each subcommand
97
+ - The existing deploy logic stays largely intact, just reorganized
98
+
99
+ ## Phase 5: Templates
100
+
101
+ ### `templates/blank/`
102
+ - `appsscript.json`: minimal manifest (V8 runtime, America/Los_Angeles timezone)
103
+ - `Code.js`: empty file with a comment
104
+
105
+ ### `templates/webapp/`
106
+ - `appsscript.json`: manifest with `webapp` section (executeAs USER_DEPLOYING, access ANYONE or DOMAIN)
107
+ - `Code.js`: `doGet()` that serves `index.html`
108
+ - `index.html`: minimal HTML5 boilerplate
109
+
110
+ ## Phase 6: Update package.json
111
+
112
+ ```json
113
+ {
114
+ "name": "hug-clasp",
115
+ "version": "0.1.0",
116
+ "description": "A lightweight wrapper around clasp for managing Google Apps Script projects",
117
+ "bin": { "hug": "bin/hug" },
118
+ "dependencies": { "@google/clasp": "^3.2.0" }
119
+ }
120
+ ```
121
+
122
+ ## Phase 7: Rewrite docs
123
+
124
+ - `README.md`: document all commands with examples, installation instructions
125
+ - `CLAUDE.md`: update to reflect new project structure and purpose
126
+
127
+ ## Verification
128
+
129
+ 1. Run `./bin/hug --help` — should show available commands
130
+ 2. Run `./bin/hug init --template webapp test-project` in a temp dir — should scaffold a project
131
+ 3. Run `./bin/hug deploy "test"` in an existing clasp project — should do push→version→deploy
132
+ 4. Run `./bin/hug versions` and `./bin/hug deployments` — should proxy to clasp
@@ -0,0 +1,83 @@
1
+ # Plan: Add `hug config` for managing project configuration
2
+
3
+ ## Context
4
+
5
+ After forking a project with `hug fork`, you need to configure it to point at
6
+ different resources (e.g. a different spreadsheet). Using `clasp run` with
7
+ script properties would be the "right" approach but requires heavy GCP setup
8
+ (link project, enable API, create OAuth client, re-login with creds).
9
+
10
+ Instead, use a simple local `config.js` file that gets pushed with the code.
11
+ `hug config` manages this file, and Apps Script code reads from the `CONFIG`
12
+ object directly. Each git branch can have different config values, which pairs
13
+ well with `hug fork`.
14
+
15
+ Config values end up in source/git, which is fine for things like spreadsheet
16
+ IDs but means sensitive values shouldn't go here.
17
+
18
+ ## Files to modify
19
+
20
+ - `bin/hug` — add `cmd_config` and dispatch entry
21
+ - `README.md` — document the config command
22
+ - `CLAUDE.md` — update command list and design decisions
23
+
24
+ Templates are not modified — `config.js` is created on demand by `hug config set`.
25
+
26
+ ## Changes
27
+
28
+ ### 1. Add `cmd_config` to `bin/hug`
29
+
30
+ ```
31
+ hug config # list all config values
32
+ hug config set KEY=VALUE ... # set one or more values
33
+ hug config unset KEY ... # remove one or more values
34
+ ```
35
+
36
+ Implementation:
37
+
38
+ **`hug config`** (no args): read `config.js`, parse and display key-value pairs.
39
+ If no `config.js` exists, say "No config.js found."
40
+
41
+ **`hug config set K=V ...`**: parse KEY=VALUE args. If `config.js` exists, read
42
+ it and merge new values. If not, create it. Write the file as:
43
+
44
+ ```javascript
45
+ const CONFIG = {
46
+ SPREADSHEET_ID: '1BxiM...',
47
+ SHEET_NAME: 'Data',
48
+ };
49
+ ```
50
+
51
+ **`hug config unset K ...`**: read `config.js`, remove specified keys, rewrite.
52
+ If no keys remain, delete the file.
53
+
54
+ The parser for reading `config.js` can be simple — grep for lines matching
55
+ `KEY: 'VALUE'` or `KEY: "VALUE"` patterns. No need for a full JS parser since
56
+ hug controls the file format.
57
+
58
+ ### 2. Add dispatch entry
59
+
60
+ Add `config` to the case statement and usage block:
61
+
62
+ ```
63
+ Configuration:
64
+ config List config values
65
+ config set KEY=VALUE ... Set config values (writes config.js)
66
+ config unset KEY ... Remove config values
67
+ ```
68
+
69
+ ### 3. Update docs
70
+
71
+ - README: add config section with example workflow (init, config set, push)
72
+ - CLAUDE.md: add config to command list and design decisions
73
+
74
+ ## Verification
75
+
76
+ - `hug config --help` shows usage
77
+ - `hug config` with no config.js prints "No config.js found."
78
+ - `hug config set FOO=bar BAZ=qux` creates config.js with both values
79
+ - `hug config` lists FOO and BAZ
80
+ - `hug config set FOO=updated` changes FOO, keeps BAZ
81
+ - `hug config unset FOO` removes FOO, keeps BAZ
82
+ - `hug config unset BAZ` removes last key and deletes config.js
83
+ - config.js is valid JavaScript that can be pushed to Apps Script
@@ -0,0 +1,78 @@
1
+ # Multi-Project Deployment Plan
2
+
3
+ ## Goal
4
+
5
+ Run the same codebase against different spreadsheets — e.g. a dev/staging instance and a production instance — without hardcoding any spreadsheet IDs in the code.
6
+
7
+ ## Background: Container-Bound vs. Standalone Scripts
8
+
9
+ The current script is **container-bound**: it was created via Extensions > Apps Script from within a spreadsheet, so `SpreadsheetApp.getActiveSpreadsheet()` works automatically. This binding is stored in the spreadsheet, not the script, and there's no official way to convert a container-bound project to standalone.
10
+
11
+ To share code across multiple independent spreadsheets, the script needs to be **standalone**, using `SpreadsheetApp.openById(id)` instead of `getActiveSpreadsheet()`.
12
+
13
+ ## Approach: Script Properties for Per-Project Config
14
+
15
+ Script Properties (`PropertiesService.getScriptProperties()`) are stored per script project, not per deployment. This means each standalone script project gets its own independent property store — perfect for storing the spreadsheet ID.
16
+
17
+ ### Code changes
18
+
19
+ Replace:
20
+ ```javascript
21
+ SpreadsheetApp.getActiveSpreadsheet()
22
+ ```
23
+ With:
24
+ ```javascript
25
+ const id = PropertiesService.getScriptProperties().getProperty('SPREADSHEET_ID');
26
+ SpreadsheetApp.openById(id);
27
+ ```
28
+
29
+ Expose a `configure` function to set properties via `clasp run`:
30
+ ```javascript
31
+ const configure = (config) => {
32
+ const props = PropertiesService.getScriptProperties();
33
+ Object.entries(config).forEach(([key, value]) => props.setProperty(key, value));
34
+ };
35
+ ```
36
+
37
+ ## Workflow for Setting Up a New Project Instance
38
+
39
+ 1. **Create a new standalone script project:**
40
+ ```bash
41
+ clasp create --type standalone --title "Lottery Form - Dev"
42
+ ```
43
+ This updates `.clasp.json` with the new `scriptId`.
44
+
45
+ 2. **Push the code:**
46
+ ```bash
47
+ clasp push
48
+ ```
49
+
50
+ 3. **Configure the project** by running the `configure` function with a JSON config file:
51
+ ```bash
52
+ clasp run configure --params "$(< config.dev.json)"
53
+ ```
54
+ Where `config.dev.json` looks like:
55
+ ```json
56
+ [{"SPREADSHEET_ID": "1BxiM..."}]
57
+ ```
58
+ Note: `--params` always takes a JSON array; each element is one argument to the function.
59
+
60
+ 4. **Create a deployment:**
61
+ ```bash
62
+ clasp create-deployment -d "dev"
63
+ ```
64
+
65
+ To switch a project to a different spreadsheet later, just re-run the `clasp run configure` step — no code changes or new version needed.
66
+
67
+ ## Managing Multiple Projects Locally
68
+
69
+ Since `.clasp.json` only holds one `scriptId` at a time, keep separate config files for each project and swap them as needed:
70
+
71
+ ```
72
+ .clasp.json.prod
73
+ .clasp.json.dev
74
+ config.prod.json
75
+ config.dev.json
76
+ ```
77
+
78
+ Or use separate git branches, one per project instance.
@@ -0,0 +1,2 @@
1
+ function myFunction() {
2
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "timeZone": "America/Los_Angeles",
3
+ "exceptionLogging": "STACKDRIVER",
4
+ "runtimeVersion": "V8"
5
+ }
@@ -0,0 +1,4 @@
1
+ function doGet() {
2
+ return HtmlService.createHtmlOutputFromFile('index')
3
+ .setTitle('My App');
4
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "timeZone": "America/Los_Angeles",
3
+ "exceptionLogging": "STACKDRIVER",
4
+ "runtimeVersion": "V8",
5
+ "webapp": {
6
+ "executeAs": "USER_DEPLOYING",
7
+ "access": "ANYONE"
8
+ }
9
+ }
@@ -0,0 +1,14 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <base target="_top">
5
+ <meta charset="utf-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1">
7
+ <style>
8
+ body { font-family: system-ui, sans-serif; max-width: 600px; margin: 2rem auto; padding: 0 1rem; }
9
+ </style>
10
+ </head>
11
+ <body>
12
+ <h1>Hello, world!</h1>
13
+ </body>
14
+ </html>