├── .eslintrc.js ├── .gitignore ├── LICENSE.txt ├── README.md ├── build-help.js ├── build-pipe-wrap.sh ├── build.sh ├── jsconfig.json ├── misc ├── bisect.sh └── pipe-wrap.js ├── notes ├── assets.txt ├── auth.txt ├── create.txt ├── debugger.txt ├── domain.txt ├── invite.txt ├── join.txt ├── meta-deps.txt ├── meta-minimize.txt ├── meta-names.dot ├── meta-names.svg ├── meta-release.txt ├── meta-tramp.txt ├── meta-wants.txt ├── ot.txt └── term.txt ├── package.json ├── pocs └── showargs.js ├── shrinkwrap.yaml ├── src └── index.js ├── vanity ├── common.css ├── dist ├── github.svg ├── glitch.svg ├── help-staging ├── help.css ├── index.html ├── join.svg ├── keytimer.html ├── npm.svg ├── practice │ ├── editor-invite.png │ ├── index.html │ └── site-didnt-respond.png ├── snail.svg └── test.html └── webpack.config.js /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | commonjs: true, 4 | es2021: true, 5 | node: true, 6 | }, 7 | extends: [ 8 | 'airbnb-base', 9 | ], 10 | parserOptions: { 11 | ecmaVersion: 12, 12 | }, 13 | rules: { 14 | 'default-case': 'off', 15 | 'global-require': 'off', 16 | 'guard-for-in': 'off', 17 | 'max-len': 'warn', 18 | 'no-await-in-loop': 'off', 19 | 'no-bitwise': 'off', 20 | 'no-console': 'off', 21 | 'no-constant-condition': ['warn', {checkLoops: false}], 22 | 'no-continue': 'off', 23 | 'no-else-return': 'off', 24 | 'no-lonely-if': 'off', 25 | 'no-mixed-operators': ['warn', {groups: [['<', '<=', '>', '>=', 'in', 'instanceof', '==', '!=', '===', '!==', '&', '^', '|']]}], 26 | 'no-param-reassign': ['warn', {props: false}], 27 | 'no-plusplus': 'off', 28 | 'no-restricted-syntax': ['error', 'LabeledStatement', 'WithStatement'], 29 | 'no-return-await': 'off', 30 | 'no-shadow': 'warn', 31 | 'no-unused-vars': ['warn', {args: 'none'}], 32 | 'object-curly-spacing': ['error', 'never'], 33 | 'one-var': 'off', 34 | 'one-var-declaration-per-line': 'off', 35 | 'padded-blocks': ['error', {blocks: 'never', classes: 'always', switches: 'never'}], 36 | 'prefer-destructuring': 'off', 37 | 'prefer-template': 'off', 38 | 'quote-props': ['error', 'consistent'], 39 | 'strict': 'off', 40 | 'vars-on-top': 'off', 41 | }, 42 | }; 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # For developing outside of Glitch, specify this manually. 2 | node_modules/ 3 | 4 | # Going with shrinkwrap.yaml for now. 5 | package-lock.json 6 | 7 | /.env 8 | /dist 9 | /help-staging 10 | /vanity/help 11 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 wh0 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Snail: CLI for Glitch 2 | 3 | [![npm](https://img.shields.io/npm/v/glitch-snail)](https://www.npmjs.com/package/glitch-snail) 4 | [![npm](https://img.shields.io/npm/dt/glitch-snail?label=downloads%20%28npm%29)](https://www.npmjs.com/package/glitch-snail) 5 | [![GitHub releases](https://img.shields.io/github/downloads/wh0/snail-cli/total?label=downloads%20%28GitHub%29)](https://github.com/wh0/snail-cli/releases) 6 | 7 | Snail is a command line tool for Glitch. 8 | See our [website](https://snail-cli.glitch.me) for instructions and stuff. 9 | -------------------------------------------------------------------------------- /build-help.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | 4 | const commander = require('commander'); 5 | 6 | const packageMeta = require('./package.json'); 7 | 8 | const /** @type {Map} */ highSubs = new Map(); 9 | const /** @type {string[]} */ highSubsOrder = []; 10 | const highSubCodeStart = 0xe000; 11 | let highSubCodeNext = highSubCodeStart; 12 | 13 | function highSub(/** @type {string} */ markup) { 14 | if (highSubs.has(markup)) return highSubs.get(markup); 15 | const code = highSubCodeNext++; 16 | if (code > 0xf8ff) throw new Error('too many highSub'); 17 | const sub = String.fromCharCode(code); 18 | highSubs.set(markup, sub); 19 | highSubsOrder.push(markup); 20 | return sub; 21 | } 22 | 23 | function resetHighSubs() { 24 | highSubs.clear(); 25 | highSubsOrder.splice(0, highSubsOrder.length); 26 | highSubCodeNext = highSubCodeStart; 27 | } 28 | 29 | function lowerHighSubs(/** @type {string} */ s) { 30 | function replace(/** @type {string} */ v, /** @type {string} */ x, /** @type {string} */ y) { 31 | return v.split(x).join(y); 32 | } 33 | let lowered = s; 34 | lowered = replace(lowered, '&', '&'); 35 | lowered = replace(lowered, '<', '<'); 36 | lowered = replace(lowered, '>', '>'); 37 | for (const markup of highSubsOrder) { 38 | lowered = replace(lowered, /** @type {string} */ (highSubs.get(markup)), markup); 39 | } 40 | return lowered; 41 | } 42 | 43 | function briefNameFromCommand(/** @type {commander.Command} */ cmd) { 44 | let name = cmd.name(); 45 | for (let parentCmd = cmd.parent; parentCmd; parentCmd = parentCmd.parent) { 46 | name = `${parentCmd.name()} ${name}`; 47 | } 48 | return name; 49 | } 50 | 51 | function filenameFromCommand(/** @type {commander.Command} */ cmd) { 52 | if (cmd === commander.program) return 'index.html'; 53 | let filename = `${cmd.name()}.html`; 54 | for ( 55 | let parentCmd = cmd.parent; 56 | parentCmd && parentCmd !== commander.program; 57 | parentCmd = parentCmd.parent 58 | ) { 59 | filename = `${parentCmd.name()}-${filename}`; 60 | } 61 | return filename; 62 | } 63 | 64 | function linkStartForCommand(/** @type {commander.Command} */ cmd) { 65 | // Slight hack: don't create link for implicit help comand. 66 | if (cmd.name() === 'help') return ''; 67 | return ``; 68 | } 69 | 70 | function linkEndForCommand(/** @type {commander.Command} */ cmd) { 71 | if (cmd.name() === 'help') return ''; 72 | return ''; 73 | } 74 | 75 | const stockHelp = new commander.Help(); 76 | 77 | commander.program.configureHelp({ 78 | // If we've done everything right, the helpWidth shouldn't have any effect, 79 | // but set it to a fixed value just in case. 80 | helpWidth: 80, 81 | subcommandTerm: (cmd) => `${highSub(`${linkStartForCommand(cmd)}`)}${stockHelp.subcommandTerm(cmd)}${highSub(`${linkEndForCommand(cmd)}`)}`, 82 | optionTerm: (option) => `${highSub('')}${stockHelp.optionTerm(option)}${highSub('')}`, 83 | // No overrides for the various length calculators. The non-displaying 84 | // high sub characters mess up the wrapping width, but our wrap 85 | // implementation ignores that. 86 | commandUsage: (cmd) => `${highSub('')}${stockHelp.commandUsage(cmd)}${highSub('')}`, 87 | commandDescription: (cmd) => `${highSub('')}${stockHelp.commandDescription(cmd)}${highSub('')}`, 88 | subcommandDescription: (cmd) => `${highSub('')}${stockHelp.subcommandDescription(cmd)}${highSub('')}`, 89 | optionDescription: (option) => `${highSub('')}${stockHelp.optionDescription(option)}${highSub('')}`, 90 | wrap: (str, width, indent, minColumnWidth = 40) => `${highSub('')}${highSub('')}${str.slice(0, indent)}${highSub('')}${highSub('')}${str.slice(indent)}${highSub('')}${highSub('')}`, 91 | }); 92 | 93 | // Haaaaaaaaaaaaaaaaaaaaaaaaaax. 94 | commander.program.parse = () => commander.program; 95 | require('./src/index'); 96 | 97 | function visitCommand(/** @type {commander.Command} */ cmd) { 98 | const briefName = briefNameFromCommand(cmd); 99 | const filename = filenameFromCommand(cmd); 100 | const webPath = cmd === commander.program ? '' : filename; 101 | const dstPath = path.join('help-staging', filename); 102 | console.error(`${briefName} -> ${dstPath}`); 103 | 104 | let content = ''; 105 | cmd.outputHelp({ 106 | // @ts-expect-error `write` is missing from type declaration 107 | write: (chunk) => { 108 | content += chunk; 109 | }, 110 | }); 111 | const loweredContent = lowerHighSubs(content); 112 | resetHighSubs(); 113 | 114 | const page = ` 115 | 116 | 117 | 118 | ${briefName} — Snail 119 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 |
134 |
135 | $ 136 | 🐌snail 137 | # 138 |

Documentation

139 |
140 |
141 | 142 |
143 |
144 | ${loweredContent}
145 |
146 | 147 |
148 |

149 | Generated from Snail ${packageMeta.version}. 150 |

151 |
152 | 153 | 154 | `; 155 | fs.writeFile(dstPath, page, (err) => { 156 | if (err) console.error(err); 157 | }); 158 | 159 | for (const sub of cmd.commands) { 160 | visitCommand(sub); 161 | } 162 | } 163 | 164 | visitCommand(commander.program); 165 | -------------------------------------------------------------------------------- /build-pipe-wrap.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh -eu 2 | terser -c evaluate=false -m --module misc/pipe-wrap.js 3 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh -eux 2 | mkdir -p dist 3 | webpack --json >dist/stats.json 4 | { 5 | head -n1 src/index.js 6 | cat dist/main.js 7 | } >dist/snail.js 8 | chmod +x dist/snail.js 9 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "*.js", 4 | "misc/*.js", 5 | "pocs/*.js" 6 | ], 7 | "compilerOptions": { 8 | "checkJs": true, 9 | "strict": true, 10 | "paths": { 11 | "events": ["./node_modules/@types/node/events"] 12 | }, 13 | "resolveJsonModule": true, 14 | "skipLibCheck": true 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /misc/bisect.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh -eu 2 | 3 | # Find the largest file size that can be served from cdn.glitch.global. 4 | 5 | # one higher than highest known ok size 6 | low=0 7 | # one lower than lowest known failing size 8 | high=24000000 9 | while true; do 10 | if [ "$low" -gt "$high" ]; then 11 | # when the cross over, we know the exact boundary, with 12 | # high = highest ok size; and 13 | # low = lowest failing size 14 | break 15 | fi 16 | guess=$(((low + high) / 2)) 17 | echo >&2 "low $low high $high guess $guess" 18 | echo >&2 -n "generating " 19 | head "-c$guess" /dev/zero >/tmp/zeros.dat 20 | echo >&2 -n "uploading " 21 | url=$(snail a push -p "$POCS_PROJECT_DOMAIN" /tmp/zeros.dat) 22 | # force cdn.glitch.global 23 | url_glitch_global=$(echo "$url" | sed s/cdn\\.glitch\\.me/cdn.glitch.global/) 24 | echo >&2 -n "downloading " 25 | if curl -fLIs "$url_glitch_global" >/dev/null; then 26 | echo >&2 "ok" 27 | low=$((guess + 1)) 28 | else 29 | echo >&2 "fail" 30 | high=$((guess - 1)) 31 | fi 32 | done 33 | echo "$high" 34 | -------------------------------------------------------------------------------- /misc/pipe-wrap.js: -------------------------------------------------------------------------------- 1 | // minify this file for `doPipe` `WRAPPER_SRC` 2 | 3 | /* eslint-disable no-var, import/newline-after-import */ 4 | 5 | var base64 = /** @type {const} */ ('base64'); 6 | var data = 'data'; 7 | 8 | var { 9 | stdin: processStdin, 10 | stdout: processStdout, 11 | argv: [, commandB64], 12 | } = process; 13 | 14 | var /** @type {NodeJS.Timeout | null} */ pingTimer = null; 15 | 16 | var writeln = (/** @type {string} */ v) => { 17 | if (pingTimer) { 18 | clearTimeout(pingTimer); 19 | pingTimer = null; 20 | } 21 | processStdout.write(v + '\n'); 22 | }; 23 | 24 | var child = require('child_process').spawn(Buffer.from(commandB64, base64).toString('utf8'), { 25 | stdio: 'pipe', 26 | shell: true, 27 | }); 28 | 29 | var recvBuf = ''; 30 | 31 | processStdin.setRawMode(true); 32 | processStdin.setEncoding('ascii'); 33 | processStdin.on(data, (chunk) => { 34 | if (!pingTimer) { 35 | pingTimer = setTimeout(() => { 36 | writeln(')p'); 37 | }, 4000); 38 | } 39 | var parts = (recvBuf + chunk).split('\n'); 40 | recvBuf = /** @type {string} */ (parts.pop()); 41 | for (var part of parts) { 42 | if (part) { 43 | child.stdin.write(Buffer.from(part, base64)); 44 | } else { 45 | child.stdin.end(); 46 | } 47 | } 48 | }); 49 | 50 | writeln(')s'); 51 | 52 | child.stdout.on(data, (chunk) => { 53 | writeln(')o' + chunk.toString(base64)); 54 | }); 55 | 56 | child.stderr.on(data, (chunk) => { 57 | writeln(')e' + chunk.toString(base64)); 58 | }); 59 | 60 | child.on('exit', (code, signal) => { 61 | var rv = signal ? 1 : code; 62 | writeln(')r' + rv); 63 | processStdin.pause(); 64 | }); 65 | -------------------------------------------------------------------------------- /notes/assets.txt: -------------------------------------------------------------------------------- 1 | GET https://api.glitch.com/v1/projects/(project id)/policy?contentType=image%2Fpng 2 | < 3 | {"policy":"eyJl...=","signature":"/7PR...=","accessKeyId":"AKIA..."} 4 | 5 | // policy 6 | { 7 | "expiration":"2021-10-10T03:51:52.214Z", // tomorrow 8 | "conditions":[ 9 | { 10 | "bucket":"production-assetsbucket-8ljvyr1xczmb" 11 | }, 12 | [ 13 | "starts-with", 14 | "$key", 15 | "(project id)/" 16 | ], 17 | [ 18 | "eq", 19 | "$Content-Type", 20 | "image/png" 21 | ], 22 | { 23 | "acl":"public-read" 24 | }, 25 | [ 26 | "starts-with", 27 | "$Cache-Control", 28 | "" 29 | ], 30 | [ 31 | "content-length-range", 32 | 0, 33 | 268435456 // 256 MiB 34 | ] 35 | ] 36 | } 37 | 38 | POST https://s3.amazonaws.com/production-assetsbucket-8ljvyr1xczmb 39 | > 40 | -----------------------------15941445142760045594863234047 41 | Content-Disposition: form-data; name="key" 42 | 43 | (project id)/no-assets-yet.png 44 | -----------------------------15941445142760045594863234047 45 | Content-Disposition: form-data; name="Content-Type" 46 | 47 | image/png 48 | -----------------------------15941445142760045594863234047 49 | Content-Disposition: form-data; name="Cache-Control" 50 | 51 | max-age=31536000 52 | -----------------------------15941445142760045594863234047 53 | Content-Disposition: form-data; name="AWSAccessKeyId" 54 | 55 | AKIA... 56 | -----------------------------15941445142760045594863234047 57 | Content-Disposition: form-data; name="acl" 58 | 59 | public-read 60 | -----------------------------15941445142760045594863234047 61 | Content-Disposition: form-data; name="policy" 62 | 63 | eyJl...= 64 | -----------------------------15941445142760045594863234047 65 | Content-Disposition: form-data; name="signature" 66 | 67 | /7PR...= 68 | -----------------------------15941445142760045594863234047 69 | Content-Disposition: form-data; name="file"; filename="no-assets-yet.png" 70 | Content-Type: image/png 71 | 72 | ... 73 | 74 | // default 365 days max-age 75 | 76 | // https://s3.amazonaws.com/production-assetsbucket-8ljvyr1xczmb/(project id)/no-assets-yet.png 77 | cdnURL(s3URL) { 78 | if (!s3URL) { 79 | return ''; 80 | } 81 | // eslint-disable-next-line no-useless-escape, no-useless-escape, no-useless-escape 82 | return s3URL.replace(/https?\:\/\/[^\/]+\/[^\/]+\//, `${CDN_URL}/`); 83 | } 84 | // https://cdn.glitch.me/(project id)%2Fno-assets-yet.png 85 | const versionedUrl = `${url}?${qs.stringify({ v: moment(date).valueOf() })}`; 86 | // https://cdn.glitch.me/(project id)%2Fno-assets-yet.png?v=1633751513443 87 | -------------------------------------------------------------------------------- /notes/auth.txt: -------------------------------------------------------------------------------- 1 | // email 2 | 3 | POST https://api.glitch.com/v1/auth/email/ 4 | (request has anon persistent token in authorization header) 5 | > {"emailAddress":"(email address)"} 6 | < "OK" 7 | 8 | // when you paste it in 9 | POST https://api.glitch.com/v1/auth/email/(sign-in code) 10 | (request has anon persistent token in authorization header) 11 | > (nothing) 12 | < {"user": (the user)} 13 | 14 | // when you click the link 15 | GET https://email.glitch.com/e/c/(bunch of data) 16 | < (redirect) 17 | 18 | GET https://glitch.com/login/email?token=(sign-in code)&(other analytics stuff) 19 | < (it makes the same POST above) 20 | 21 | // password 22 | 23 | // setting up a password 24 | POST https://api.glitch.com/v1/self/updatePassword 25 | > {"oldPassword":"(old password, empty for none)","newPassword":"(new password, min 8 chars)"} 26 | < "OK" 27 | 28 | // signing in 29 | POST https://api.glitch.com/v1/auth/password 30 | (request has anon persistent token in authorization header) 31 | > {"emailAddress":"(email address)","password":"(password)"} 32 | < {"user": (the user)} 33 | -------------------------------------------------------------------------------- /notes/create.txt: -------------------------------------------------------------------------------- 1 | /remix/:name/:projectId => RemixFromProjectIdRoute 2 | 3 | - search params to env dict 4 | - name ignored 5 | - send CREATE_PROJECT {baseProjectId, env, projectOwner} 6 | 7 | /remix/:domain => RemixProjectRoute 8 | 9 | - search params to env dict 10 | - send CREATE_PROJECT {baseProjectDomain, env, projectOwner} 11 | 12 | additionalData = {domain, remixReferer, recaptcha?, gitRepoUrl?} 13 | remixProject(id, additionalData) 14 | remixProjectByDomain(domain, additionalData) 15 | 16 | by id: 17 | POST /v1/projects/(id)/remix 18 | > (data) 19 | 20 | by domain: 21 | POST /v1/projects/by/domain/(domain)/remix 22 | > (data) 23 | -------------------------------------------------------------------------------- /notes/debugger.txt: -------------------------------------------------------------------------------- 1 | // 2 | set env GLITCH_DEBUGGER=true 3 | https://glitch.com/edit/debugger.html?(project id) 4 | 5 | POST https://api.glitch.com/v1/projects/(project id)/singlePurposeTokens/devtools 6 | > 7 | (no body) 8 | < 9 | {"token":"41ea..."} 10 | 11 | // 12 | devtools://devtools/bundled/inspector.html?ws=api.glitch.com:80/project/debugger/41ea... 13 | 14 | // 15 | 192 app 25 5 19760 3156 2840 S 0.0 0.1 0:00.00 `- bash /opt/watcher/app-types/node/start.sh 16 | 210 app 25 5 861020 30428 25120 S 0.0 0.5 0:00.13 `- node source/debugger.js node server.js 17 | // cwd of 210 is /opt/debugger 18 | 19 | // 20 | if [ -z "${GLITCH_DEBUGGER}" ]; then 21 | if [ "${START_SCRIPT}" = "null" ]; then 22 | (>&2 echo "Check /app/package.json: command not found. Is a start script missing? https://glitch.com/help/failstart/") 23 | else 24 | eval ${START_SCRIPT} & pid=$! 25 | fi 26 | else 27 | cd /opt/debugger 28 | node source/debugger.js "${START_SCRIPT}" & pid=$! 29 | fi 30 | 31 | // 32 | ports 9200 (DEBUGGER_PORT), 1988 (APP_PORT) 33 | -------------------------------------------------------------------------------- /notes/domain.txt: -------------------------------------------------------------------------------- 1 | POST https://api.glitch.com/v1/projects/(project id)/domains 2 | > {"domain":"(custom domain)"} 3 | < {"hostname":"(custom domain)","preview_hostname":"(something).preview.edgeapp.net","dns_configured":false} 4 | 5 | GET https://api.glitch.com/v1/projects/(project id)/domains 6 | < {"items":[{"hostname":"(custom domain)"},...]} 7 | 8 | DELETE https://api.glitch.com/v1/projects/(project id)/domains 9 | < {"domain":"(custom domain)"} 10 | > "OK" 11 | -------------------------------------------------------------------------------- /notes/invite.txt: -------------------------------------------------------------------------------- 1 | the new UI doesn't have the 'request to join project' / 'invite to edit' system 2 | so now we can't add anonymous users to projects 3 | 4 | the old way: 5 | - they click 'request to join' 6 | - UI is gone, but Snail can still send request over OT socket, possible 7 | - you click 'invite to edit' 8 | - UI is gone, impossible 9 | 10 | the current way: 11 | - click share 12 | - find user by login or email address 13 | - anonymous user has no login or email address, impossible 14 | - Glitch sends them an email 15 | - anonymous user has no email address, impossible 16 | - they click the link in the email to join 17 | 18 | maybe through teams? 19 | - you create a team 20 | - you invite the anonymous user 21 | - basically the same interface, you can't find an anonymous user, impossible 22 | - you add the project to the team 23 | - they click 'join this team project' 24 | - UI is gone, but Snail can still access the API, possible 25 | 26 | maybe the other way through teams? 27 | - they create a team 28 | - anonymous users not allowed to create team, impossible 29 | - they invite you to team 30 | - goes through your email, really lame, but possible 31 | - you click a thing to accept 32 | - you add the project to the team 33 | - they click 'join this team project' 34 | - UI is gone, but Snail can still access the API, possible 35 | 36 | the self-xss way: 37 | - you run `await application.glitchApi().v0.createProjectPermission(application.currentProject().id(), theirUserId, 20);` 38 | - you have to open the developer console, really lame, but possible 39 | - mobile has no developer console, impossible 40 | 41 | https://www.youtube.com/watch?v=KxGRhd_iWuE 42 | -------------------------------------------------------------------------------- /notes/join.txt: -------------------------------------------------------------------------------- 1 | // in-editor flow 2 | 3 | // request invite 4 | (inside ot) 5 | > {"type":"broadcast","payload":{"user":{"avatarUrl":null,"avatarThumbnailUrl":null,"awaitingInvite":true,"id":29281147,"name":null,"login":null,"color":"#6ed7f4","lastCursor":{"cursor":{"line":0,"ch":0,"sticky":null},"documentId":"19847bdfc957","clientId":"QZWEsRIxgbAZlXDr"},"readOnly":null,"thanksReceived":false,"tabId":"32662","teams":[]}}} 6 | // notably: `payload.user.awaitingInvite`, `payload.user.id` 7 | 8 | // invite 9 | POST https://api.glitch.com/project_permissions/8e6cdc77-20b9-4209-850f-d2607eeae33a 10 | > {"userId":29281147,"projectId":"8e6cdc77-20b9-4209-850f-d2607eeae33a","accessLevel":20} 11 | < null 12 | 13 | // after that, 14 | // (1) inviter broadcasts the invitee's user with `invited: true` 15 | // (2) invitee broadcasts itself with `awaitingInvite: false` 16 | 17 | (inside ot) 18 | < {"type":"broadcast","payload":{"user":{"avatarUrl":null,"avatarThumbnailUrl":null,"awaitingInvite":false,"id":29281147,"name":null,"login":null,"color":"#6ed7f4","lastCursor":{"cursor":{"line":0,"ch":0,"sticky":null},"documentId":"19847bdfc957","clientId":"BkXYb6c5CO39geaR"},"thanksReceived":false,"tabId":"57200","teams":[],"left":true}}} 19 | // notably: `payload.user.left` 20 | 21 | // leave, same as kick 22 | DELETE https://api.glitch.com/v1/projects/8e6cdc77-20b9-4209-850f-d2607eeae33a/users/29281147 23 | > (nothing) 24 | < "OK" 25 | 26 | // old fashioned token flow 27 | POST https://api.glitch.com/projects/(project invite token)/join 28 | > 29 | (nothing) 30 | < 31 | (project json) 32 | 33 | // newfangled share flow 34 | 35 | // share invite 36 | POST https://api.glitch.com/v1/projects/d206077e-97d2-489a-acb5-cc73fb82a071/invites 37 | > {"userId":210195} 38 | < {"id":29999598,"expiresAt":"2021-03-10T05:43:14.725Z","user":{"id":210195,"name":null,"login":"wh0","avatarUrl":"https://s3.amazonaws.com/production-assetsbucket-8ljvyr1xczmb/user-avatar/f01b5275-d807-41b9-880f-4d17e51e9ff6-large.png","color":"#a7ff82"},"email":null} 39 | 40 | POST https://api.glitch.com/v1/projects/(project id)/invites 41 | > 42 | {"userId":"(user id)"} 43 | // supposedly you can alternatively do {"email":"(email?)"} 44 | < 45 | (unknown, I never tried it) 46 | 47 | GET https://api.glitch.com/v1/projects/(project id)/invites 48 | > 49 | (nothing) 50 | < 51 | (unknown, I never tried it) 52 | 53 | DELETE https://api.glitch.com/v1/projects/(project id)/invites/(invite id) 54 | > 55 | (nothing) 56 | < 57 | (unknown, I never tried it) 58 | -------------------------------------------------------------------------------- /notes/meta-deps.txt: -------------------------------------------------------------------------------- 1 | ## node (engine) 2 | 3 | Continuing supporting >=10. 4 | 10.x is no longer supported, but it plays an important role in Glitch, where 5 | it's the interpreter for the in-container software. 6 | 7 | ## commander 8 | 9 | Can't update to 8.x until we raise our minimum node version. 10 | 11 | ## eslint 12 | 13 | Can't update to 8.x until we raise our minimum node version. 14 | 15 | ## events 16 | 17 | Webpack depends on events for some reason. 18 | As a separate problem, TypeScript incorrectly resolves `require('events')` to 19 | this package instead of Node.js's built-in module. 20 | And events doesn't satisfy the type checking configuration we're using. 21 | 22 | ## form-data 23 | 24 | Want to remove this, but node-fetch alone can't send a known-length file to S3. 25 | This pulls in mime-db, which is large compared to this project. 26 | 27 | ## node-fetch 28 | 29 | Can't update to 3.x until we raise our minimum node version. 30 | 31 | ## socket.io-client 32 | 33 | Stuck on 2.x because Glitch's WeTTY server uses version 2.x. 34 | 35 | ## typescript 36 | 37 | Can't update to 5.x until we raise our minimum node version. 38 | 39 | ## webpack-cli 40 | 41 | Can't update to 5.x until we raise our minimum node version. 42 | 43 | ## ws 44 | 45 | Sticking to 7.5.x, to match engine.io-client so that we don't install a 46 | separate copy. 47 | -------------------------------------------------------------------------------- /notes/meta-minimize.txt: -------------------------------------------------------------------------------- 1 | It would be nice to minimize our single-file bundle. 2 | 3 | However: 4 | 5 | - Let's not do that unless we can also release a source map. 6 | - webpack can generate a source map, but it would get messed when we add the 7 | shebang in build.sh. 8 | - webpack has BannerPlugin that can add the shebang natively. 9 | - BannerPlugin makes source map generation silently fail. 10 | 11 | Thus as this time we have no satisfactory way to do it. 12 | -------------------------------------------------------------------------------- /notes/meta-names.dot: -------------------------------------------------------------------------------- 1 | digraph { 2 | rankdir=LR 3 | epsilon [label="ε"] 4 | 5 | # glitch 6 | edge [color="#694dff", fontcolor="#694dff"] 7 | glitch -> snail [label="reserved on Glitch"] 8 | glitch -> cli [label="reserved on Glitch"] 9 | snail -> cli [label=<
us on Glitch
>] 10 | glitch -> epsilon [label="reserved on Glitch"] 11 | snail -> epsilon [label="unknown (private) on Glitch"] 12 | cli -> epsilon [label="unknown (private) on Glitch"] 13 | 14 | # npm 15 | edge [color="#cb0000", fontcolor="#cb0000"] 16 | glitch -> snail [label=<
us on npm
>] 17 | glitch -> cli [label="unrelated on npm"] 18 | snail -> cli [label="unrelated on npm"] 19 | glitch -> epsilon [label="unrelated on npm"] 20 | snail -> epsilon [label="unrelated on npm"] 21 | cli -> epsilon [label="unrelated on npm"] 22 | 23 | # github 24 | edge [color="#171515", fontcolor="#171515"] 25 | snail -> cli [label=<
us on GitHub (wh0/)
>] 26 | 27 | # command line 28 | edge [color="#00a000", fontcolor="#00a000"] 29 | snail -> epsilon [label=<
us on command line
>] 30 | } 31 | -------------------------------------------------------------------------------- /notes/meta-names.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | %0 5 | 6 | 7 | 8 | epsilon 9 | 10 | ε 11 | 12 | 13 | 14 | glitch 15 | 16 | glitch 17 | 18 | 19 | 20 | glitch->epsilon 21 | 22 | 23 | reserved on Glitch 24 | 25 | 26 | 27 | glitch->epsilon 28 | 29 | 30 | unrelated on npm 31 | 32 | 33 | 34 | snail 35 | 36 | snail 37 | 38 | 39 | 40 | glitch->snail 41 | 42 | 43 | reserved on Glitch 44 | 45 | 46 | 47 | glitch->snail 48 | 49 | 50 | 51 | us on npm 52 | 53 | 54 | 55 | cli 56 | 57 | cli 58 | 59 | 60 | 61 | glitch->cli 62 | 63 | 64 | reserved on Glitch 65 | 66 | 67 | 68 | glitch->cli 69 | 70 | 71 | unrelated on npm 72 | 73 | 74 | 75 | snail->epsilon 76 | 77 | 78 | unknown (private) on Glitch 79 | 80 | 81 | 82 | snail->epsilon 83 | 84 | 85 | unrelated on npm 86 | 87 | 88 | 89 | snail->epsilon 90 | 91 | 92 | 93 | us on command line 94 | 95 | 96 | 97 | snail->cli 98 | 99 | 100 | 101 | us on Glitch 102 | 103 | 104 | 105 | snail->cli 106 | 107 | 108 | unrelated on npm 109 | 110 | 111 | 112 | snail->cli 113 | 114 | 115 | 116 | us on GitHub (wh0/) 117 | 118 | 119 | 120 | cli->epsilon 121 | 122 | 123 | unknown (private) on Glitch 124 | 125 | 126 | 127 | cli->epsilon 128 | 129 | 130 | unrelated on npm 131 | 132 | 133 | -------------------------------------------------------------------------------- /notes/meta-release.txt: -------------------------------------------------------------------------------- 1 | typecheck 2 | - `tsc -p jsconfig.json` 3 | 4 | lint 5 | - `eslint *.js misc pocs src` 6 | 7 | bump dependencies 8 | - `pnpm outdated` 9 | - `pnpm update` 10 | - update package.json 11 | 12 | commit 13 | - commit message: `bump dependencies` 14 | 15 | bump version 16 | - `pnpm version minor` (or as needed) 17 | 18 | commit 19 | - `[p]npm version` commits automatically, so `--amend` 20 | - commit message: `version x.x.x` 21 | 22 | check package 23 | - `pnpm pack --dry-run` 24 | 25 | build 26 | - `./build.sh` 27 | 28 | smoketest 29 | - `distsnail --version` 30 | 31 | check bundle 32 | - save https://snail-cli.glitch.me/dist/stats.json 33 | - open https://chrisbateman.github.io/webpack-visualizer/ 34 | - drop stats.json 35 | 36 | build help 37 | - `rm -rf help-staging && mkdir -p help-staging && node build-help.js` 38 | 39 | check help staging 40 | - open https://snail-cli.glitch.me/help-staging/ 41 | - check version at bottom 42 | 43 | write release notes 44 | 45 | tag commit 46 | - `[p]npm version` tags automatically, so `-f` 47 | - tag name: `vx.x.x` 48 | 49 | push 50 | - master 51 | - scratch 52 | - vx.x.x 53 | 54 | publish on npm 55 | - `pnpm publish` 56 | 57 | create release on GitHub 58 | - save https://snail-cli.glitch.me/dist/snail.js 59 | - body: release notes 60 | - artifact: snail.js 61 | 62 | update help 63 | - `rm -r vanity/help && cp -rv help-staging vanity/help` 64 | 65 | close issues on GitHub 66 | - uh https://github.com/wh0/snail-cli/issues 67 | - message: "fixed in vx.x.x" 68 | 69 | update practice template 70 | - open https://glitch.com/edit/console.html?fa7cbf09-22f8-40fa-9ff5-054ede74b759 71 | - `npm update` 72 | - `git commit -am "bump dependencies"` 73 | -------------------------------------------------------------------------------- /notes/meta-tramp.txt: -------------------------------------------------------------------------------- 1 | Put this in your .emacs: 2 | 3 | ```el 4 | (with-eval-after-load 'tramp 5 | (add-to-list 'tramp-methods 6 | '("snail" 7 | (tramp-login-program "snail") 8 | (tramp-login-args (("term") ("-p") ("%h") ("--no-raw"))) 9 | (tramp-remote-shell "/bin/sh") 10 | (tramp-remote-shell-args ("-c"))))) 11 | ``` 12 | 13 | Use the filename format /snail:(your project domain):(path in project). 14 | 15 | ```sh 16 | emacs /snail:snail-cli:src/index.js 17 | ``` 18 | -------------------------------------------------------------------------------- /notes/meta-wants.txt: -------------------------------------------------------------------------------- 1 | engineering: 2 | - tests 3 | - extract some functions from lower level commands 4 | - split up into multiple files 5 | 6 | impossible things: 7 | - sso auth 8 | - maybe enable minimize 9 | - captcha (for anonymous project creation and soon for auth) 10 | -------------------------------------------------------------------------------- /notes/ot.txt: -------------------------------------------------------------------------------- 1 | // 2 | wss://api.glitch.com/(project uuid)/ot?authorization=(persistent token) 3 | 4 | > {"type":"master-state","clientId":"aeSLc0RTcXHVVKFU","force":true} 5 | < {"type":"master-state","state":{"id":"7d8fdbb2e9d1","version":2,"documents":{"root":{"docId":"root","docType":"directory","children":{".":"6a18ba86790a"}},"6a18ba86790a":{"name":".","docId":"6a18ba86790a","parentId":"root","docType":"directory","children":{".glitch-assets":"10c5eb65ef16","a.txt":"5299b77ec626","c.txt":"571e5bc5aacd","tofustrap.png":"06ccb3e91651"}},"10c5eb65ef16":{"name":".glitch-assets","docId":"10c5eb65ef16","parentId":"6a18ba86790a","docType":"file"},"5299b77ec626":{"name":"a.txt","docId":"5299b77ec626","parentId":"6a18ba86790a","docType":"file"},"571e5bc5aacd":{"name":"c.txt","docId":"571e5bc5aacd","parentId":"6a18ba86790a","docType":"file"},"06ccb3e91651":{"name":"tofustrap.png","docId":"06ccb3e91651","parentId":"6a18ba86790a","docType":"file","base64Content":"iVBORw0KGgoAAAANSUhEUgAAADIAAABJCAIAAADwuOLkAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAHYSURBVGhD7dhbTsMwEIXhboI3HtlnX9kX62ENYfA5Hk3juGR8IVY1n/qQTi7+cZFQuW1LiiyPV8n6MjiawJFVpmACHA1yKuvMwrgGOOrwR1bbMrgLOHKqZvU81MJzgKNt+7y94cX3hYOs3SMGksdqkL547tE+a1IQfL9/4GWz7vc7TxsPWdr0u10ZJv2k5pbZMkeWhTjgqAmykDIgy0IccHSaTdFjec6ALCu1EUd1co2m2CYxOMvCAsBRxmnOsnuG+cQsC4sJeywkqGwS/5SlsKpaN0ubIquAVVVkPYVV1bAs/P3iGz+sqkbu1opZi+7Wolku5c+AVdU1WeKaLOyH4PtH5SmsqqbvFgoE3yflBKuq9izBa/s+TeCDssOswyaxz1K4DThy4s3ZLkuCak2immXxwQlHJ/CGbJfFiypOZVlcJOGoghdlNotX1LmzLC6YcGTwRCKfF7J47i9dWRbXT+zk+e9QzbAsqycIpmT1iyyPyPKILI/I8ogsj8jyiCyPyPJ4rSzX94UGLVlomlp2nIXvCDXXZHHxCvkyc0GWLMyjCrlg3aypTaJ9t5q/mp5RzSr/q4aDyDI0q+b6LNkhxVHs1p4s/ITUIItXz3GQtYLI8lgya9t+AIR3qRxzehTJAAAAAElFTkSuQmCC"}}},"canBroadcast":true} 6 | 7 | < {"type":"broadcast","payload":{"type":"project-stats","timeNs":1602358423402000000,"cpuUsage":34196862115,"diskSize":209175552,"diskUsage":1929216,"memoryLimit":536870912,"memoryUsage":32301056,"quotaPercent":0.25}} 8 | 9 | // you get the top level `root`, the `.`, and the immediate children of `.` to get you started 10 | // then you register-document the children 11 | // official editor starts with register-document on `.`, maybe we don't have to 12 | > {"type":"register-document","docId":"aececd33dad3"} 13 | < {"type":"register-document","document":{"name":"inform7","docId":"aececd33dad3","parentId":"2b0b5265dd01","docType":"directory","children":{"ChangeLogs":{"name":"ChangeLogs","docId":"015162c30cb9","parentId":"aececd33dad3","docType":"directory","children":{}},"INSTALL":{"name":"INSTALL","docId":"503b4c7b63fe","parentId":"aececd33dad3","docType":"file"},"README":{"name":"README","docId":"528bf0b35ece","parentId":"aececd33dad3","docType":"file"}}}} 14 | 15 | // requesting a text file 16 | > {"type":"register-document","docId":"5299b77ec626"} 17 | < {"type":"register-document","document":{"name":"a.txt","docId":"5299b77ec626","parentId":"6a18ba86790a","docType":"file","content":"hello world\nasdf\na\n"}} 18 | 19 | // requesting a binary file 20 | > {"type":"register-document","docId":"06ccb3e91651"} 21 | < {"type":"register-document","document":{"name":"tofustrap.png","docId":"06ccb3e91651","parentId":"6a18ba86790a","docType":"file","base64Content":"iVBORw0KGgoAAAANSUhEUgAAADIAAABJCAIAAADwuOLkAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAHYSURBVGhD7dhbTsMwEIXhboI3HtlnX9kX62ENYfA5Hk3juGR8IVY1n/qQTi7+cZFQuW1LiiyPV8n6MjiawJFVpmACHA1yKuvMwrgGOOrwR1bbMrgLOHKqZvU81MJzgKNt+7y94cX3hYOs3SMGksdqkL547tE+a1IQfL9/4GWz7vc7TxsPWdr0u10ZJv2k5pbZMkeWhTjgqAmykDIgy0IccHSaTdFjec6ALCu1EUd1co2m2CYxOMvCAsBRxmnOsnuG+cQsC4sJeywkqGwS/5SlsKpaN0ubIquAVVVkPYVV1bAs/P3iGz+sqkbu1opZi+7Wolku5c+AVdU1WeKaLOyH4PtH5SmsqqbvFgoE3yflBKuq9izBa/s+TeCDssOswyaxz1K4DThy4s3ZLkuCak2immXxwQlHJ/CGbJfFiypOZVlcJOGoghdlNotX1LmzLC6YcGTwRCKfF7J47i9dWRbXT+zk+e9QzbAsqycIpmT1iyyPyPKILI/I8ogsj8jyiCyPyPJ4rSzX94UGLVlomlp2nIXvCDXXZHHxCvkyc0GWLMyjCrlg3aypTaJ9t5q/mp5RzSr/q4aDyDI0q+b6LNkhxVHs1p4s/ITUIItXz3GQtYLI8lgya9t+AIR3qRxzehTJAAAAAElFTkSuQmCC"}} 22 | 23 | // add d.txt 24 | > {"type":"client-oplist","opList":{"id":"N7m6B1bj9oiK5kkV","version":2,"ops":[{"type":"add","name":"d.txt","docId":"y0ykE60KjOCGbzr6","docType":"file","parentId":"6a18ba86790a"}]}} 25 | > {"type":"register-document","docId":"y0ykE60KjOCGbzr6"} 26 | < {"type":"accepted-oplist","opList":{"id":"N7m6B1bj9oiK5kkV","version":2,"ops":[{"type":"add","name":"d.txt","docId":"y0ykE60KjOCGbzr6","docType":"file","parentId":"6a18ba86790a"}]}} 27 | < {"type":"register-document","document":{"name":"d.txt","docId":"y0ykE60KjOCGbzr6","parentId":"6a18ba86790a","docType":"file","content":""}} 28 | 29 | // rename d.txt to e/f.txt 30 | > {"type":"client-oplist","opList":{"id":"lddhAf1Cf3k85NSy","version":3,"ops":[{"type":"add","name":"e","docId":"Jk8emh5jtalvQ4qm","docType":"directory","parentId":"6a18ba86790a"},{"type":"rename","docId":"y0ykE60KjOCGbzr6","newName":"f.txt","newParentId":"Jk8emh5jtalvQ4qm"}]}} 31 | < {"type":"accepted-oplist","opList":{"id":"lddhAf1Cf3k85NSy","version":3,"ops":[{"type":"add","name":"e","docId":"Jk8emh5jtalvQ4qm","docType":"directory","parentId":"6a18ba86790a"},{"type":"rename","docId":"y0ykE60KjOCGbzr6","newName":"f.txt","newParentId":"Jk8emh5jtalvQ4qm","oldParentId":"6a18ba86790a"}]}} 32 | 33 | // replace contents of a.txt 34 | > {"type":"client-oplist","opList":{"id":"svwNdn9WeB2WUt39","version":6,"ops":[{"docId":"5299b77ec626","type":"remove","text":"hello world\nasdf\na\n","position":0},{"docId":"5299b77ec626","type":"insert","text":"this is a\n","position":0}]}} 35 | < {"type":"accepted-oplist","opList":{"id":"svwNdn9WeB2WUt39","version":6,"ops":[{"docId":"5299b77ec626","type":"remove","text":"hello world\nasdf\na\n","position":0},{"docId":"5299b77ec626","type":"insert","text":"this is a\n","position":0}]}} 36 | 37 | // there's also a set-content op type, for offline edits 38 | 39 | // app status 40 | < { type: 'broadcast', 41 | payload: 42 | { type: 'project-stats', 43 | timeNs: 1620201326323000000, 44 | cpuUsage: 3414913529, 45 | diskSize: 209175552, 46 | diskUsage: 1992704, 47 | memoryLimit: 536870912, 48 | memoryUsage: 34664448, 49 | quotaPercent: 0.25 } } 50 | < { type: 'broadcast', 51 | payload: 52 | { type: 'project-stats', 53 | timeNs: 1620201341405000000, 54 | cpuUsage: 3635315666, 55 | diskSize: 209175552, 56 | diskUsage: 1992704, 57 | memoryLimit: 536870912, 58 | memoryUsage: 39329792, 59 | quotaPercent: 0.25, 60 | cpuUsagePercent: 0.014613588308615522, 61 | quotaUsagePercent: 0.05845435323446209 } } 62 | -------------------------------------------------------------------------------- /notes/term.txt: -------------------------------------------------------------------------------- 1 | 2 | POST https://api.glitch.com/v1/projects/(project uuid)/singlePurposeTokens/terminal 3 | > (no body) 4 | < {"token":"(terminal token)"} 5 | 6 | // 7 | https://api.glitch.com/console/(terminal token) 8 | 9 | const userRegex = new RegExp('ssh/[^/]+$'); 10 | const trim = (str: string): string => str.replace(/\/*$/, ''); 11 | const socketBase = trim(window.location.pathname).replace(userRegex, ''); 12 | const socket = io(window.location.origin, { 13 | path: `${trim(socketBase)}/socket.io`, 14 | }); 15 | 16 | // https://github.com/glitchdotcom/wetty/commit/496db5e5632517052fb9abaeddd5ee769e77e296 17 | 18 | < login () 19 | < logout () 20 | < data (data) 21 | > resize ({cols, rows}) 22 | > input (input) 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "glitch-snail", 3 | "version": "2.20.0", 4 | "description": "CLI for Glitch", 5 | "keywords": [ 6 | "glitch", 7 | "cli" 8 | ], 9 | "homepage": "https://snail-cli.glitch.me/", 10 | "license": "MIT", 11 | "files": [ 12 | "/src" 13 | ], 14 | "bin": { 15 | "snail": "./src/index.js" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "https://github.com/wh0/snail-cli.git" 20 | }, 21 | "scripts": { 22 | "start": "cd vanity && python3 -m http.server $PORT" 23 | }, 24 | "dependencies": { 25 | "commander": "^7.2.0", 26 | "form-data": "^4.0.2", 27 | "node-fetch": "^2.7.0", 28 | "socket.io-client": "^2.5.0", 29 | "ws": "~7.5.10" 30 | }, 31 | "devDependencies": { 32 | "@types/node": "^22.15.18", 33 | "@types/node-fetch": "^2.6.12", 34 | "@types/socket.io-client": "^1.4.36", 35 | "@types/ws": "^7.4.7", 36 | "eslint": "^7.32.0", 37 | "eslint-config-airbnb-base": "^15.0.0", 38 | "eslint-plugin-import": "^2.31.0", 39 | "terser": "^5.39.2", 40 | "typescript": "^4.9.5", 41 | "webpack": "^5.99.8", 42 | "webpack-cli": "^4.10.0" 43 | }, 44 | "engines": { 45 | "node": ">=10" 46 | }, 47 | "glitch": { 48 | "projectType": "generated_static", 49 | "buildDirectory": "vanity" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /pocs/showargs.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | console.error(process.argv); 3 | process.exitCode = 1; 4 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | 'use strict'; 4 | 5 | const childProcess = require('child_process'); 6 | const crypto = require('crypto'); 7 | const fs = require('fs'); 8 | const os = require('os'); 9 | const path = require('path'); 10 | const util = require('util'); 11 | 12 | const commander = require('commander'); 13 | const fetch = require('node-fetch').default; 14 | 15 | const packageMeta = require('../package.json'); 16 | 17 | // credentials 18 | 19 | function getPersistentTokenPath() { 20 | return path.join(os.homedir(), '.config', 'snail', 'persistent-token'); 21 | } 22 | 23 | async function failIfPersistentTokenSaved() { 24 | const persistentTokenPath = getPersistentTokenPath(); 25 | let persistentTokenExists = false; 26 | try { 27 | await fs.promises.stat(persistentTokenPath); 28 | persistentTokenExists = true; 29 | } catch (/** @type {any} */ e) { 30 | if (e.code !== 'ENOENT') throw e; 31 | } 32 | if (persistentTokenExists) { 33 | throw new Error(`Persistent token already saved (${persistentTokenPath}). Delete that to authenticate again`); 34 | } 35 | } 36 | 37 | async function savePersistentToken(/** @type {string} */ persistentToken) { 38 | const persistentTokenPath = getPersistentTokenPath(); 39 | await fs.promises.mkdir(path.dirname(persistentTokenPath), {recursive: true}); 40 | await fs.promises.writeFile(persistentTokenPath, persistentToken + '\n', {flag: 'wx', mode: 0o600}); 41 | } 42 | 43 | function getPersistentTokenFromEnv() { 44 | return process.env.G_PERSISTENT_TOKEN; 45 | } 46 | 47 | async function getPersistentTokenFromConfig() { 48 | const persistentTokenPath = getPersistentTokenPath(); 49 | let data; 50 | try { 51 | data = await fs.promises.readFile(persistentTokenPath, 'utf8'); 52 | } catch (/** @type {any} */ e) { 53 | if (e.code === 'ENOENT') return null; 54 | throw e; 55 | } 56 | return data.trim(); 57 | } 58 | 59 | async function getPersistentTokenHowever() { 60 | return getPersistentTokenFromEnv() || await getPersistentTokenFromConfig(); 61 | } 62 | 63 | async function getPersistentToken() { 64 | const persistentToken = await getPersistentTokenHowever(); 65 | if (!persistentToken) throw new Error(`Persistent token unset. Sign in (snail auth) or save (${getPersistentTokenPath()}) or set in environment (G_PERSISTENT_TOKEN)`); 66 | return persistentToken; 67 | } 68 | 69 | function readResponse(/** @type {import('http').IncomingMessage} */ res) { 70 | return new Promise((resolve, reject) => { 71 | const chunks = /** @type {Buffer[]} */ ([]); 72 | res.on('data', (chunk) => { 73 | chunks.push(chunk); 74 | }); 75 | res.on('end', () => { 76 | resolve(Buffer.concat(chunks)); 77 | }); 78 | res.on('close', () => { 79 | reject(new Error('response close before end')); 80 | }); 81 | }); 82 | } 83 | 84 | async function readResponseString(/** @type {import('http').IncomingMessage} */ res) { 85 | return (await readResponse(res)).toString(); 86 | } 87 | 88 | async function boot() { 89 | const res = await fetch('https://api.glitch.com/boot?latestProjectOnly=true', { 90 | headers: { 91 | 'Authorization': await getPersistentToken(), 92 | }, 93 | }); 94 | if (!res.ok) throw new Error(`Glitch v0 boot response ${res.status} not ok, body ${await res.text()}`); 95 | return await res.json(); 96 | } 97 | 98 | // project selection 99 | 100 | function getProjectDomainFromOpts(/** @type {{project?: string}} */ opts) { 101 | return opts.project; 102 | } 103 | 104 | const REMOTE_NAME = 'glitch'; 105 | 106 | async function getProjectDomainFromRemote() { 107 | let result; 108 | try { 109 | result = await util.promisify(childProcess.execFile)('git', ['remote', 'get-url', REMOTE_NAME]); 110 | } catch (/** @type {any} */ e) { 111 | if (e.code === 2) return null; 112 | // Out of sympathy for places with older Git that doesn't yet have this 113 | // special exit code, we'll do some string matching too. 114 | if (typeof e.stderr === 'string' && e.stderr.includes('No such remote')) return null; 115 | throw e; 116 | } 117 | const remoteUrl = result.stdout.trim(); 118 | const m = /https:\/\/(?:[\w-]+@)?api\.glitch\.com\/git\/([\w-]+)/.exec(remoteUrl); 119 | if (!m) return null; 120 | return m[1]; 121 | } 122 | 123 | async function getProjectDomainHowever(/** @type {{project?: string}} */ opts) { 124 | return getProjectDomainFromOpts(opts) || await getProjectDomainFromRemote(); 125 | } 126 | 127 | async function getProjectDomain(/** @type {{project?: string}} */ opts) { 128 | const domain = await getProjectDomainHowever(opts); 129 | if (!domain) throw new Error('Unable to determine which project. Specify (-p) or set up remote (snail remote)'); 130 | return domain; 131 | } 132 | 133 | async function getProjectByDomain(/** @type {string} */ domain) { 134 | const res = await fetch(`https://api.glitch.com/v1/projects/by/domain?domain=${domain}`, { 135 | headers: { 136 | 'Authorization': await getPersistentToken(), 137 | }, 138 | }); 139 | if (!res.ok) throw new Error(`Glitch projects by domain response ${res.status} not ok, body ${await res.text()}`); 140 | const body = await res.json(); 141 | if (!(domain in body)) throw new Error(`Glitch project domain ${domain} not found`); 142 | return body[domain]; 143 | } 144 | 145 | // user selection 146 | 147 | async function getUserByLogin(/** @type {string} */ login) { 148 | const res = await fetch(`https://api.glitch.com/v1/users/by/login?login=${login}`); 149 | if (!res.ok) throw new Error(`Glitch users by login response ${res.status} not ok, body ${await res.text()}`); 150 | const body = await res.json(); 151 | if (!(login in body)) throw new Error(`Glitch user login ${login} not found`); 152 | return body[login]; 153 | } 154 | 155 | // ot 156 | 157 | /** 158 | * @typedef {{ 159 | * type: 'add', 160 | * docType: string, 161 | * name: string, 162 | * parentId: string, 163 | * docId: string, 164 | * }} OtOpAdd 165 | * @typedef {{ 166 | * type: 'unlink', 167 | * docId: string, 168 | * }} OtOpUnlink 169 | * @typedef {{ 170 | * type: 'rename', 171 | * docId: string, 172 | * newName: string, 173 | * newParentId: string, 174 | * }} OtOpRename 175 | * @typedef {{ 176 | * type: 'insert', 177 | * docId: string, 178 | * position: number, 179 | * text: string, 180 | * }} OtOpInsert 181 | * @typedef {{ 182 | * type: 'remove', 183 | * docId: string, 184 | * position: number, 185 | * text: string, 186 | * }} OtOpRemove 187 | * @typedef {( 188 | * OtOpAdd | 189 | * OtOpUnlink | 190 | * OtOpRename | 191 | * OtOpInsert | 192 | * OtOpRemove 193 | * )} OtOp 194 | * @typedef {{ 195 | * name: string, 196 | * docId: string, 197 | * parentId: string, 198 | * }} OtDocCommon 199 | * @typedef {OtDocCommon & { 200 | * docType: 'directory', 201 | * children: {[name: string]: string}, 202 | * }} OtDocDirectory 203 | * @typedef {OtDocCommon & { 204 | * docType: 'file', 205 | * content: string, 206 | * }} OtDocFileText 207 | * @typedef {OtDocCommon & { 208 | * docType: 'file', 209 | * base64Content: string, 210 | * }} OtDocFileBinary 211 | * @typedef {OtDocFileText | OtDocFileBinary} OtDocNotDirectory 212 | * @typedef {( 213 | * OtDocDirectory | 214 | * OtDocNotDirectory 215 | * )} OtDoc 216 | */ 217 | 218 | function otNewId() { 219 | return crypto.randomBytes(6).toString('hex'); 220 | } 221 | 222 | /** @return {asserts doc is OtDocDirectory} */ 223 | function otRequireDir(/** @type {OtDoc} */ doc) { 224 | if (doc.docType !== 'directory') throw new Error(`document ${doc.docId} is not a directory`); 225 | } 226 | 227 | /** @return {asserts doc is OtDocNotDirectory} */ 228 | function otRequireNotDir(/** @type {OtDoc} */ doc) { 229 | if (doc.docType === 'directory') throw new Error(`document ${doc.docId} is a directory`); 230 | } 231 | 232 | /** 233 | * @typedef {{ 234 | * resolve: any, 235 | * reject: any, 236 | * }} Resolvers 237 | */ 238 | 239 | /** 240 | * @typedef {{ 241 | * debug?: boolean, 242 | * }} OtClientOptions 243 | * @typedef {{ 244 | * ws: import('ws'), 245 | * opts: OtClientOptions, 246 | * clientId: string, 247 | * version: number, 248 | * masterPromised: Promise | null, 249 | * masterRequested: Resolvers, 250 | * docPromised: {[docId: string]: Promise}, 251 | * docRequested: {[docId: string]: Resolvers}, 252 | * opListRequested: {[opListId: string]: Resolvers}, 253 | * onmessage: ((message: any) => void) | null, 254 | * }} OtClient 255 | */ 256 | 257 | function otClientAttach(/** @type {import('ws')} */ ws, /** @type {OtClientOptions} */ opts) { 258 | const c = /** @type {OtClient} */ ({ 259 | ws, 260 | opts, 261 | clientId: /** @type {never} */ (null), 262 | version: /** @type {never} */ (null), 263 | masterPromised: null, 264 | masterRequested: /** @type {never} */ (null), 265 | docPromised: {}, 266 | docRequested: {}, 267 | opListRequested: {}, 268 | onmessage: null, 269 | }); 270 | c.ws.on('message', (/** @type {string} */ data) => { 271 | const msg = JSON.parse(data); 272 | if (c.opts.debug) { 273 | console.error('<', util.inspect(msg, {depth: null, colors: true})); 274 | } 275 | if (c.onmessage) { 276 | c.onmessage(msg); 277 | } 278 | switch (msg.type) { 279 | case 'master-state': { 280 | c.version = msg.state.version; 281 | const {resolve} = c.masterRequested; 282 | c.masterRequested = /** @type {never} */ (null); 283 | resolve(msg); 284 | break; 285 | } 286 | case 'register-document': { 287 | const doc = msg.document; 288 | const children = doc.children; 289 | doc.children = {}; 290 | for (const k in children) { 291 | doc.children[k] = children[k].docId; 292 | } 293 | const {resolve} = c.docRequested[doc.docId]; 294 | delete c.docRequested[doc.docId]; 295 | resolve(doc); 296 | break; 297 | } 298 | case 'accepted-oplist': { 299 | const opList = msg.opList; 300 | const {resolve} = c.opListRequested[opList.id]; 301 | delete c.opListRequested[opList.id]; 302 | c.version = opList.version + 1; 303 | resolve(); 304 | break; 305 | } 306 | case 'rejected-oplist': { 307 | const opList = msg.opList; 308 | const {reject} = c.opListRequested[opList.id]; 309 | delete c.opListRequested[opList.id]; 310 | reject(new Error(`oplist ${opList.id} rejected`)); 311 | break; 312 | } 313 | } 314 | }); 315 | return c; 316 | } 317 | 318 | function otClientSend(/** @type {OtClient} */ c, /** @type {any} */ msg) { 319 | if (c.opts.debug) { 320 | console.error('>', util.inspect(msg, {depth: null, colors: true})); 321 | } 322 | c.ws.send(JSON.stringify(msg)); 323 | } 324 | 325 | function otClientFetchMaster(/** @type {OtClient} */ c) { 326 | if (!c.masterPromised) { 327 | c.clientId = otNewId(); 328 | c.masterPromised = new Promise((resolve, reject) => { 329 | c.masterRequested = {resolve, reject}; 330 | otClientSend(c, { 331 | type: 'master-state', 332 | clientId: c.clientId, 333 | // the editor also sends `force: true` 334 | }); 335 | }); 336 | } 337 | return c.masterPromised; 338 | } 339 | 340 | function otClientFetchDoc(/** @type {OtClient} */ c, /** @type {string} */ docId) { 341 | if (!(docId in c.docPromised)) { 342 | c.docPromised[docId] = new Promise((resolve, reject) => { 343 | c.docRequested[docId] = {resolve, reject}; 344 | otClientSend(c, { 345 | type: 'register-document', 346 | docId, 347 | }); 348 | }); 349 | } 350 | return c.docPromised[docId]; 351 | } 352 | 353 | function otClientBroadcastOps(/** @type {OtClient} */ c, /** @type {OtOp[]} */ ops) { 354 | const id = otNewId(); 355 | return new Promise((resolve, reject) => { 356 | c.opListRequested[id] = {resolve, reject}; 357 | otClientSend(c, { 358 | type: 'client-oplist', 359 | opList: { 360 | id, 361 | version: c.version, 362 | ops, 363 | }, 364 | }); 365 | }); 366 | } 367 | 368 | async function otClientFetchDot(/** @type {OtClient} */ c) { 369 | const root = /** @type {OtDocDirectory} */ (await otClientFetchDoc(c, 'root')); 370 | return await otClientFetchDoc(c, root.children['.']); 371 | } 372 | 373 | async function otClientResolveExisting(/** @type {OtClient} */ c, /** @type {string[]} */ names) { 374 | let doc = await otClientFetchDot(c); 375 | for (const name of names) { 376 | otRequireDir(doc); 377 | if (name === '' || name === '.') continue; 378 | if (!(name in doc.children)) throw new Error(`${name} not found in document ${doc.docId}`); 379 | doc = await otClientFetchDoc(c, doc.children[name]); 380 | } 381 | return doc; 382 | } 383 | 384 | /** @return {Promise<{existing: OtDoc} | {parent: OtDoc, name: string | null}>} */ 385 | async function otClientResolveOrCreateParents( 386 | /** @type {OtClient} */ c, 387 | /** @type {OtOp[]} */ ops, 388 | /** @type {string[]} */ names, 389 | /** @type {string | null} */ fallbackName, 390 | ) { 391 | let doc = await otClientFetchDot(c); 392 | let docIndex = 0; 393 | 394 | for (; docIndex < names.length; docIndex++) { 395 | otRequireDir(doc); 396 | const name = names[docIndex]; 397 | if (name === '' || name === '.') continue; 398 | if (!(name in doc.children)) break; 399 | doc = await otClientFetchDoc(c, doc.children[name]); 400 | } 401 | 402 | for (; docIndex < names.length - 1; docIndex++) { 403 | const commonFields = { 404 | name: names[docIndex], 405 | docId: otNewId(), 406 | docType: /** @type {const} */ ('directory'), 407 | parentId: doc.docId, 408 | }; 409 | ops.push({type: 'add', ...commonFields}); 410 | doc = {...commonFields, children: {}}; 411 | } 412 | 413 | if (doc.docType === 'directory') { 414 | const name = docIndex < names.length && names[docIndex] || fallbackName; 415 | if (name && name in doc.children) { 416 | doc = await otClientFetchDoc(c, doc.children[name]); 417 | return {existing: doc}; 418 | } else { 419 | return {parent: doc, name}; 420 | } 421 | } else { 422 | return {existing: doc}; 423 | } 424 | } 425 | 426 | // file utilities 427 | 428 | async function guessSingleDestination(/** @type {string} */ dst, /** @type {string} */ name) { 429 | if (dst.endsWith(path.sep) || dst.endsWith(path.posix.sep)) return dst + name; 430 | let dstStats = null; 431 | try { 432 | dstStats = await fs.promises.stat(dst); 433 | } catch (/** @type {any} */ e) { 434 | if (e.code === 'ENOENT') return dst; 435 | throw e; 436 | } 437 | if (dstStats.isDirectory()) return path.join(dst, name); 438 | return dst; 439 | } 440 | 441 | // shell helpers 442 | 443 | function shellWord(/** @type {string} */ s) { 444 | return '\'' + s.replace(/'/g, '\'"\'"\'') + '\''; 445 | } 446 | 447 | // interaction 448 | 449 | function noCompletions(/** @type {string} */ line) { 450 | // eslint-disable-next-line array-bracket-spacing 451 | return [/** @type {string[]} */ ([]), line]; 452 | } 453 | 454 | function promptTrimmed(/** @type {string} */ query) { 455 | const readline = require('readline'); 456 | return new Promise((resolve, reject) => { 457 | const rl = readline.createInterface({ 458 | input: process.stdin, 459 | output: process.stderr, 460 | completer: noCompletions, 461 | historySize: 0, 462 | }); 463 | rl.on('close', () => { 464 | reject(new Error('readline close')); 465 | }); 466 | rl.question(`${query}: `, (answer) => { 467 | resolve(answer.trim()); 468 | rl.close(); 469 | }); 470 | }); 471 | } 472 | 473 | function promptPassword(/** @type {string} */ query) { 474 | const readline = require('readline'); 475 | return new Promise((resolve, reject) => { 476 | const maskedStderr = /** @type {typeof process.stdout} */ (Object.create(process.stdout)); 477 | // @ts-expect-error not all overloads supported 478 | maskedStderr.write = (/** @type {string} */ chunk, encoding, callback) => { 479 | const masked = chunk 480 | .replace( 481 | // eslint-disable-next-line no-control-regex 482 | /(^[^:]*: )|(\x1b\x5b[\x20-\x3f]*[\x40-\x7f])|(.)/g, 483 | (_, p, cs, ch) => p || cs || '*', 484 | ); 485 | process.stderr.write(masked); 486 | }; 487 | const rl = readline.createInterface({ 488 | input: process.stdin, 489 | output: maskedStderr, 490 | completer: noCompletions, 491 | }); 492 | rl.on('close', () => { 493 | reject(new Error('readline close')); 494 | }); 495 | rl.question(`${query}: `, (answer) => { 496 | resolve(answer); 497 | rl.close(); 498 | }); 499 | }); 500 | } 501 | 502 | // Glitch constants 503 | 504 | const ACCESS_LEVEL_MEMBER = 20; 505 | 506 | // commands 507 | 508 | async function doAuthPersistentToken(/** @type {string | undefined} */ persistentToken) { 509 | await failIfPersistentTokenSaved(); 510 | 511 | const persistentTokenPrompted = persistentToken || await promptPassword('Persistent token'); 512 | 513 | await savePersistentToken(persistentTokenPrompted); 514 | } 515 | 516 | async function doAuthAnon() { 517 | await failIfPersistentTokenSaved(); 518 | 519 | const res = await fetch('https://api.glitch.com/v1/users/anon', { 520 | method: 'POST', 521 | }); 522 | if (!res.ok) throw new Error(`Glitch users anon response ${res.status} not ok, body ${await res.text()}`); 523 | const user = await res.json(); 524 | 525 | await savePersistentToken(user.persistentToken); 526 | } 527 | 528 | async function doAuthSendEmail( 529 | /** @type {string | undefined} */ email, 530 | /** @type {{interactive: boolean}} */ opts, 531 | ) { 532 | if (opts.interactive) { 533 | await failIfPersistentTokenSaved(); 534 | } 535 | 536 | const emailPrompted = email || await promptTrimmed('Email'); 537 | const res = await fetch('https://api.glitch.com/v1/auth/email/', { 538 | method: 'POST', 539 | headers: { 540 | 'Content-Type': 'application/json', 541 | }, 542 | body: JSON.stringify({ 543 | emailAddress: emailPrompted, 544 | }), 545 | }); 546 | if (!res.ok) throw new Error(`Glitch auth email response ${res.status} not ok, body ${await res.text()}`); 547 | 548 | if (opts.interactive) { 549 | const codePrompted = await promptTrimmed('Code'); 550 | const codeRes = await fetch(`https://api.glitch.com/v1/auth/email/${codePrompted}`, { 551 | method: 'POST', 552 | }); 553 | if (!codeRes.ok) throw new Error(`Glitch auth email response ${codeRes.status} not ok, body ${await codeRes.text()}`); 554 | const body = await codeRes.json(); 555 | 556 | await savePersistentToken(body.user.persistentToken); 557 | } 558 | } 559 | 560 | async function doAuthCode(/** @type {string | undefined} */ code) { 561 | await failIfPersistentTokenSaved(); 562 | 563 | const codePrompted = code || await promptTrimmed('Code'); 564 | const res = await fetch(`https://api.glitch.com/v1/auth/email/${codePrompted}`, { 565 | method: 'POST', 566 | }); 567 | if (!res.ok) throw new Error(`Glitch auth email response ${res.status} not ok, body ${await res.text()}`); 568 | const body = await res.json(); 569 | 570 | await savePersistentToken(body.user.persistentToken); 571 | } 572 | 573 | async function doAuthPassword( 574 | /** @type {string | undefined} */ email, 575 | /** @type {string | undefined} */ password, 576 | ) { 577 | await failIfPersistentTokenSaved(); 578 | 579 | const emailPrompted = email || await promptTrimmed('Email'); 580 | const passwordPrompted = password || await promptPassword('Password'); 581 | const res = await fetch('https://api.glitch.com/v1/auth/password', { 582 | method: 'POST', 583 | headers: { 584 | 'Content-Type': 'application/json', 585 | }, 586 | body: JSON.stringify({ 587 | emailAddress: emailPrompted, 588 | password: passwordPrompted, 589 | }), 590 | }); 591 | if (!res.ok) throw new Error(`Glitch auth password response ${res.status} not ok, body ${await res.text()}`); 592 | const body = await res.json(); 593 | 594 | await savePersistentToken(body.user.persistentToken); 595 | } 596 | 597 | async function doWhoami(/** @type {{numeric?: boolean}} */ opts) { 598 | const {user} = await boot(); 599 | if (opts.numeric) { 600 | console.log('' + user.id); 601 | } else { 602 | if (!user.login) throw new Error('Logged in as anonymous user. Pass -n to get ID'); 603 | console.log(user.login); 604 | } 605 | } 606 | 607 | async function doLogout() { 608 | const persistentTokenPath = getPersistentTokenPath(); 609 | try { 610 | await fs.promises.unlink(persistentTokenPath); 611 | } catch (/** @type {any} */ e) { 612 | if (e.code !== 'ENOENT') throw e; 613 | } 614 | } 615 | 616 | async function doRemote(/** @type {{project?: string}} */ opts) { 617 | const projectDomain = getProjectDomainFromOpts(opts); 618 | if (!projectDomain) throw new Error('Unable to determine which project. Specify (-p)'); 619 | const {user} = await boot(); 620 | const url = `https://${user.gitAccessToken}@api.glitch.com/git/${projectDomain}`; 621 | await util.promisify(childProcess.execFile)('git', ['remote', 'add', REMOTE_NAME, url]); 622 | } 623 | 624 | async function doSetenv( 625 | /** @type {string} */ name, 626 | /** @type {string} */ value, 627 | /** @type {{project?: string}} */ opts, 628 | ) { 629 | const env = {[name]: value}; 630 | const res = await fetch(`https://api.glitch.com/projects/${await getProjectDomain(opts)}/setenv`, { 631 | method: 'POST', 632 | headers: { 633 | 'Authorization': await getPersistentToken(), 634 | 'Content-Type': 'application/json', 635 | }, 636 | body: JSON.stringify({env}), 637 | }); 638 | if (!res.ok) throw new Error(`Glitch v0 projects setenv response ${res.status} not ok, body ${await res.text()}`); 639 | } 640 | 641 | async function doExec(/** @type {string[]} */ command, /** @type {{project?: string}} */ opts) { 642 | const projectDomain = await getProjectDomain(opts); 643 | const project = await getProjectByDomain(projectDomain); 644 | const res = await fetch(`https://api.glitch.com/projects/${project.id}/exec`, { 645 | method: 'POST', 646 | headers: { 647 | 'Authorization': await getPersistentToken(), 648 | 'Content-Type': 'application/json', 649 | }, 650 | body: JSON.stringify({ 651 | command: command.join(' '), 652 | }), 653 | }); 654 | if (res.ok) { 655 | const body = await res.json(); 656 | process.stderr.write(body.stderr); 657 | process.stdout.write(body.stdout); 658 | } else if (res.status === 500) { 659 | const body = await res.json(); 660 | process.stderr.write(body.stderr); 661 | process.stdout.write(body.stdout); 662 | process.exitCode = body.signal || body.code; 663 | } else { 664 | throw new Error(`Glitch v0 projects exec response ${res.status} not ok, body ${await res.text()}`); 665 | } 666 | } 667 | 668 | async function doTerm( 669 | /** @type {string[]} */ command, 670 | /** @type {{project?: string, setTransports?: boolean, raw?: boolean}} */ opts, 671 | ) { 672 | const io = require('socket.io-client'); 673 | 674 | const projectDomain = await getProjectDomain(opts); 675 | const project = await getProjectByDomain(projectDomain); 676 | const res = await fetch(`https://api.glitch.com/v1/projects/${project.id}/singlePurposeTokens/terminal`, { 677 | method: 'POST', 678 | headers: { 679 | 'Authorization': await getPersistentToken(), 680 | }, 681 | }); 682 | if (!res.ok) throw new Error(`Glitch projects single purpose tokens terminal response ${res.status} not ok, body ${await res.text()}`); 683 | const body = await res.json(); 684 | 685 | let done = false; 686 | const ioOpts = /** @type {SocketIOClient.ConnectOpts} */ ({ 687 | path: `/console/${body.token}/socket.io`, 688 | }); 689 | if (opts.setTransports) { 690 | ioOpts.transports = ['websocket']; 691 | } 692 | const socket = io('https://api.glitch.com', ioOpts); 693 | 694 | function handleResize() { 695 | socket.emit('resize', { 696 | cols: process.stdout.columns, 697 | rows: process.stdout.rows, 698 | }); 699 | } 700 | 701 | socket.once('disconnect', (/** @type {string} */ reason) => { 702 | if (!done || reason !== 'io client disconnect') { 703 | console.error(`Glitch console disconnected: ${reason}`); 704 | process.exit(1); 705 | } 706 | }); 707 | socket.on('error', (/** @type {any} */ e) => { 708 | console.error(e); 709 | }); 710 | socket.once('login', () => { 711 | if (process.stdin.isTTY && opts.raw) { 712 | process.stdin.setRawMode(true); 713 | } 714 | handleResize(); 715 | if (command.length) { 716 | socket.emit('input', command.join(' ') + '\n'); 717 | } 718 | process.stdout.on('resize', handleResize); 719 | process.stdin.on('data', (data) => { 720 | socket.emit('input', data); 721 | }); 722 | }); 723 | socket.once('logout', () => { 724 | done = true; 725 | process.stdin.pause(); 726 | socket.close(); 727 | }); 728 | socket.on('data', (/** @type {string} */ data) => { 729 | process.stdout.write(data); 730 | }); 731 | } 732 | 733 | async function doPipe( 734 | /** @type {string[]} */ command, 735 | /** @type {{project?: string, setTransports?: boolean, debug?: boolean}} */ opts, 736 | ) { 737 | const io = require('socket.io-client'); 738 | 739 | // see pipe-wrap.js 740 | const WRAPPER_SRC = 'var t="base64",e="data",{stdin:o,stdout:r,argv:[,s]}=process,i=null,n=t=>{i&&(clearTimeout(i),i=null),r.write(t+"\\n")},a=require("child_process").spawn(Buffer.from(s,t).toString("utf8"),{stdio:"pipe",shell:!0}),d="";o.setRawMode(!0),o.setEncoding("ascii"),o.on(e,(e=>{i||(i=setTimeout((()=>{n(")p")}),4e3));var o=(d+e).split("\\n");for(var r of(d=o.pop(),o))r?a.stdin.write(Buffer.from(r,t)):a.stdin.end()})),n(")s"),a.stdout.on(e,(e=>{n(")o"+e.toString(t))})),a.stderr.on(e,(e=>{n(")e"+e.toString(t))})),a.on("exit",((t,e)=>{n(")r"+(e?1:t)),o.pause()}));'; 741 | 742 | let started = false; 743 | let returned = false; 744 | let recvBuf = ''; 745 | 746 | const projectDomain = await getProjectDomain(opts); 747 | const project = await getProjectByDomain(projectDomain); 748 | const res = await fetch(`https://api.glitch.com/v1/projects/${project.id}/singlePurposeTokens/terminal`, { 749 | method: 'POST', 750 | headers: { 751 | 'Authorization': await getPersistentToken(), 752 | }, 753 | }); 754 | if (!res.ok) throw new Error(`Glitch projects single purpose tokens terminal response ${res.status} not ok, body ${await res.text()}`); 755 | const body = await res.json(); 756 | 757 | let done = false; 758 | const ioOpts = /** @type {SocketIOClient.ConnectOpts} */ ({ 759 | path: `/console/${body.token}/socket.io`, 760 | }); 761 | if (opts.setTransports) { 762 | ioOpts.transports = ['websocket']; 763 | } 764 | const socket = io('https://api.glitch.com', ioOpts); 765 | 766 | socket.once('disconnect', (/** @type {string} */ reason) => { 767 | if (!done || reason !== 'io client disconnect') { 768 | console.error(`Glitch console disconnected: ${reason}`); 769 | process.exit(1); 770 | } 771 | }); 772 | socket.on('error', (/** @type {any} */ e) => { 773 | console.error(e); 774 | }); 775 | socket.on('data', (/** @type {string} */ data) => { 776 | const parts = (recvBuf + data).split('\n'); 777 | recvBuf = /** @type {string} */ (parts.pop()); 778 | for (const part of parts) { 779 | if (part[0] === ')') { 780 | switch (part[1]) { 781 | case 's': 782 | if (started) continue; 783 | started = true; 784 | process.stdin.on('data', (chunk) => { 785 | socket.emit('input', chunk.toString('base64') + '\n'); 786 | }); 787 | process.stdin.on('end', () => { 788 | socket.emit('input', '\n'); 789 | }); 790 | continue; 791 | case 'p': 792 | continue; 793 | case 'o': 794 | process.stdout.write(Buffer.from(part.slice(2), 'base64')); 795 | continue; 796 | case 'e': 797 | process.stderr.write(Buffer.from(part.slice(2), 'base64')); 798 | continue; 799 | case 'r': 800 | if (returned) continue; 801 | returned = true; 802 | process.stdin.pause(); 803 | process.exitCode = +part.slice(2); 804 | continue; 805 | } 806 | } 807 | if (opts.debug) { 808 | console.error(part); 809 | } 810 | } 811 | }); 812 | socket.once('login', () => { 813 | socket.emit('input', `unset HISTFILE && exec /opt/nvm/versions/node/v10/bin/node -e ${shellWord(WRAPPER_SRC)} ${shellWord(Buffer.from(command.join(' '), 'utf8').toString('base64'))}\n`); 814 | }); 815 | socket.once('logout', () => { 816 | done = true; 817 | socket.close(); 818 | if (!returned) { 819 | console.error('Received console logout without receiving return message'); 820 | process.exitCode = 1; 821 | } 822 | }); 823 | } 824 | 825 | async function doRsync(/** @type {string[]} */ args) { 826 | const rsyncArgs = ['-e', 'snail rsync-rsh --', ...args]; 827 | const c = childProcess.spawn('rsync', rsyncArgs, { 828 | stdio: 'inherit', 829 | }); 830 | c.on('exit', (code, signal) => { 831 | process.exitCode = signal ? 1 : /** @type {number} */ (code); 832 | }); 833 | } 834 | 835 | async function doRsyncRsh(/** @type {string} */ host, /** @type {string[]} */ args) { 836 | await commander.program.parseAsync(['pipe', '-p', host, '--', ...args], {from: 'user'}); 837 | } 838 | 839 | const SSHD_CONFIG = `AcceptEnv LANG LC_* 840 | AuthenticationMethods publickey 841 | ChallengeResponseAuthentication no 842 | HostKey /app/.data/.snail/ssh/ssh_host_rsa_key 843 | HostKey /app/.data/.snail/ssh/ssh_host_dsa_key 844 | HostKey /app/.data/.snail/ssh/ssh_host_ecdsa_key 845 | HostKey /app/.data/.snail/ssh/ssh_host_ed25519_key 846 | PrintMotd no 847 | Subsystem sftp /tmp/.snail/unpack/usr/lib/openssh/sftp-server 848 | UsePrivilegeSeparation no 849 | X11Forwarding yes 850 | `; 851 | 852 | const SCRIPT_INSTALL_SSHD = `mkdir -p /tmp/.snail 853 | 854 | if [ ! -e /tmp/.snail/stamp-sshd-unpack ]; then 855 | mkdir -p /tmp/.snail/unpack 856 | if [ ! -e /tmp/.snail/stamp-sshd-download ]; then 857 | printf "Downloading debs\\n" >&2 858 | mkdir -p /tmp/.snail/deb 859 | ( 860 | cd /tmp/.snail/deb 861 | apt-get download openssh-server openssh-sftp-server >&2 862 | ) 863 | fi 864 | printf "Unpacking debs\\n" >&2 865 | dpkg -x /tmp/.snail/deb/openssh-sftp-server_*.deb /tmp/.snail/unpack 866 | dpkg -x /tmp/.snail/deb/openssh-server_*.deb /tmp/.snail/unpack 867 | touch /tmp/.snail/stamp-sshd-unpack 868 | fi`; 869 | 870 | const SCRIPT_GEN_HOST_KEY = `if [ ! -e /app/.data/.snail/stamp-sshd-key ]; then 871 | printf "Generating host keys\\n" >&2 872 | mkdir -p /app/.data/.snail/ssh 873 | ssh-keygen -q -f /app/.data/.snail/ssh/ssh_host_rsa_key -N "" -t rsa >&2 874 | ssh-keygen -l -f /app/.data/.snail/ssh/ssh_host_rsa_key >&2 875 | ssh-keygen -q -f /app/.data/.snail/ssh/ssh_host_dsa_key -N "" -t dsa >&2 876 | ssh-keygen -l -f /app/.data/.snail/ssh/ssh_host_dsa_key >&2 877 | ssh-keygen -q -f /app/.data/.snail/ssh/ssh_host_ecdsa_key -N "" -t ecdsa >&2 878 | ssh-keygen -l -f /app/.data/.snail/ssh/ssh_host_ecdsa_key >&2 879 | ssh-keygen -q -f /app/.data/.snail/ssh/ssh_host_ed25519_key -N "" -t ed25519 >&2 880 | ssh-keygen -l -f /app/.data/.snail/ssh/ssh_host_ed25519_key >&2 881 | touch /app/.data/.snail/stamp-sshd-key 882 | fi`; 883 | 884 | const SCRIPT_CREATE_SSHD_CONFIG = `if [ ! -e /tmp/.snail/stamp-sshd-config ]; then 885 | printf "Creating config\\n" >&2 886 | mkdir -p /tmp/.snail/ssh 887 | cat >/tmp/.snail/ssh/sshd_config <>/app/.ssh/authorized_keys < { 1006 | setInterval(() => { 1007 | ws.send('keep alive'); 1008 | }, 30000); 1009 | }); 1010 | ws.on('message', (/** @type {string} */ data) => { 1011 | const msg = JSON.parse(data); 1012 | if (opts.all) { 1013 | console.log(msg); 1014 | } else { 1015 | if (msg.process !== 'signal') { 1016 | console.log(msg.text); 1017 | } 1018 | } 1019 | }); 1020 | ws.on('error', (e) => { 1021 | console.error(e); 1022 | }); 1023 | ws.on('close', (code, reason) => { 1024 | console.error(`Glitch logs closed: ${code} ${reason}`); 1025 | process.exit(1); 1026 | }); 1027 | } 1028 | 1029 | async function doStop(/** @type {{project?: string}} */ opts) { 1030 | const projectDomain = await getProjectDomain(opts); 1031 | const project = await getProjectByDomain(projectDomain); 1032 | const res = await fetch(`https://api.glitch.com/v1/projects/${project.id}/stop`, { 1033 | method: 'POST', 1034 | headers: { 1035 | 'Authorization': await getPersistentToken(), 1036 | }, 1037 | }); 1038 | if (!res.ok) throw new Error(`Glitch projects stop response ${res.status} not ok, body ${await res.text()}`); 1039 | } 1040 | 1041 | async function doDownload(/** @type {{project?: string, output?: string}} */ opts) { 1042 | const projectDomain = await getProjectDomain(opts); 1043 | const project = await getProjectByDomain(projectDomain); 1044 | const res = await fetch(`https://api.glitch.com/project/download/?authorization=${await getPersistentToken()}&projectId=${project.id}`); 1045 | if (!res.ok) throw new Error(`Glitch project download response ${res.status} not ok, body ${await res.text()}`); 1046 | let dstStream; 1047 | if (opts.output === '-') { 1048 | dstStream = process.stdout; 1049 | } else { 1050 | let dst; 1051 | if (opts.output) { 1052 | dst = opts.output; 1053 | } else { 1054 | dst = /** @type {RegExpExecArray} */ (/attachment; filename=([\w-]+\.tgz)/.exec(/** @type {string} */ (res.headers.get('Content-Disposition'))))[1]; 1055 | } 1056 | dstStream = fs.createWriteStream(dst); 1057 | } 1058 | res.body.pipe(dstStream); 1059 | } 1060 | 1061 | async function doAPolicy(/** @type {{project?: string, type: string}} */ opts) { 1062 | const projectDomain = await getProjectDomain(opts); 1063 | const project = await getProjectByDomain(projectDomain); 1064 | const res = await fetch(`https://api.glitch.com/v1/projects/${project.id}/policy?contentType=${encodeURIComponent(opts.type)}`, { 1065 | headers: { 1066 | 'Authorization': await getPersistentToken(), 1067 | 'Origin': 'https://glitch.com', 1068 | }, 1069 | }); 1070 | if (!res.ok) throw new Error(`Glitch projects policy response ${res.status} not ok, body ${await res.text()}`); 1071 | const body = await res.json(); 1072 | console.log(JSON.stringify(body)); 1073 | } 1074 | 1075 | async function doAPush( 1076 | /** @type {string} */ src, 1077 | /** @type {{project?: string, name?: string, type: string, maxAge: string}} */ opts, 1078 | ) { 1079 | const FormData = require('form-data'); 1080 | 1081 | const srcSize = (await fs.promises.stat(src)).size; 1082 | const srcStream = fs.createReadStream(src); 1083 | 1084 | const projectDomain = await getProjectDomain(opts); 1085 | const project = await getProjectByDomain(projectDomain); 1086 | const policyRes = await fetch(`https://api.glitch.com/v1/projects/${project.id}/policy?contentType=${encodeURIComponent(opts.type)}`, { 1087 | headers: { 1088 | 'Authorization': await getPersistentToken(), 1089 | 'Origin': 'https://glitch.com', 1090 | }, 1091 | }); 1092 | if (!policyRes.ok) throw new Error(`Glitch projects policy response ${policyRes.status} not ok, body ${await policyRes.text()}`); 1093 | const body = await policyRes.json(); 1094 | const policy = JSON.parse(Buffer.from(body.policy, 'base64').toString('utf8')); 1095 | let bucket, keyPrefix, acl; 1096 | for (const condition of policy.conditions) { 1097 | if (condition instanceof Array) { 1098 | if (condition[1] === '$key' && condition[0] === 'starts-with') keyPrefix = condition[2]; 1099 | } else { 1100 | if ('bucket' in condition) bucket = condition.bucket; 1101 | if ('acl' in condition) acl = condition.acl; 1102 | } 1103 | } 1104 | const key = opts.name || path.basename(src); 1105 | const awsKey = keyPrefix + key; 1106 | const form = new FormData(); 1107 | form.append('key', awsKey); 1108 | form.append('Content-Type', opts.type); 1109 | form.append('Cache-Control', `max-age=${opts.maxAge}`); 1110 | form.append('AWSAccessKeyId', body.accessKeyId); 1111 | form.append('acl', acl); 1112 | form.append('policy', body.policy); 1113 | form.append('signature', body.signature); 1114 | form.append('file', srcStream, {knownLength: srcSize}); 1115 | // node-fetch is variously annoying about how it sends FormData 1116 | // https://github.com/node-fetch/node-fetch/pull/1020 1117 | const uploadRes = await util.promisify(form.submit).call(form, `https://s3.amazonaws.com/${bucket}`); 1118 | if (/** @type {number} */ (uploadRes.statusCode) < 200 || /** @type {number} */ (uploadRes.statusCode) >= 300) throw new Error(`S3 upload response ${uploadRes.statusCode} not ok, body ${await readResponseString(uploadRes)}`); 1119 | // empirically, 20MiB works, (20Mi + 1)B gives 503 on cdn.glitch.global 1120 | const cdnHost = srcSize > (20 * 1024 * 1024) ? 'cdn.glitch.me' : 'cdn.glitch.global'; 1121 | console.log(`https://${cdnHost}/${keyPrefix}${encodeURIComponent(key)}?v=${Date.now()}`); 1122 | } 1123 | 1124 | async function doACp( 1125 | /** @type {string} */ src, 1126 | /** @type {string} */ dst, 1127 | /** @type {{project?: string}} */ opts, 1128 | ) { 1129 | const projectDomain = await getProjectDomain(opts); 1130 | const project = await getProjectByDomain(projectDomain); 1131 | // don't want protocol, host, ?v= thingy, etc. 1132 | let sourceKey = new URL(src, 'https://cdn.glitch.global/').pathname; 1133 | // key doesn't have leading slash 1134 | sourceKey = sourceKey.replace(/^\//, ''); 1135 | // for convenience, allow shorthand for assets in current project 1136 | if (!/\/|^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}%2F/i.test(sourceKey)) { 1137 | sourceKey = `${project.id}/${sourceKey}`; 1138 | } 1139 | const res = await fetch(`https://api.glitch.com/v1/projects/${project.id}/asset/${encodeURIComponent(dst)}`, { 1140 | method: 'PUT', 1141 | headers: { 1142 | 'Authorization': await getPersistentToken(), 1143 | 'Content-Type': 'application/json', 1144 | }, 1145 | body: JSON.stringify({ 1146 | sourceKey, 1147 | }), 1148 | }); 1149 | if (!res.ok) throw new Error(`Glitch projects asset response ${res.status} not ok, body ${await res.text()}`); 1150 | console.log(`https://cdn.glitch.global/${project.id}/${encodeURIComponent(dst)}?v=${Date.now()}`); 1151 | } 1152 | 1153 | async function doOtPush( 1154 | /** @type {string} */ src, 1155 | /** @type {string} */ dst, 1156 | /** @type {{project?: string, debug?: boolean}} */ opts, 1157 | ) { 1158 | const WebSocket = require('ws'); 1159 | 1160 | const projectDomain = await getProjectDomain(opts); 1161 | const project = await getProjectByDomain(projectDomain); 1162 | 1163 | const dstNames = dst.split('/'); 1164 | let /** @type {string | null} */ srcBasename; 1165 | let /** @type {string} */ content; 1166 | if (src === '-') { 1167 | srcBasename = null; 1168 | content = await new Promise((resolve, reject) => { 1169 | let buf = ''; 1170 | process.stdin.setEncoding('utf8'); 1171 | process.stdin.on('data', (chunk) => { 1172 | buf += chunk; 1173 | }); 1174 | process.stdin.on('end', () => { 1175 | resolve(buf); 1176 | }); 1177 | }); 1178 | } else { 1179 | srcBasename = path.basename(src); 1180 | content = await fs.promises.readFile(src, 'utf8'); 1181 | } 1182 | 1183 | let done = false; 1184 | const ws = new WebSocket(`wss://api.glitch.com/${project.id}/ot?authorization=${await getPersistentToken()}`); 1185 | const c = otClientAttach(ws, opts); 1186 | ws.on('error', (e) => { 1187 | console.error(e); 1188 | }); 1189 | ws.on('close', (code, reason) => { 1190 | if (!done || code !== 1000) { 1191 | console.error(`Glitch OT closed: ${code} ${reason}`); 1192 | process.exit(1); 1193 | } 1194 | }); 1195 | ws.on('open', () => { 1196 | if (opts.debug) { 1197 | console.error('* open'); 1198 | } 1199 | (async () => { 1200 | try { 1201 | const ops = /** @type {OtOp[]} */ ([]); 1202 | const dstAccess = await otClientResolveOrCreateParents(c, ops, dstNames, srcBasename); 1203 | if ('existing' in dstAccess) { 1204 | otRequireNotDir(dstAccess.existing); 1205 | if ('base64Content' in dstAccess.existing) { 1206 | ops.push({ 1207 | type: 'unlink', 1208 | docId: dstAccess.existing.docId, 1209 | }); 1210 | const doc = { 1211 | name: dstAccess.existing.name, 1212 | docId: otNewId(), 1213 | docType: 'file', 1214 | parentId: dstAccess.existing.parentId, 1215 | }; 1216 | ops.push({type: 'add', ...doc}); 1217 | ops.push({ 1218 | type: 'insert', 1219 | docId: doc.docId, 1220 | position: 0, 1221 | text: content, 1222 | }); 1223 | } else { 1224 | ops.push({ 1225 | type: 'remove', 1226 | docId: dstAccess.existing.docId, 1227 | position: 0, 1228 | text: dstAccess.existing.content, 1229 | }); 1230 | ops.push({ 1231 | type: 'insert', 1232 | docId: dstAccess.existing.docId, 1233 | position: 0, 1234 | text: content, 1235 | }); 1236 | } 1237 | } else { 1238 | if (!dstAccess.name) throw new Error('Need explicit dst filename to push from stdin'); 1239 | const doc = { 1240 | name: dstAccess.name, 1241 | docId: otNewId(), 1242 | docType: 'file', 1243 | parentId: dstAccess.parent.docId, 1244 | }; 1245 | ops.push({type: 'add', ...doc}); 1246 | ops.push({ 1247 | type: 'insert', 1248 | docId: doc.docId, 1249 | position: 0, 1250 | text: content, 1251 | }); 1252 | } 1253 | 1254 | await otClientFetchMaster(c); 1255 | await otClientBroadcastOps(c, ops); 1256 | 1257 | done = true; 1258 | ws.close(); 1259 | } catch (e) { 1260 | console.error(e); 1261 | process.exit(1); 1262 | } 1263 | })(); 1264 | }); 1265 | } 1266 | 1267 | async function doOtPull( 1268 | /** @type {string} */ src, 1269 | /** @type {string} */ dst, 1270 | /** @type {{project?: string, debug?: boolean}} */ opts, 1271 | ) { 1272 | const WebSocket = require('ws'); 1273 | 1274 | const projectDomain = await getProjectDomain(opts); 1275 | const project = await getProjectByDomain(projectDomain); 1276 | 1277 | const srcNames = src.split('/'); 1278 | const dstInfo = dst === '-' ? {stdout: true} : {file: await guessSingleDestination(dst, srcNames[srcNames.length - 1])}; 1279 | 1280 | let done = false; 1281 | const ws = new WebSocket(`wss://api.glitch.com/${project.id}/ot?authorization=${await getPersistentToken()}`); 1282 | const c = otClientAttach(ws, opts); 1283 | ws.on('error', (e) => { 1284 | console.error(e); 1285 | }); 1286 | ws.on('close', (code, reason) => { 1287 | if (!done || code !== 1000) { 1288 | console.error(`Glitch OT closed: ${code} ${reason}`); 1289 | process.exit(1); 1290 | } 1291 | }); 1292 | ws.on('open', () => { 1293 | if (opts.debug) { 1294 | console.error('* open'); 1295 | } 1296 | (async () => { 1297 | try { 1298 | const doc = await otClientResolveExisting(c, srcNames); 1299 | 1300 | done = true; 1301 | ws.close(); 1302 | 1303 | otRequireNotDir(doc); 1304 | if ('base64Content' in doc) { 1305 | if ('stdout' in dstInfo) { 1306 | process.stdout.write(doc.base64Content, 'base64'); 1307 | } else { 1308 | await fs.promises.writeFile(dstInfo.file, doc.base64Content, 'base64'); 1309 | } 1310 | } else { 1311 | if ('stdout' in dstInfo) { 1312 | process.stdout.write(doc.content); 1313 | } else { 1314 | await fs.promises.writeFile(dstInfo.file, doc.content); 1315 | } 1316 | } 1317 | } catch (e) { 1318 | console.error(e); 1319 | process.exit(1); 1320 | } 1321 | })(); 1322 | }); 1323 | } 1324 | 1325 | async function doOtMv( 1326 | /** @type {string} */ src, 1327 | /** @type {string} */ dst, 1328 | /** @type {{project?: string, debug?: boolean}} */ opts, 1329 | ) { 1330 | const WebSocket = require('ws'); 1331 | 1332 | const projectDomain = await getProjectDomain(opts); 1333 | const project = await getProjectByDomain(projectDomain); 1334 | 1335 | const srcNames = src.split('/'); 1336 | const srcBasename = path.posix.basename(src); 1337 | if (srcBasename === '' || srcBasename === '.' || srcBasename === '..') throw new Error('Invalid src basename'); 1338 | const dstNames = dst.split('/'); 1339 | 1340 | let done = false; 1341 | const ws = new WebSocket(`wss://api.glitch.com/${project.id}/ot?authorization=${await getPersistentToken()}`); 1342 | const c = otClientAttach(ws, opts); 1343 | ws.on('error', (e) => { 1344 | console.error(e); 1345 | }); 1346 | ws.on('close', (code, reason) => { 1347 | if (!done || code !== 1000) { 1348 | console.error(`Glitch OT closed: ${code} ${reason}`); 1349 | process.exit(1); 1350 | } 1351 | }); 1352 | ws.on('open', () => { 1353 | if (opts.debug) { 1354 | console.error('* open'); 1355 | } 1356 | (async () => { 1357 | try { 1358 | const doc = await otClientResolveExisting(c, srcNames); 1359 | 1360 | const ops = /** @type {OtOp[]} */ ([]); 1361 | const dstAccess = await otClientResolveOrCreateParents(c, ops, dstNames, srcBasename); 1362 | if ('existing' in dstAccess) { 1363 | otRequireNotDir(doc); 1364 | otRequireNotDir(dstAccess.existing); 1365 | ops.push({ 1366 | type: 'rename', 1367 | docId: doc.docId, 1368 | newName: dstAccess.existing.name, 1369 | newParentId: dstAccess.existing.parentId, 1370 | }); 1371 | } else { 1372 | ops.push({ 1373 | type: 'rename', 1374 | docId: doc.docId, 1375 | newName: /** @type {string} */ (dstAccess.name), 1376 | newParentId: dstAccess.parent.docId, 1377 | }); 1378 | } 1379 | 1380 | await otClientFetchMaster(c); 1381 | await otClientBroadcastOps(c, ops); 1382 | 1383 | done = true; 1384 | ws.close(); 1385 | } catch (e) { 1386 | console.error(e); 1387 | process.exit(1); 1388 | } 1389 | })(); 1390 | }); 1391 | } 1392 | 1393 | async function doOtRm( 1394 | /** @type {string} */ pathArg, 1395 | /** @type {{project?: string, debug?: boolean}} */ opts, 1396 | ) { 1397 | const WebSocket = require('ws'); 1398 | 1399 | const projectDomain = await getProjectDomain(opts); 1400 | const project = await getProjectByDomain(projectDomain); 1401 | 1402 | const names = pathArg.split('/'); 1403 | 1404 | let done = false; 1405 | const ws = new WebSocket(`wss://api.glitch.com/${project.id}/ot?authorization=${await getPersistentToken()}`); 1406 | const c = otClientAttach(ws, opts); 1407 | ws.on('error', (e) => { 1408 | console.error(e); 1409 | }); 1410 | ws.on('close', (code, reason) => { 1411 | if (!done || code !== 1000) { 1412 | console.error(`Glitch OT closed: ${code} ${reason}`); 1413 | process.exit(1); 1414 | } 1415 | }); 1416 | ws.on('open', () => { 1417 | if (opts.debug) { 1418 | console.error('* open'); 1419 | } 1420 | (async () => { 1421 | try { 1422 | const doc = await otClientResolveExisting(c, names); 1423 | 1424 | const ops = /** @type {OtOp[]} */ ([]); 1425 | ops.push({ 1426 | type: 'unlink', 1427 | docId: doc.docId, 1428 | }); 1429 | 1430 | await otClientFetchMaster(c); 1431 | await otClientBroadcastOps(c, ops); 1432 | 1433 | done = true; 1434 | ws.close(); 1435 | } catch (e) { 1436 | console.error(e); 1437 | process.exit(1); 1438 | } 1439 | })(); 1440 | }); 1441 | } 1442 | 1443 | async function doOtLs( 1444 | /** @type {string} */ pathArg, 1445 | /** @type {{project?: string, debug?: boolean}} */ opts, 1446 | ) { 1447 | const WebSocket = require('ws'); 1448 | 1449 | const projectDomain = await getProjectDomain(opts); 1450 | const project = await getProjectByDomain(projectDomain); 1451 | 1452 | const names = pathArg.split('/'); 1453 | 1454 | let done = false; 1455 | const ws = new WebSocket(`wss://api.glitch.com/${project.id}/ot?authorization=${await getPersistentToken()}`); 1456 | const c = otClientAttach(ws, opts); 1457 | ws.on('error', (e) => { 1458 | console.error(e); 1459 | }); 1460 | ws.on('close', (code, reason) => { 1461 | if (!done || code !== 1000) { 1462 | console.error(`Glitch OT closed: ${code} ${reason}`); 1463 | process.exit(1); 1464 | } 1465 | }); 1466 | ws.on('open', () => { 1467 | if (opts.debug) { 1468 | console.error('* open'); 1469 | } 1470 | (async () => { 1471 | try { 1472 | const doc = await otClientResolveExisting(c, names); 1473 | 1474 | done = true; 1475 | ws.close(); 1476 | 1477 | otRequireDir(doc); 1478 | for (const name in doc.children) { 1479 | console.log(name); 1480 | } 1481 | } catch (e) { 1482 | console.error(e); 1483 | process.exit(1); 1484 | } 1485 | })(); 1486 | }); 1487 | } 1488 | 1489 | async function doOtRequestJoin( 1490 | /** @type {{project?: string, debug?: boolean, randomName?: boolean}} */ opts, 1491 | ) { 1492 | const WebSocket = require('ws'); 1493 | 1494 | const projectDomain = await getProjectDomain(opts); 1495 | const project = await getProjectByDomain(projectDomain); 1496 | const {user} = await boot(); 1497 | 1498 | for (const permission of project.permissions) { 1499 | if (permission.userId === user.id) { 1500 | console.error(`Already have permission at access level ${permission.accessLevel}`); 1501 | return; 1502 | } 1503 | } 1504 | 1505 | const nagUser = { 1506 | avatarUrl: user.avatarUrl, 1507 | avatarThumbnailUrl: user.avatarThumbnailUrl, 1508 | awaitingInvite: true, 1509 | id: user.id, 1510 | name: user.name, 1511 | login: user.login, 1512 | color: user.color, 1513 | // I personally find it weird that Glitch relies on what we report 1514 | // ourselves, to the point where it crashes if this is absent. Snail is 1515 | // honest here. 1516 | projectPermission: { 1517 | userId: user.id, 1518 | projectId: project.id, 1519 | accessLevel: 0, 1520 | }, 1521 | }; 1522 | if (opts.randomName) { 1523 | if (!nagUser.avatarUrl) { 1524 | nagUser.avatarUrl = 'https://snail-cli.glitch.me/join.svg'; 1525 | } 1526 | nagUser.name = `snail-${crypto.randomBytes(2).toString('hex')}`; 1527 | nagUser.color = '#c00040'; 1528 | } 1529 | 1530 | let done = false; 1531 | const ws = new WebSocket(`wss://api.glitch.com/${project.id}/ot?authorization=${user.persistentToken}`); 1532 | const c = otClientAttach(ws, opts); 1533 | 1534 | let nagTimer = null; 1535 | function nag() { 1536 | otClientSend(c, { 1537 | type: 'broadcast', 1538 | payload: { 1539 | user: nagUser, 1540 | }, 1541 | }); 1542 | } 1543 | 1544 | let /** @type {Resolvers} */ inviteRequested = /** @type {never} */ (null); 1545 | const invitePromised = new Promise((resolve, reject) => { 1546 | inviteRequested = {resolve, reject}; 1547 | }); 1548 | c.onmessage = (msg) => { 1549 | if (msg.type === 'broadcast' && 'user' in msg.payload) { 1550 | if (msg.payload.user.id === user.id && msg.payload.user.invited) { 1551 | // And yet here we are doing the same thing, trusting the broadcast 1552 | // about our invite. 1553 | const {resolve} = inviteRequested; 1554 | inviteRequested = /** @type {never} */ (null); 1555 | resolve(msg.payload.user); 1556 | } 1557 | } 1558 | }; 1559 | 1560 | ws.on('error', (e) => { 1561 | console.error(e); 1562 | }); 1563 | ws.on('close', (code, reason) => { 1564 | if (!done || code !== 1000) { 1565 | console.error(`Glitch OT closed: ${code} ${reason}`); 1566 | process.exit(1); 1567 | } 1568 | }); 1569 | ws.on('open', () => { 1570 | if (opts.debug) { 1571 | console.error('* open'); 1572 | } 1573 | (async () => { 1574 | try { 1575 | await otClientFetchMaster(c); 1576 | 1577 | console.error(`Requesting to join as ${nagUser.name || nagUser.login || 'Anonymous'}`); 1578 | nag(); 1579 | nagTimer = setInterval(nag, 10000); 1580 | 1581 | const invitedUser = await invitePromised; 1582 | 1583 | clearInterval(nagTimer); 1584 | otClientSend(c, { 1585 | type: 'broadcast', 1586 | payload: { 1587 | user: { 1588 | avatarUrl: user.avatarUrl, 1589 | avatarThumbnailUrl: user.avatarThumbnailUrl, 1590 | awaitingInvite: false, 1591 | id: user.id, 1592 | // I'd like to reset the name and color to the real ones in the 1593 | // `--random-name` case, but Glitch doesn't treat these fields 1594 | // as observable, so they don't actually update in the editor. 1595 | name: user.name, 1596 | login: user.login, 1597 | color: user.color, 1598 | invited: true, 1599 | projectPermission: { 1600 | userId: user.id, 1601 | projectId: project.id, 1602 | accessLevel: invitedUser.projectPermission.accessLevel, 1603 | }, 1604 | }, 1605 | }, 1606 | }); 1607 | 1608 | done = true; 1609 | ws.close(); 1610 | } catch (e) { 1611 | console.error(e); 1612 | process.exit(1); 1613 | } 1614 | })(); 1615 | }); 1616 | } 1617 | 1618 | async function doOtStatus(/** @type {{project?: string, debug?: boolean}} */ opts) { 1619 | const WebSocket = require('ws'); 1620 | 1621 | const projectDomain = await getProjectDomain(opts); 1622 | const project = await getProjectByDomain(projectDomain); 1623 | 1624 | const LINES_PER_HEADER = 23; 1625 | let linesUntilHeader = 0; 1626 | const ws = new WebSocket(`wss://api.glitch.com/${project.id}/ot?authorization=${await getPersistentToken()}`); 1627 | const c = otClientAttach(ws, opts); 1628 | c.onmessage = (msg) => { 1629 | if (msg.type === 'broadcast' && msg.payload.type === 'project-stats') { 1630 | if (!linesUntilHeader) { 1631 | console.log(' Time Memory Disk CPU'); 1632 | linesUntilHeader = LINES_PER_HEADER; 1633 | } 1634 | 1635 | const p = msg.payload; 1636 | const timeD = new Date(p.timeNs / 1000000); 1637 | const timeCol = timeD.toLocaleTimeString().padStart(11); 1638 | const memoryP = (p.memoryUsage / p.memoryLimit * 100).toFixed(0).padStart(3); 1639 | const memoryUsageM = (p.memoryUsage / (1 << 20)).toFixed(0).padStart(3); 1640 | const memoryLimitM = (p.memoryLimit / (1 << 20)).toFixed(0).padStart(3); 1641 | const diskP = (p.diskUsage / p.diskSize * 100).toFixed(0).padStart(3); 1642 | const diskUsageM = (p.diskUsage / (1 << 20)).toFixed(0).padStart(3); 1643 | const diskSizeM = (p.diskSize / (1 << 20)).toFixed(0).padStart(3); 1644 | const cpuP = ((p.quotaUsagePercent || 0) * 100).toFixed(0).padStart(3); 1645 | console.log(`${timeCol} ${memoryP}% ${memoryUsageM}MB / ${memoryLimitM}MB ${diskP}% ${diskUsageM}MB / ${diskSizeM}MB ${cpuP}%`); 1646 | 1647 | linesUntilHeader--; 1648 | } 1649 | }; 1650 | 1651 | ws.on('error', (e) => { 1652 | console.error(e); 1653 | }); 1654 | ws.on('close', (code, reason) => { 1655 | console.error(`Glitch OT closed: ${code} ${reason}`); 1656 | process.exit(1); 1657 | }); 1658 | ws.on('open', () => { 1659 | if (opts.debug) { 1660 | console.error('* open'); 1661 | } 1662 | (async () => { 1663 | try { 1664 | await otClientFetchMaster(c); 1665 | } catch (e) { 1666 | console.error(e); 1667 | process.exit(1); 1668 | } 1669 | })(); 1670 | }); 1671 | } 1672 | 1673 | async function doHours() { 1674 | const {user} = await boot(); 1675 | const persistentToken = await getPersistentToken(); 1676 | const uptimeRes = await fetch(`https://api.glitch.com/v1/users/${user.id}/uptime`, { 1677 | headers: { 1678 | 'Authorization': persistentToken, 1679 | }, 1680 | }); 1681 | if (!uptimeRes.ok) throw new Error(`Glitch users uptime response ${uptimeRes.status} not ok, body ${await uptimeRes.text()}`); 1682 | const uptimeBody = await uptimeRes.json(); 1683 | 1684 | if (!uptimeBody.accountInGoodStanding) { 1685 | console.log('Account not in good standing'); 1686 | } 1687 | console.log(`Total: ${uptimeBody.consumedHours.toFixed(2)} / ${uptimeBody.allowanceHours.toFixed(2)}`); 1688 | 1689 | const projectIds = Object.keys(uptimeBody.hoursByProject); 1690 | projectIds.sort((a, b) => uptimeBody.hoursByProject[a] - uptimeBody.hoursByProject[b]); 1691 | 1692 | console.log('Domain Hours'); 1693 | const LIMIT = 100; 1694 | for (let i = 0; i < projectIds.length; i += LIMIT) { 1695 | const batch = projectIds.slice(i, i + LIMIT); 1696 | const idParams = batch.map((id) => `id=${id}`).join('&'); 1697 | const projectsRes = await fetch(`https://api.glitch.com/v1/projects/by/id?${idParams}`, { 1698 | headers: { 1699 | 'Authorization': persistentToken, 1700 | }, 1701 | }); 1702 | if (!projectsRes.ok) throw new Error(`Glitch projects by ID response ${projectsRes.status} not ok, body ${await projectsRes.text()}`); 1703 | const projects = await projectsRes.json(); 1704 | for (const id of batch) { 1705 | let domain; 1706 | if (id in projects) { 1707 | domain = projects[id].domain; 1708 | } else { 1709 | domain = `(${id})`; 1710 | } 1711 | const domainCol = domain.padEnd(38); 1712 | const hoursCol = uptimeBody.hoursByProject[id].toFixed(2).padStart(7); 1713 | console.log(`${domainCol} ${hoursCol}`); 1714 | } 1715 | } 1716 | } 1717 | 1718 | async function doProjectCreate( 1719 | /** @type {string | undefined} */ domain, 1720 | /** @type {{remix?: string, remote?: boolean}} */ opts, 1721 | ) { 1722 | const fromDomain = opts.remix || 'glitch-hello-node'; 1723 | const reqBody = {}; 1724 | if (domain) { 1725 | reqBody.domain = domain; 1726 | } 1727 | const res = await fetch(`https://api.glitch.com/v1/projects/by/domain/${fromDomain}/remix`, { 1728 | method: 'POST', 1729 | headers: { 1730 | 'Authorization': await getPersistentToken(), 1731 | 'Content-Type': 'application/json', 1732 | }, 1733 | body: JSON.stringify(reqBody), 1734 | }); 1735 | if (!res.ok) throw new Error(`Glitch projects by domain remix response ${res.status} not ok, body ${await res.text()}`); 1736 | const project = await res.json(); 1737 | console.log(project.domain); 1738 | if (opts.remote) { 1739 | try { 1740 | const {user} = await boot(); 1741 | const url = `https://${user.gitAccessToken}@api.glitch.com/git/${project.domain}`; 1742 | await util.promisify(childProcess.execFile)('git', ['remote', 'add', REMOTE_NAME, url]); 1743 | } catch (e) { 1744 | console.error(e); 1745 | } 1746 | } 1747 | } 1748 | 1749 | async function doProjectInfo(/** @type {{project?: string}} */ opts) { 1750 | const projectDomain = await getProjectDomain(opts); 1751 | const project = await getProjectByDomain(projectDomain); 1752 | console.log(`\ 1753 | ID ${project.id} 1754 | Domain ${project.domain} 1755 | Description ${project.description} 1756 | Privacy ${project.privacy} 1757 | Application type ${project.appType} 1758 | Last edited ${new Date(project.updatedAt).toLocaleString()} 1759 | Created at ${new Date(project.createdAt).toLocaleString()}`); 1760 | } 1761 | 1762 | async function doProjectUpdate( 1763 | /** 1764 | * @type {{ 1765 | * project?: string, 1766 | * domain?: string, 1767 | * description?: string, 1768 | * private?: boolean, 1769 | * privacy?: string, 1770 | * }} 1771 | */ opts, 1772 | ) { 1773 | const projectDomain = await getProjectDomain(opts); 1774 | const project = await getProjectByDomain(projectDomain); 1775 | 1776 | let any = false; 1777 | const reqBody = {}; 1778 | if ('domain' in opts) { 1779 | any = true; 1780 | reqBody.domain = opts.domain; 1781 | } 1782 | if ('description' in opts) { 1783 | any = true; 1784 | reqBody.description = opts.description; 1785 | } 1786 | if ('private' in opts) { 1787 | any = true; 1788 | reqBody.private = opts.private; 1789 | } 1790 | if ('privacy' in opts) { 1791 | any = true; 1792 | reqBody.privacy = opts.privacy; 1793 | } 1794 | if (!any) return; 1795 | const res = await fetch(`https://api.glitch.com/v1/projects/${project.id}`, { 1796 | method: 'PATCH', 1797 | headers: { 1798 | 'Authorization': await getPersistentToken(), 1799 | 'Content-Type': 'application/json', 1800 | }, 1801 | body: JSON.stringify(reqBody), 1802 | }); 1803 | if (!res.ok) throw new Error(`Glitch projects patch response ${res.status} not ok, body ${await res.text()}`); 1804 | } 1805 | 1806 | async function doProjectDelete(/** @type {{project?: string}} */ opts) { 1807 | const projectDomain = await getProjectDomain(opts); 1808 | const project = await getProjectByDomain(projectDomain); 1809 | const res = await fetch(`https://api.glitch.com/v1/projects/${project.id}`, { 1810 | method: 'DELETE', 1811 | headers: { 1812 | 'Authorization': await getPersistentToken(), 1813 | }, 1814 | }); 1815 | if (!res.ok) throw new Error(`Glitch projects delete response ${res.status} not ok, body ${await res.text()}`); 1816 | } 1817 | 1818 | async function doProjectUndelete(/** @type {{project?: string}} */ opts) { 1819 | const projectDomain = await getProjectDomain(opts); 1820 | // is there a way to get a deleted project by domain in the v1 API? 1821 | // const {user} = await boot(); 1822 | // `https://api.glitch.com/v1/users/${user.id}/deletedProjects?limit=1&orderKey=domain&orderDirection=DESC&lastOrderValue=${projectDomain}%00` 1823 | const projectRes = await fetch(`https://api.glitch.com/projects/${projectDomain}?showDeleted=true`, { 1824 | headers: { 1825 | 'Authorization': await getPersistentToken(), 1826 | }, 1827 | }); 1828 | if (!projectRes.ok) throw new Error(`Glitch v0 projects response ${projectRes.status} not ok, body ${await projectRes.text()}`); 1829 | const project = await projectRes.json(); 1830 | const res = await fetch(`https://api.glitch.com/v1/projects/${project.id}/undelete`, { 1831 | method: 'POST', 1832 | headers: { 1833 | 'Authorization': await getPersistentToken(), 1834 | }, 1835 | }); 1836 | if (!res.ok) throw new Error(`Glitch projects undelete response ${res.status} not ok, body ${await res.text()}`); 1837 | } 1838 | 1839 | async function doProjectList(/** @type {{deleted?: boolean}} */ opts) { 1840 | const {user} = await boot(); 1841 | const persistentToken = await getPersistentToken(); 1842 | 1843 | console.log('Domain Privacy Application type Last edited Description'); 1844 | const LIMIT = 100; // dashboard uses 100 1845 | let pageParam = ''; 1846 | while (true) { 1847 | let body; 1848 | if (opts.deleted) { 1849 | const res = await fetch(`https://api.glitch.com/v1/users/${user.id}/deletedProjects?limit=${LIMIT}&orderKey=createdAt&orderDirection=ASC${pageParam}`, { 1850 | headers: { 1851 | 'Authorization': persistentToken, 1852 | }, 1853 | }); 1854 | if (!res.ok) throw new Error(`Glitch users deleted projects response ${res.status} not ok, body ${await res.text()}`); 1855 | body = await res.json(); 1856 | } else { 1857 | const res = await fetch(`https://api.glitch.com/v1/users/${user.id}/projects?limit=${LIMIT}&orderKey=createdAt&orderDirection=ASC${pageParam}`, { 1858 | headers: { 1859 | 'Authorization': persistentToken, 1860 | }, 1861 | }); 1862 | if (!res.ok) throw new Error(`Glitch users projects response ${res.status} not ok, body ${await res.text()}`); 1863 | body = await res.json(); 1864 | } 1865 | 1866 | for (const project of body.items) { 1867 | const domainCol = project.domain.padEnd(38); 1868 | const privacyCol = project.privacy.padEnd(15); 1869 | const typeCol = ('' + project.appType).padEnd(16); 1870 | const edited = project.permission && project.permission.userLastAccess; 1871 | const editedCol = (edited ? new Date(edited).toLocaleDateString() : '').padStart(11); 1872 | const descriptionCol = project.description.replace(/\n[\s\S]*/, '...'); 1873 | console.log(`${domainCol} ${privacyCol} ${typeCol} ${editedCol} ${descriptionCol}`); 1874 | } 1875 | 1876 | if (!body.hasMore) break; 1877 | pageParam = `&lastOrderValue=${encodeURIComponent(body.lastOrderValue)}`; 1878 | } 1879 | } 1880 | 1881 | async function doMemberAdd( 1882 | /** @type {string} */ login, 1883 | /** @type {{project?: string, numeric?: boolean}} */ opts, 1884 | ) { 1885 | const projectDomain = await getProjectDomain(opts); 1886 | const project = await getProjectByDomain(projectDomain); 1887 | const userId = opts.numeric ? +login : (await getUserByLogin(login)).id; 1888 | 1889 | const res = await fetch(`https://api.glitch.com/project_permissions/${project.id}`, { 1890 | method: 'POST', 1891 | headers: { 1892 | 'Authorization': await getPersistentToken(), 1893 | 'Content-Type': 'application/json', 1894 | }, 1895 | body: JSON.stringify({ 1896 | userId, 1897 | projectId: project.id, 1898 | accessLevel: ACCESS_LEVEL_MEMBER, 1899 | }), 1900 | }); 1901 | if (!res.ok) throw new Error(`Glitch v0 project permissions response ${res.status} not ok, body ${await res.text()}`); 1902 | } 1903 | 1904 | async function doMemberRm( 1905 | /** @type {string} */ login, 1906 | /** @type {{project?: string, numeric?: boolean}} */ opts, 1907 | ) { 1908 | const projectDomain = await getProjectDomain(opts); 1909 | const project = await getProjectByDomain(projectDomain); 1910 | const userId = opts.numeric ? +login : (await getUserByLogin(login)).id; 1911 | 1912 | const res = await fetch(`https://api.glitch.com/v1/projects/${project.id}/users/${userId}`, { 1913 | method: 'DELETE', 1914 | headers: { 1915 | 'Authorization': await getPersistentToken(), 1916 | }, 1917 | }); 1918 | if (!res.ok) throw new Error(`Glitch projects users delete response ${res.status} not ok, body ${await res.text()}`); 1919 | } 1920 | 1921 | async function doMemberLeave(/** @type {{project?: string}} */ opts) { 1922 | const projectDomain = await getProjectDomain(opts); 1923 | const project = await getProjectByDomain(projectDomain); 1924 | const {user} = await boot(); 1925 | 1926 | const res = await fetch(`https://api.glitch.com/v1/projects/${project.id}/users/${user.id}`, { 1927 | method: 'DELETE', 1928 | headers: { 1929 | 'Authorization': await getPersistentToken(), 1930 | }, 1931 | }); 1932 | if (!res.ok) throw new Error(`Glitch projects users delete response ${res.status} not ok, body ${await res.text()}`); 1933 | } 1934 | 1935 | async function doMemberList(/** @type {{project?: string}} */ opts) { 1936 | const projectDomain = await getProjectDomain(opts); 1937 | const project = await getProjectByDomain(projectDomain); 1938 | const idParams = /** @type {any[]} */ (project.permissions).map((permission) => `id=${permission.userId}`).join('&'); 1939 | const res = await fetch(`https://api.glitch.com/v1/users/by/id?${idParams}`); 1940 | if (!res.ok) throw new Error(`Glitch users by ID response ${res.status} not ok, body ${await res.text()}`); 1941 | const users = await res.json(); 1942 | console.log(' User ID Access level User login'); 1943 | for (const permission of project.permissions) { 1944 | const userIdCol = ('' + permission.userId).padStart(10); 1945 | const accessLevelCol = ('' + permission.accessLevel).padStart(12); 1946 | const login = users[permission.userId].login; 1947 | const userLoginCol = typeof login === 'string' ? login : `(${login})`; 1948 | console.log(`${userIdCol} ${accessLevelCol} ${userLoginCol}`); 1949 | } 1950 | } 1951 | 1952 | async function doDomainAdd(/** @type {string} */ domain, /** @type {{project?: string}} */ opts) { 1953 | const projectDomain = await getProjectDomain(opts); 1954 | const project = await getProjectByDomain(projectDomain); 1955 | const res = await fetch(`https://api.glitch.com/v1/projects/${project.id}/domains`, { 1956 | method: 'POST', 1957 | headers: { 1958 | 'Authorization': await getPersistentToken(), 1959 | 'Content-Type': 'application/json', 1960 | }, 1961 | body: JSON.stringify({domain}), 1962 | }); 1963 | if (!res.ok) throw new Error(`Glitch projects domains response ${res.status} not ok, body ${await res.text()}`); 1964 | } 1965 | 1966 | async function doDomainRm(/** @type {string} */ domain, /** @type {{project?: string}} */ opts) { 1967 | const projectDomain = await getProjectDomain(opts); 1968 | const project = await getProjectByDomain(projectDomain); 1969 | const res = await fetch(`https://api.glitch.com/v1/projects/${project.id}/domains`, { 1970 | method: 'DELETE', 1971 | headers: { 1972 | 'Authorization': await getPersistentToken(), 1973 | 'Content-Type': 'application/json', 1974 | }, 1975 | body: JSON.stringify({domain}), 1976 | }); 1977 | if (!res.ok) throw new Error(`Glitch projects domains delete response ${res.status} not ok, body ${await res.text()}`); 1978 | } 1979 | 1980 | async function doDomainList(/** @type {{project?: string}} */ opts) { 1981 | const projectDomain = await getProjectDomain(opts); 1982 | const project = await getProjectByDomain(projectDomain); 1983 | const res = await fetch(`https://api.glitch.com/v1/projects/${project.id}/domains`, { 1984 | headers: { 1985 | 'Authorization': await getPersistentToken(), 1986 | }, 1987 | }); 1988 | if (!res.ok) throw new Error(`Glitch projects domains response ${res.status} not ok, body ${await res.text()}`); 1989 | const body = await res.json(); 1990 | for (const domain of body.items) { 1991 | console.log(domain.hostname); 1992 | } 1993 | } 1994 | 1995 | async function doWebApp(/** @type {{project?: string}} */ opts) { 1996 | console.log(`https://${await getProjectDomain(opts)}.glitch.me/`); 1997 | } 1998 | 1999 | async function doWebDetails(/** @type {{project?: string}} */ opts) { 2000 | console.log(`https://glitch.com/~${await getProjectDomain(opts)}`); 2001 | } 2002 | 2003 | async function doWebEdit(/** @type {{project?: string}} */ opts) { 2004 | console.log(`https://glitch.com/edit/#!/${await getProjectDomain(opts)}`); 2005 | } 2006 | 2007 | async function doWebTerm(/** @type {{project?: string, cap?: boolean}} */ opts) { 2008 | const projectDomain = await getProjectDomain(opts); 2009 | const project = await getProjectByDomain(projectDomain); 2010 | if (opts.cap) { 2011 | const res = await fetch(`https://api.glitch.com/v1/projects/${project.id}/singlePurposeTokens/terminal`, { 2012 | method: 'POST', 2013 | headers: { 2014 | 'Authorization': await getPersistentToken(), 2015 | }, 2016 | }); 2017 | if (!res.ok) throw new Error(`Glitch projects single purpose tokens terminal response ${res.status} not ok, body ${await res.text()}`); 2018 | const body = await res.json(); 2019 | console.log(`https://api.glitch.com/console/${body.token}/`); 2020 | } else { 2021 | console.log(`https://glitch.com/edit/console.html?${project.id}`); 2022 | } 2023 | } 2024 | 2025 | async function doWebDebugger(/** @type {{project?: string, cap?: boolean}} */ opts) { 2026 | const projectDomain = await getProjectDomain(opts); 2027 | const project = await getProjectByDomain(projectDomain); 2028 | if (opts.cap) { 2029 | const res = await fetch(`https://api.glitch.com/v1/projects/${project.id}/singlePurposeTokens/devtools`, { 2030 | method: 'POST', 2031 | headers: { 2032 | 'Authorization': await getPersistentToken(), 2033 | }, 2034 | }); 2035 | if (!res.ok) throw new Error(`Glitch projects single purpose tokens devtools response ${res.status} not ok, body ${await res.text()}`); 2036 | const body = await res.json(); 2037 | console.log(`devtools://devtools/bundled/inspector.html?ws=api.glitch.com:80/project/debugger/${body.token}`); 2038 | } else { 2039 | console.log(`https://glitch.com/edit/debugger.html?${project.id}`); 2040 | } 2041 | } 2042 | 2043 | async function doConsoleAddMe() { 2044 | const {user} = await boot(); 2045 | console.log(`await application.glitchApi().v0.createProjectPermission(application.currentProject().id(), ${JSON.stringify(user.id)}, ${JSON.stringify(ACCESS_LEVEL_MEMBER)});`); 2046 | } 2047 | 2048 | async function doConsolePersistentToken() { 2049 | console.log('copy(JSON.parse(localStorage.getItem(\'cachedUser\')).persistentToken);'); 2050 | } 2051 | 2052 | commander.program.name('snail'); 2053 | commander.program.version(packageMeta.version); 2054 | commander.program.description(`CLI for Glitch 2055 | https://snail-cli.glitch.me/`); 2056 | const cmdAuth = commander.program 2057 | .command('auth') 2058 | .description('sign in'); 2059 | cmdAuth 2060 | .command('persistent-token [persistent_token]') 2061 | .description('use an existing persistent token') 2062 | .addHelpText('after', ` 2063 | Use the snippet from snail console persistent-token to get your persistent 2064 | token from your browser.`) 2065 | .action(doAuthPersistentToken); 2066 | cmdAuth 2067 | .command('anon') 2068 | .description('create a new anonymous user') 2069 | .action(doAuthAnon); 2070 | cmdAuth 2071 | .command('send-email [email]') 2072 | .description('request a sign-in code over email') 2073 | .option('-i, --interactive', 'additionally prompt for code and complete sign-in') 2074 | .addHelpText('after', ` 2075 | Use the code in the email with snail auth code to authenticate.`) 2076 | .action(doAuthSendEmail); 2077 | cmdAuth 2078 | .command('code [code]') 2079 | .description('authenticate with sign-in code') 2080 | .addHelpText('after', ` 2081 | Request a code on the web or with snail auth send-email.`) 2082 | .action(doAuthCode); 2083 | cmdAuth 2084 | .command('password [email] [password]') 2085 | .description('authenticate with email address and password') 2086 | .action(doAuthPassword); 2087 | commander.program 2088 | .command('whoami') 2089 | .description('show user login') 2090 | .option('-n, --numeric', 'show user ID instead of login') 2091 | .action(doWhoami); 2092 | commander.program 2093 | .command('logout') 2094 | .description('delete your saved persistent token') 2095 | .action(doLogout); 2096 | commander.program 2097 | .command('remote') 2098 | .description('set up the glitch git remote') 2099 | .option('-p, --project ', 'specify which project') 2100 | .action(doRemote); 2101 | commander.program 2102 | .command('setenv ') 2103 | .description('set an environment variable') 2104 | .option('-p, --project ', 'specify which project (taken from remote if not set)') 2105 | .action(doSetenv); 2106 | commander.program 2107 | .command('exec ') 2108 | .description('run a command in the project container') 2109 | .option('-p, --project ', 'specify which project (taken from remote if not set)') 2110 | .addHelpText('after', ` 2111 | Limitations: 2112 | Command line and output are not binary safe. 2113 | No output is returned until the process exits.`) 2114 | .action(doExec); 2115 | commander.program 2116 | .command('term [command...]') 2117 | .alias('t') 2118 | .description('connect to a project terminal') 2119 | .option('-p, --project ', 'specify which project (taken from remote if not set)') 2120 | .option('--no-set-transports', 'do not set a custom list of socket.io transports') 2121 | .option('--no-raw', 'do not alter stdin tty mode') 2122 | .addHelpText('after', ` 2123 | If command is provided, additionally sends that right after connecting. 2124 | 2125 | Projects may go to sleep quickly when it only has a terminal connection open. 2126 | Using snail ot status creates an OT connection, which is what normally prevents 2127 | this when editing on the web.`) 2128 | .action(doTerm); 2129 | commander.program 2130 | .command('pipe ') 2131 | .description('run a command and transfer binary data to and from it') 2132 | .option('-p, --project ', 'specify which project (taken from remote if not set)') 2133 | .option('--no-set-transports', 'do not set a custom list of socket.io transports') 2134 | .option('--debug', 'show unrecognized lines from terminal session') 2135 | .addHelpText('after', ` 2136 | Examples: 2137 | # Download a file 2138 | snail pipe 'cat .data/omni.db' >omni.db 2139 | # Upload a file 2140 | snail pipe 'cat >.data/omni.db' ') 2152 | .description('launch rsync with snail pipe as the transport') 2153 | .addHelpText('after', ` 2154 | Use -- to separate options meant for snail from options meant for rsync. 2155 | 2156 | Examples: 2157 | # Download the contents of a directory 2158 | snail rsync -- -aP my-domain:notes/ notes 2159 | # Upload the contents of a directory 2160 | snail rsync -- -aP notes/ my-domain:notes`) 2161 | .action(doRsync); 2162 | commander.program 2163 | .command('rsync-rsh ', {hidden: true}) 2164 | .action(doRsyncRsh); 2165 | const cmdSsh = commander.program 2166 | .command('ssh') 2167 | .description('interact over SSH'); 2168 | cmdSsh 2169 | .command('copy-id ') 2170 | .description('add a local public key to authorized keys in project') 2171 | .option('-i ', 'specify which public key file') 2172 | .addHelpText('after', ` 2173 | This is like ssh-copy-id. It's not as intelligent though. It unconditionally 2174 | adds the public key without trying to authenticate first. Also it does not 2175 | support ssh-agent. 2176 | 2177 | The fake_host is the project domain, optionally followed by a dot and 2178 | additional labels, which Snail ignores.`) 2179 | .action(doSshCopyId); 2180 | cmdSsh 2181 | .command('keyscan ') 2182 | .description('get host keys from project for local known hosts list') 2183 | .addHelpText('after', ` 2184 | This is like ssh-keyscan. 2185 | 2186 | The fake_host is the project domain, optionally followed by a dot and 2187 | additional labels, which Snail ignores. 2188 | 2189 | Example: 2190 | snail ssh keyscan lapis-empty-cafe.snail >>~/.ssh/known_hosts`) 2191 | .action(doSshKeyscan); 2192 | cmdSsh 2193 | .command('proxy ') 2194 | .description('set up an SSH daemon and connect to it') 2195 | .addHelpText('after', ` 2196 | Use this in an SSH ProxyCommand option. 2197 | 2198 | The fake_host is the project domain, optionally followed by a dot and 2199 | additional labels, which Snail ignores. 2200 | 2201 | Example: 2202 | # In ~/.ssh/config 2203 | Host *.snail 2204 | User app 2205 | ProxyCommand snail ssh proxy %h 2206 | RequestTTY no 2207 | # Then 2208 | ssh my-domain.snail ls 2209 | 2210 | Implementation problems: 2211 | Pseudo-terminal allocation is broken, because the daemon insists on trying to 2212 | chown the pty device, which it isn't permitted to do. That makes it not very 2213 | friendly for interactive terminal use, but it should work for file transfer, 2214 | port forwarding, and programmatic access.`) 2215 | .action(doSshProxy); 2216 | commander.program 2217 | .command('logs') 2218 | .description('watch application logs') 2219 | .option('-p, --project ', 'specify which project (taken from remote if not set)') 2220 | .option('-a, --all', 'show non-application logs too, as well as all fields') 2221 | .action(doLogs); 2222 | commander.program 2223 | .command('stop') 2224 | .description('stop project container') 2225 | .option('-p, --project ', 'specify which project (taken from remote if not set)') 2226 | .action(doStop); 2227 | commander.program 2228 | .command('download') 2229 | .description('download project as tarball') 2230 | .option('-p, --project ', 'specify which project (taken from remote if not set)') 2231 | .option('-o, --output ', 'output file location (uses server suggestion if not set)') 2232 | .addHelpText('after', ` 2233 | Pass - as path to write to stdout.`) 2234 | .action(doDownload); 2235 | const cmdAsset = commander.program 2236 | .command('asset') 2237 | .alias('a') 2238 | .description('manage CDN assets'); 2239 | cmdAsset 2240 | .command('policy') 2241 | .description('provision an S3 POST policy for asset upload') 2242 | .option('-p, --project ', 'specify which project (taken from remote if not set)') 2243 | .option('-t, --type ', 'asset MIME type', 'application/octet-stream') 2244 | .action(doAPolicy); 2245 | cmdAsset 2246 | .command('push ') 2247 | .description('upload an asset') 2248 | .option('-p, --project ', 'specify which project (taken from remote if not set)') 2249 | .option('-n, --name ', 'destination filename (taken from src if not set)') 2250 | .option('-t, --type ', 'asset MIME type', 'application/octet-stream') 2251 | .option('-a, --max-age ', 'max-age for Cache-Control', '31536000') 2252 | .addHelpText('after', ` 2253 | Implementation problems: 2254 | Does not maintain .glitch-assets.`) 2255 | .action(doAPush); 2256 | cmdAsset 2257 | .command('cp ') 2258 | .description('copy an asset') 2259 | .option('-p, --project ', 'specify which project (taken from remote if not set)') 2260 | .addHelpText('after', ` 2261 | Copy an asset from URL or name src to a new name dst. 2262 | 2263 | Examples: 2264 | # Copy from full URL 2265 | snail a cp https://cdn.glitch.global/8e6cdc77-20b9-4209-850f-d2607eeae33a/my-asset.png?v=1622356099641 avatar.png 2266 | # Copy asset from current project 2267 | snail a cp my-asset.png avatar.png 2268 | 2269 | Implementation problems: 2270 | Does not maintain .glitch-assets.`) 2271 | .action(doACp); 2272 | const cmdOt = commander.program 2273 | .command('ot') 2274 | .description('interact over OT'); 2275 | cmdOt 2276 | .command('push ') 2277 | .description('transfer local file src to project file dst') 2278 | .option('-p, --project ', 'specify which project (taken from remote if not set)') 2279 | .option('--debug', 'show OT messages') 2280 | .addHelpText('after', ` 2281 | Pass - as src to read from stdin.`) 2282 | .action(doOtPush); 2283 | cmdOt 2284 | .command('pull ') 2285 | .description('transfer project file src to local file dst') 2286 | .option('-p, --project ', 'specify which project (taken from remote if not set)') 2287 | .option('--debug', 'show OT messages') 2288 | .addHelpText('after', ` 2289 | Pass - as dst to write to stdout.`) 2290 | .action(doOtPull); 2291 | cmdOt 2292 | .command('mv ') 2293 | .description('move or rename a document') 2294 | .option('-p, --project ', 'specify which project (taken from remote if not set)') 2295 | .option('--debug', 'show OT messages') 2296 | .action(doOtMv); 2297 | cmdOt 2298 | .command('rm ') 2299 | .description('unlink a document') 2300 | .option('-p, --project ', 'specify which project (taken from remote if not set)') 2301 | .option('--debug', 'show OT messages') 2302 | .action(doOtRm); 2303 | cmdOt 2304 | .command('ls ') 2305 | .description('list a document\'s children') 2306 | .option('-p, --project ', 'specify which project (taken from remote if not set)') 2307 | .option('--debug', 'show OT messages') 2308 | .action(doOtLs); 2309 | cmdOt 2310 | .command('request-join') 2311 | .description('request to join') 2312 | .option('-p, --project ', 'specify which project (taken from remote if not set)') 2313 | .option('--debug', 'show OT messages') 2314 | .option('-r, --random-name', 'send request under a randomly generated name') 2315 | .action(doOtRequestJoin); 2316 | cmdOt 2317 | .command('status') 2318 | .description('watch container resource statistics') 2319 | .option('-p, --project ', 'specify which project (taken from remote if not set)') 2320 | .option('--debug', 'show OT messages') 2321 | .action(doOtStatus); 2322 | commander.program 2323 | .command('hours') 2324 | .description('show project hours usage') 2325 | .action(doHours); 2326 | const cmdProject = commander.program 2327 | .command('project') 2328 | .description('manage projects'); 2329 | cmdProject 2330 | .command('create [domain]') 2331 | .description('create a project') 2332 | .option('-r, --remix ', 'specify base project (glitch-hello-node if not set)') 2333 | .option('--remote', 'attempt to set up the glitch git remote') 2334 | .addHelpText('after', ` 2335 | Creates a new project and shows its domain. Leave domain unset to get a 2336 | randomly generated project domain. 2337 | 2338 | Implementation problems: 2339 | Does not send a reCAPTCHA response. This won't work on anonymous accounts.`) 2340 | .action(doProjectCreate); 2341 | cmdProject 2342 | .command('info') 2343 | .description('show information about a project') 2344 | .option('-p, --project ', 'specify which project (taken from remote if not set)') 2345 | .action(doProjectInfo); 2346 | cmdProject 2347 | .command('update') 2348 | .description('update a project\'s metadata') 2349 | .option('-p, --project ', 'specify which project') 2350 | .option('--domain ', 'set new domain') 2351 | .option('--description ', 'set description') 2352 | .option('--private', 'set legacy private flag (deprecated)') 2353 | .option('--no-private', 'clear legacy private flag (deprecated)') 2354 | .option('--privacy ', 'set privacy (public, private_code, or private_project)') 2355 | .action(doProjectUpdate); 2356 | cmdProject 2357 | .command('delete') 2358 | .description('delete a project') 2359 | .option('-p, --project ', 'specify which project') 2360 | .action(doProjectDelete); 2361 | cmdProject 2362 | .command('undelete') 2363 | .description('undelete a project') 2364 | .option('-p, --project ', 'specify which project') 2365 | .action(doProjectUndelete); 2366 | cmdProject 2367 | .command('list') 2368 | .description('list projects') 2369 | .option('-d, --deleted', 'list deleted projects') 2370 | .action(doProjectList); 2371 | const cmdMember = commander.program 2372 | .command('member') 2373 | .description('manage project members'); 2374 | cmdMember 2375 | .command('add ') 2376 | .description('add a member') 2377 | .option('-p, --project ', 'specify which project') 2378 | .option('-n, --numeric', 'specify user ID instead of login') 2379 | .action(doMemberAdd); 2380 | cmdMember 2381 | .command('rm ') 2382 | .description('remove a member') 2383 | .option('-p, --project ', 'specify which project') 2384 | .option('-n, --numeric', 'specify user ID instead of login') 2385 | .action(doMemberRm); 2386 | cmdMember 2387 | .command('leave') 2388 | .description('leave the project') 2389 | .option('-p, --project ', 'specify which project') 2390 | .action(doMemberLeave); 2391 | cmdMember 2392 | .command('list') 2393 | .description('list members') 2394 | .option('-p, --project ', 'specify which project') 2395 | .action(doMemberList); 2396 | const cmdDomain = commander.program 2397 | .command('domain') 2398 | .description('manage custom domains'); 2399 | cmdDomain 2400 | .command('add ') 2401 | .description('add a custom domain') 2402 | .option('-p, --project ', 'specify which project') 2403 | .action(doDomainAdd); 2404 | cmdDomain 2405 | .command('rm ') 2406 | .description('remove a custom domain') 2407 | .option('-p, --project ', 'specify which project') 2408 | .action(doDomainRm); 2409 | cmdDomain 2410 | .command('list') 2411 | .description('list custom domains') 2412 | .option('-p, --project ', 'specify which project') 2413 | .action(doDomainList); 2414 | const cmdWeb = commander.program 2415 | .command('web') 2416 | .description('display web URLs'); 2417 | cmdWeb 2418 | .command('app') 2419 | .description('display application URL') 2420 | .option('-p, --project ', 'specify which project (taken from remote if not set)') 2421 | .action(doWebApp); 2422 | cmdWeb 2423 | .command('details') 2424 | .description('display project details URL') 2425 | .option('-p, --project ', 'specify which project (taken from remote if not set)') 2426 | .action(doWebDetails); 2427 | cmdWeb 2428 | .command('edit') 2429 | .description('display editor URL') 2430 | .option('-p, --project ', 'specify which project (taken from remote if not set)') 2431 | .action(doWebEdit); 2432 | cmdWeb 2433 | .command('term') 2434 | .description('display terminal URL') 2435 | .option('-p, --project ', 'specify which project (taken from remote if not set)') 2436 | .option('-c, --cap', 'display inner URL with persistent token') 2437 | .action(doWebTerm); 2438 | cmdWeb 2439 | .command('debugger') 2440 | .description('display debugger URL') 2441 | .option('-p, --project ', 'specify which project (taken from remote if not set)') 2442 | .option('-c, --cap', 'display devtool URL with debugger token') 2443 | .addHelpText('after', ` 2444 | Implementation problems: 2445 | Does not set GLITCH_DEBUGGER. Do that yourself (snail setenv GLITCH_DEBUGGER 2446 | true).`) 2447 | .action(doWebDebugger); 2448 | const cmdConsole = commander.program 2449 | .command('console') 2450 | .description('generate snippets for running in the developer console'); 2451 | cmdConsole 2452 | .command('add-me') 2453 | .description('generate snippet to add this user to a project') 2454 | .action(doConsoleAddMe); 2455 | cmdConsole 2456 | .command('persistent-token') 2457 | .description('generate snippet to copy your persistent token') 2458 | .action(doConsolePersistentToken); 2459 | 2460 | commander.program.parseAsync(process.argv).catch((e) => { 2461 | console.error(e); 2462 | process.exit(1); 2463 | }); 2464 | 2465 | // Local Variables: 2466 | // indent-tabs-mode: nil 2467 | // js-indent-level: 2 2468 | // End: 2469 | -------------------------------------------------------------------------------- /vanity/common.css: -------------------------------------------------------------------------------- 1 | .header { 2 | margin: 0em 3vw; 3 | padding-top: 2em; 4 | max-width: 37rem; 5 | } 6 | .header .line { 7 | line-height: 1.5em; 8 | font-family: Consolas, Menlo, monospace; 9 | } 10 | .header .prompt { 11 | color: #f0f0f080; 12 | } 13 | .header .home { 14 | color: inherit; 15 | text-decoration: none; 16 | } 17 | .header .snail { 18 | position: absolute; 19 | margin-top: -1.1em; 20 | } 21 | .header .command { 22 | margin: 0em -0.25em; 23 | border-radius: 0.25em; 24 | padding: 0.25em; 25 | background-color: #80ffc0; 26 | font-weight: normal; 27 | color: #1c1c1c; 28 | } 29 | .header h1 { 30 | display: inline; 31 | font-weight: normal; 32 | font-size: 1em; 33 | } 34 | 35 | .block { 36 | margin: 6rem 3vw; 37 | max-width: 37rem; 38 | } 39 | 40 | .sigil { 41 | padding-left: 1.7rem; 42 | } 43 | .sigil::before { 44 | display: block; 45 | float: left; 46 | margin: -1px 0rem -1px -1.7rem; 47 | border: 1px solid #f0f0f040; 48 | border-radius: 0.25rem; 49 | width: 1.2rem; 50 | text-align: center; 51 | color: #f0f0f040; 52 | } 53 | .sigil-command::before { 54 | content: "$"; 55 | } 56 | 57 | /* colors from GNOME Console */ 58 | .term-red { color: #c01c28; } 59 | .term-green { color: #2ec27e; } 60 | .term-yellow { color: #f5c211; } 61 | .term-blue { color: #1e78e4; } 62 | .term-magenta { color: #9841bb; } 63 | .term-cyan { color: #0ab9dc; } 64 | b .term-red { color: #ed333b; } 65 | b .term-green { color: #57e389; } 66 | b .term-yellow { color: #f8e45c; } 67 | b .term-blue { color: #51a1ff; } 68 | b .term-magenta { color: #c061cb; } 69 | b .term-cyan { color: #4fd2fd; } 70 | 71 | pre .command, code .command { 72 | color: #80ffc0; 73 | } 74 | pre .placeholder, code .placeholder { 75 | color: #ffb0a0; 76 | } 77 | pre .control, code .control { 78 | border-radius: 0.25em; 79 | padding: 0em 0.25em; 80 | background-color: #e0b040; 81 | font-family: Source Sans Pro, sans-serif; 82 | font-size: 0.75em; 83 | color: #202020; 84 | } 85 | 86 | .attractive-button { 87 | display: inline-block; 88 | border: 0.1875em double #80ffc0; 89 | border-radius: 0.25em; 90 | padding: 0.3125em 0.5625em; 91 | background-color: #80ffc0; 92 | background-clip: padding-box; 93 | color: #1c1c1c; 94 | font: inherit; 95 | text-decoration: none; 96 | } 97 | .attractive-button:link, .attractive-button:visited, .attractive-button:any-link:active { 98 | color: #1c1c1c; 99 | } 100 | .attractive-button:disabled { 101 | border: 0.1875em solid #608070; 102 | background-color: #608070; 103 | } 104 | 105 | .footer { 106 | margin: 1.25rem 0rem 0rem; 107 | padding: 0rem 3vw 2.8125rem; 108 | border-top: 1px solid #f0f0f040; 109 | color: #f0f0f080; 110 | font-size: 0.75rem; 111 | } 112 | 113 | @media print { 114 | .footer span { 115 | display: none; 116 | } 117 | 118 | .footer:after { 119 | content: "Go ahead, write whatever you want down here. It's your own paper."; 120 | } 121 | } 122 | 123 | body { 124 | margin: 0rem; 125 | background-color: #202020; 126 | font-family: Source Sans Pro, sans-serif; 127 | color: #f0f0f0; 128 | } 129 | *:link { 130 | color: #8090f0; 131 | } 132 | *:visited { 133 | color: #d080f0; 134 | } 135 | *:any-link:active { 136 | color: #f08080; 137 | } 138 | code { 139 | padding: 0rem 0.25em; 140 | border-radius: 0.25em; 141 | background-color: #101010; 142 | font-family: Consolas, Menlo, monospace; 143 | color: #f0f0f0c0; 144 | } 145 | a code { 146 | color: inherit; 147 | } 148 | pre { 149 | font-family: Consolas, Menlo, monospace; 150 | color: #f0f0f0c0; 151 | } 152 | -------------------------------------------------------------------------------- /vanity/dist: -------------------------------------------------------------------------------- 1 | ../dist -------------------------------------------------------------------------------- /vanity/github.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /vanity/glitch.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /vanity/help-staging: -------------------------------------------------------------------------------- 1 | ../help-staging -------------------------------------------------------------------------------- /vanity/help.css: -------------------------------------------------------------------------------- 1 | .header h1 { 2 | display: inline; 3 | font-weight: normal; 4 | font-size: 1em; 5 | } 6 | .help-main { 7 | margin-top: 2rem; 8 | margin-bottom: 2rem; 9 | max-width: none; 10 | } 11 | .help-main pre { 12 | color: inherit; 13 | } 14 | .help-misc { 15 | margin-top: 2rem; 16 | margin-bottom: 2rem; 17 | } 18 | .item { 19 | display: inline-flex; 20 | } 21 | .description { 22 | white-space: pre-wrap; 23 | flex: 1; 24 | } 25 | -------------------------------------------------------------------------------- /vanity/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Snail: CLI for Glitch 6 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 148 | 149 |
150 |
151 |
152 | $ 153 | 🐌

snail

154 |
155 |
    156 |
  • 
  • 157 |
  • auth
  • 158 |
  • remote
  • 159 |
  • term
  • 160 |
  • asset
  • 161 |
  • exec
  • 162 |
  • logs
  • 163 |
  • 164 | 165 | 166 | # 167 | Glitch 168 | CLI 169 | 170 |
  • 171 |
172 |
173 |
174 |
175 | 176 |
177 |

178 | The Glitch team is busy building their web-based editor. 179 | So we're making our own command line tool for the service, and it's called Snail. 180 |

181 | 185 | 190 |
191 | 192 |
193 |

Getting started

194 |

195 | Install, sign in, and specify a project. 196 |

197 |

198 | You can also practice using Snail online to see what it offers. 199 |

200 |
201 | 202 |
203 |

Installation

204 |

205 | Use npm or download a bundled script or clone our repository. 206 |

207 |

208 | npm option. 209 | Install a copy from our package on npm. 210 | Run this: 211 |

212 |
213 | npm install -g glitch-snail
214 | 
215 |

216 | Bundled script option. 217 | We have a single-file bundle for Node.js on our GitHub Releases page. 218 | Download it and set it up to be executable and add it to your PATH: 219 |

220 |
221 | curl -LO https://github.com/wh0/snail-cli/releases/latest/download/snail.js
222 | chmod +x snail.js
223 | mkdir -p ~/.local/bin
224 | ln -s "$PWD/snail.js" ~/.local/bin/snail
225 | 
226 |

227 | Repository option. 228 | Clone a copy of our Git repository and have npm install it: 229 |

230 |
231 | git clone https://github.com/wh0/snail-cli.git
232 | cd snail-cli
233 | npm install -g
234 | 
235 |
236 | 237 |
238 |

Signing in

239 |

240 | Get a sign-in code over email or create a new anonymous user. 241 | Signing in stores your persistent token in $HOME/.config/snail/persistent-token. 242 |

243 |

244 | Sign-in code over email option. 245 |

246 |
247 | snail auth code xxxxxxxxx-xxxxx-xxxxxxxx
248 | 
249 |

250 | Request an email with the code from https://glitch.com/signin. 251 | Don't click the link in the email. 252 |

253 |

254 | 💔 Glitch now requires a CAPTCHA solution for this endpoint, so snail auth send-email doesn't work anymore. 255 |

256 |

257 | New anonymous user option. 258 |

259 |
260 | snail auth anon
261 | 
262 |
263 | 264 |
265 |

Specifying a project

266 |

267 | Create a Git remote or specify the project on each command. 268 |

269 |

270 | Git remote option. 271 |

272 |
273 | snail remote -p glitch-hello-website
274 | snail ot ls .
275 | 
276 |

277 | On each command option. 278 |

279 |
280 | snail ot ls -p glitch-hello-website .
281 | 
282 |
283 | 284 |
285 |

Sample session

286 |

287 | We have a simple page. 288 | We want to deploy it and add an image. 289 |

290 |
291 | 292 |
293 |

294 | We have Snail installed, and we're signed in to an anonymous account. 295 |

296 |
297 | type snail
298 | snail is /app/.local/bin/snail
299 | 
300 |
301 | snail whoami -n
302 | 28021122
303 | 
304 |
305 | 306 |
307 |

308 | Here's the page, in a Git repository. 309 |

310 |
311 | git log -p
312 | commit 213b517180c7a4af53836287679bc62c43fc3eba
313 | Author: w <none>
314 | Date:   Sat Oct 24 02:58:43 2020 +0000
315 | 
316 |     add placeholder
317 | 
318 | diff --git a/index.html b/index.html
319 | new file mode 100644
320 | index 0000000..8435ed0
321 | --- /dev/null
322 | +++ b/index.html
323 | @@ -0,0 +1 @@
324 | +yay
325 | 
326 |

327 | The image will go on the assets CDN, so it's not in our repository. 328 |

329 |
330 | git status
331 | On branch master
332 | Untracked files:
333 |   (use "git add <file>..." to include in what will be committed)
334 | 
335 |         my-asset.png
336 | 
337 | nothing added to commit but untracked files present (use "git add" to track)
338 | 
339 |
340 | 341 |
342 |

343 | First, we set up the glitch Git remote so that subsequent commands will operate on a specific project. 344 | Later, we'll also use this remote to push code into the project. 345 |

346 |
347 | snail remote -p lapis-empty-cafe
348 | 
349 |
350 | git remote -v
351 | glitch  https://982a[redacted]@api.glitch.com/git/lapis-empty-cafe (fetch)
352 | glitch  https://982a[redacted]@api.glitch.com/git/lapis-empty-cafe (push)
353 | 
354 |

355 | The project we're deploying to wasn't created on this anonymous account, so we have to join the project before we can make any changes to it. 356 | We request to join, and another project member approves this request in the web editor. 357 |

358 |
359 | snail ot request-join -r
360 | Requesting to join as snail-652c
361 | 
362 |
363 | 364 |
365 |

366 | We run a few commands to clear out the starter project. 367 | The snail term command connects us to an interactive terminal session where we can do that. 368 |

369 |
370 | snail term
371 | Welcome to the Glitch console
372 | 
373 | If you’re following someone else’s instructions make sure you trust them.
374 | If in doubt post a question in our forum https://support.glitch.com
375 | 
376 | For now, the console and the editor don't automatically sync. You can
377 | manually run the `refresh` command and it will force a refresh,
378 | updating the editor with any console-created files.
379 | 
380 | For more information about this and other technical restrictions,
381 | please see the Help Center: https://glitch.com/help
382 | 
383 | app@lapis-empty-cafe:~ 06:44
384 | $ rm -rf * .??*
385 | 
386 | app@lapis-empty-cafe:~ 06:44
387 | $ git init
388 | Initialized empty Git repository in /app/.git/
389 | 
390 | app@lapis-empty-cafe:~ 06:44
391 | $ ls -a
392 | .  ..  .git
393 | 
394 | app@lapis-empty-cafe:~ 06:45
395 | $ exit
396 | logout
397 | 
398 |
399 | 400 |
401 |

402 | We upload the image to the assets CDN. 403 | That gives us a URL, which we put in our page. 404 |

405 |
406 | snail asset push my-asset.png
407 | https://cdn.glitch.com/8e6cdc77-20b9-4209-850f-d2607eeae33a%2Fmy-asset.png?v=1622356099641
408 | 
409 |
410 | cat >>index.html
411 | <img src="https://cdn.glitch.com/8e6cdc77-20b9-4209-850f-d2607eeae33a%2Fmy-asset.png?v=1622356099641">
412 | Ctrl-D
413 | 
414 |
415 | git commit -am "add asset"
416 | [master 6a65e3c] add asset
417 |  1 file changed, 1 insertion(+)
418 | 
419 |
420 | 421 |
422 |

423 | We transfer our site over Git. 424 | To avoid conflict with the project's own checkout of the master branch, we push to a new branch called staging. 425 |

426 |
427 | git push glitch master:staging
428 | Counting objects: 6, done.
429 | Delta compression using up to 4 threads.
430 | Compressing objects: 100% (3/3), done.
431 | Writing objects: 100% (6/6), 492 bytes | 0 bytes/s, done.
432 | Total 6 (delta 0), reused 0 (delta 0)
433 | To https://982a[redacted]@api.glitch.com/git/lapis-empty-cafe
434 |  * [new branch]      master -> staging
435 | 
436 |

437 | Then we use Git inside the project to update to the version we pushed. 438 | The snail exec command lets us run single commands, in case we aren't in the mood for an entire terminal session. 439 |

440 |
441 | snail exec -- git reset --hard staging
442 | HEAD is now at 6a65e3c add asset
443 | 
444 |
445 | 446 |
447 |

448 | Finally, we manually run refresh because we've updated the project outside the Glitch editor. 449 | We watch the logs to see it come back up. 450 |

451 |
452 | snail exec -- refresh
453 | restarting...
454 | 
455 |
456 | snail logs
457 | Serving at http://1e7adc1b5ef7:3000, http://127.0.0.1:3000, http://172.17.0.62:3000
458 | Serving at http://1e7adc1b5ef7:3000, http://127.0.0.1:3000, http://172.17.0.62:3000
459 | Ctrl-C
460 | 
461 |
462 | 463 |
464 |

465 | Now our application is online with the page we created! 466 |

467 |
468 | curl https://lapis-empty-cafe.glitch.me/
469 | yay
470 | <img src="https://cdn.glitch.com/8e6cdc77-20b9-4209-850f-d2607eeae33a%2Fmy-asset.png?v=1622356099641">
471 | 
472 |
473 | 474 | 475 | 476 | 477 | 478 | 479 | -------------------------------------------------------------------------------- /vanity/join.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 🐌 5 | 6 | -------------------------------------------------------------------------------- /vanity/keytimer.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 41 | -------------------------------------------------------------------------------- /vanity/npm.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /vanity/practice/editor-invite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wh0/snail-cli/26d91b614b6d1f397d805ed554a59260bfa7e22a/vanity/practice/editor-invite.png -------------------------------------------------------------------------------- /vanity/practice/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Practice — Snail 6 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 101 | 102 |
103 |
104 |
105 |
106 | $ 107 | 🐌snail 108 | # 109 |

Practice online

110 |
111 |
112 | 113 |
114 |

115 | You can try Snail right in your browser! 116 | This practice page is available for a limited time, until either 117 | we're convinced that it's not a good experience or 118 | Glitch changes their embedding rules to make this impossible. 119 |

120 |
121 | 122 | 147 | 148 | 158 | 159 | 166 | 167 |
168 |

Installation

169 |

170 | Snail is already installed in this practice area. 171 | Let's run a command to test it out. 172 |

173 |
174 | snail --version
175 | 
176 |

177 | Completion criteria: 178 |

179 |
    180 |
  • The command executes successfully and shows a version number
  • 181 |
182 | 188 |
189 | 190 |
191 |

Sign in

192 |

193 | We'll use an anonymous account. 194 |

195 |
196 | snail auth anon
197 | 
198 |

199 | Then check that you're signed in by looking up your user ID. 200 |

201 |
202 | snail whoami -n
203 | 
204 |

205 | Completion criteria: 206 |

207 |
    208 |
  • You get a numeric user ID
  • 209 |
  • We won't be using this ID anywhere, so you don't have to remember it
  • 210 |
211 | 217 |
218 | 219 |
220 |

Create a project

221 |

222 | Let's make a project for us to practice on. 223 |

224 |

225 | Remix this practice project 226 |

227 |

228 | (Here's a link to take a peek without remixing if you don't want to do that just now.) 229 |

230 |

231 | While your practice area itself runs in a project, this will be a separate one. 232 | That simulates how you'd run Snail on your local computer, outside of the project you're working on. 233 |

234 |

235 | Completion criteria: 236 |

237 |
    238 |
  • Glitch successfully remixes the template project for you
  • 239 |
  • You remember the name of the project, which we'll need later
  • 240 |
  • The project doesn't have to be in working condition (it shouldn't be, and we'll examine it later)
  • 241 |
242 | 248 |
249 | 250 |
251 |

Set up a Git repository

252 |

253 | Snail uses the presence of a special glitch Git remote to figure out what project you intend to interact with. 254 | Set up a repository—we'll call it badge: 255 |

256 |
257 | mkdir -p .data/badge
258 | cd .data/badge
259 | git init
260 | 
261 |

262 | Then add that Git remote (sample project name shown; use your own): 263 |

264 |
265 | snail remote --help
266 | snail remote -p lively-indecisive-archeology
267 | git remote -v
268 | 
269 |

270 | Put the name of the project you just created after the -p. 271 | Then, be prepared not to call that thing the "name" anymore. 272 | In the Glitch API and in Snail's documentation, it's called the project's domain for some reason. 273 |

274 |

275 | Weird that we call it the "domain," right? 276 | You'd normally think that the "domain" would include the .glitch.me at the end. 277 | But in this usage, it refers only to that first part. 278 |

279 |

280 | Do the rest of this exercise from inside this repository that we've just set up. 281 | Do not run the code below instead: 282 |

283 |
284 | echo "I solemnly swear that I'm not just skipping"
285 | echo "from code block to code block and I really"
286 | echo "did read the last paragraph."
287 | 
288 |

289 | Completion criteria: 290 |

291 |
    292 |
  • You have a local Git repository, although it's empty right now
  • 293 |
  • Git shows that you have a remote called glitch with a URL that looks like https://xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx@api.glitch.com/git/lively-indecisive-archeology
  • 294 |
  • Pushing to this remote won't work yet
  • 295 |
296 | 302 |
303 | 304 |
305 |

Open the project's website

306 |

307 | The template project is designed to be at a point where it needs some work done on it. 308 | Let's see what it's like. 309 |

310 |
311 | snail web --help
312 | snail web app
313 | 
314 |

315 | This prints out the project's web URL. 316 | For this in-browser terminal, select the output by dragging and press Ctrl+Insert to copy it. 317 | Open that URL in a new tab. 318 |

319 |

320 | The site immediately crashes. 321 |

322 |

323 | Ouch, the site is really not in good shape. 324 | It only displays an error message, either "Site didn't respond" or "failed to start application." 325 |

326 |

327 | Completion criteria: 328 |

329 |
    330 |
  • The command shows the web URL
  • 331 |
  • You're able to copy text from the terminal
  • 332 |
  • The site opens, but it shows that it is broken
  • 333 |
334 | 340 |
341 | 342 |
343 |

Grant access

344 |

345 | You're signed in to Snail as an anonymous user, different from how you're signed in on a browser when you created the project. 346 | Because these are separate users, so far we've only been able to look but not touch. 347 | But it's clear now this project will need some work, so let's get write access. 348 |

349 |

350 | We'll use the editor to invite your anonymous user account. 351 | Get the editor URL from Snail and open it in a browser: 352 |

353 |
354 | snail web edit
355 | 
356 |

357 | Now request an invitation: 358 |

359 |
360 | snail ot request-join --help
361 | snail ot request-join -r
362 | 
363 |

364 | Find the request on the top left of the editor and invite the user to join. 365 |

366 |

367 | Your anonymous user shows up with a randomly generated name, snail-c304 in this example. 368 |

369 |

370 | Let's see who can access the project now. 371 |

372 |
373 | snail member --help
374 | snail member list
375 | 
376 |

377 | Completion criteria: 378 |

379 |
    380 |
  • The request to join appears in the editor
  • 381 |
  • Your anonymous user shows up in the member list
  • 382 |
383 | 389 |
390 | 391 |
392 |

Check the logs

393 |

394 | The error shown on the website doesn't tell us why the application didn't respond. 395 | We'll want to look at the logs for why this is happening: 396 |

397 |
398 | snail logs --help
399 | snail logs
400 | 
401 |

402 | Then, reload the project's website and see what the project logs. 403 |

404 |
405 | /app/server.js:38
406 |       .end(err.stack);
407 |            ^
408 | 
409 | ReferenceError: err is not defined
410 | 
411 |

412 | Let's look there. 413 | We'll need a copy of the code though. 414 |

415 |

416 | Completion criteria: 417 |

418 |
    419 |
  • You have permission to view the logs
  • 420 |
  • Details about the error show up in the logs
  • 421 |
422 | 428 |
429 | 430 |
431 |

Pull the code

432 |

433 | The Git repository is indeed a Git repository, no big surprise there. 434 | Go ahead and pull. 435 |

436 |
437 | git pull glitch master
438 | 
439 |

440 | Completion criteria: 441 |

442 |
    443 |
  • You have a copy of the version controlled files in your local repo
  • 444 |
445 | 451 |
452 | 453 |
454 |

Fix the error reporting

455 |

456 | Now we can take a look at the code: 457 |

458 |
459 | less -N server.js
460 | 
461 |

462 | The error happens here: 463 |

464 |
465 |      35   } catch (e) {
466 | ...
467 |      38       .end(err.stack);
468 | 
469 |

470 | This exercise is not about how to solve JavaScript problems or how to use command line text editors. 471 | The code mistakenly uses err instead of e. 472 | Here's a one-liner to edit in the fix, so we can get on with things: 473 |

474 |
475 | sed -i '38s/err/e/' server.js
476 | 
477 |

478 | ... and set up your Git user info (sample info shown; use your own): 479 |

480 |
481 | git config user.name 'wh0'
482 | git config user.email 'none'
483 | 
484 |

485 | ... and commit the change: 486 |

487 |
488 | git commit -am "fix error reporting"
489 | 
490 |

491 | Completion criteria: 492 |

493 |
    494 |
  • You have a commit with the fix locally
  • 495 |
  • That fix, by the way, is not deployed yet
  • 496 |
  • The project still shouldn't work, that is to say
  • 497 |
  • Also when you're doing this on your own computer, you'll have a text editor, so feel free not to learn anything about sed here
  • 498 |
499 | 505 |
506 | 507 |
508 |

Deploy the change

509 |

510 | We need to set up the project to receive Git pushes to its current branch. 511 | To set that, we run a git config command in the project container, i.e., not locally or in our practice area. 512 | We can use Snail to run a command in the project: 513 |

514 |
515 | snail exec --help
516 | snail exec -- git config \
517 |   receive.denyCurrentBranch updateInstead
518 | 
519 |

520 | Then push the change: 521 |

522 |
523 | git push glitch master
524 | 
525 |

526 | ... and have Glitch run the new version: 527 |

528 |
529 | snail exec -- refresh
530 | 
531 |

532 | Now let's reload the project's website and have another look. 533 |

534 |

535 | Completion criteria: 536 |

537 |
    538 |
  • The Git push succeeds
  • 539 |
  • The "Site didn't respond" error no longer occurs
  • 540 |
  • The site still isn't working; remember that we've only fixed the error reporting so we can find out why it's not working
  • 541 |
542 | 548 |
549 | 550 |
551 |

Open the project's website, take two

552 |

553 | Error reporting should now work. 554 | It should now respond with a stack trace when we try to load the page and it fails. 555 | So let's see what that is. 556 |

557 |
558 | Error: ENOENT: no such file or directory, open '.data/me.html'
559 | ...
560 |     at Server.<anonymous> (/app/server.js:13:23)
561 | 
562 |

563 | According to this, there's a file missing. 564 | The code that uses it is like this: 565 |

566 |
567 | const me = fs.readFileSync('.data/me.html', 'utf-8').trim();
568 | ...
569 |   .end(`<!doctype html>
570 | ...
571 | I'm <span class="me">${me}</span>,
572 | and I completed ...
573 | 
574 |

575 | All we have to do is add a file with your name. 576 |

577 |

578 | Completion criteria: 579 |

580 |
    581 |
  • The site serves up an error stack trace
  • 582 |
  • The error matches the listing above
  • 583 |
584 | 590 |
591 | 592 |
593 |

Add your name

594 |

595 | We can reasonably use Git to deploy code changes, but we normally wouldn't put data files under version control. 596 | Snail has other facilities for doing general file management. 597 | First let's enter an interactive shell to take a look around the project filesystem. 598 |

599 |
600 | snail term --help
601 | snail term
602 | ls -al
603 | mkdir -p .data
604 | ls -al .data
605 | Ctrl-D
606 | 
607 |

608 | Here, the ls and mkdir are things you type while connected to the snail term session. 609 | The .data directory might already exist. 610 |

611 |

612 | Make a file that we'll upload: 613 |

614 |
615 | git config user.name >/tmp/me.html
616 | 
617 |

618 | Snail hasn't settled on a best way to transfer files, but the rsync wrapper is pretty friendly. 619 | Use the project domain as the "host" for rsync. 620 | We'll use the rsync wrapper (sample project domain shown; use your own): 621 |

622 |
623 | snail rsync --help
624 | snail rsync -- /tmp/me.html lively-indecisive-archeology:.data
625 | 
626 |

627 | If you're like me, you'll have an urge to look again: 628 |

629 |
630 | snail term
631 | ls -al .data
632 | Ctrl-D
633 | 
634 |

635 | Completion criteria: 636 |

637 |
    638 |
  • The me.html file is present in the project's .data directory
  • 639 |
640 | 646 |
647 | 648 |
649 |

Open the project's website, take three

650 |

651 | This should be it for fixing up the project. 652 |

653 |

654 | Go ahead and open the project website again. 655 | You'll now have a page to commemorate that you completed this practice exercise. 656 |

657 |

658 | Completion criteria: 659 |

660 |
    661 |
  • The site should serve an actual page
  • 662 |
  • The page should have the name you entered earlier
  • 663 |
664 | 670 |
671 | 672 |
673 |

You're done!

674 |

675 | The fixed website is yours to keep, and the practice area will keep working for 5 days. 676 |

677 |

678 | If you continue to use the practice area, be advised that files you create outside of the .data directory are public. 679 | Prefer to create Git repositories inside the .data directory like we did in the exercise, so that the Git access token in the remote doesn't get exposed. 680 |

681 |
682 | 683 | 684 |
685 | 686 |
687 | 700 | 701 | 702 | 716 |
717 |
718 | 719 | 816 | 817 | 818 | 819 | 820 | -------------------------------------------------------------------------------- /vanity/practice/site-didnt-respond.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wh0/snail-cli/26d91b614b6d1f397d805ed554a59260bfa7e22a/vanity/practice/site-didnt-respond.png -------------------------------------------------------------------------------- /vanity/snail.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 🐌 4 | 5 | -------------------------------------------------------------------------------- /vanity/test.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | mode: 'production', 3 | target: 'node', 4 | optimization: { 5 | moduleIds: 'named', 6 | minimize: false, 7 | }, 8 | }; 9 | --------------------------------------------------------------------------------