permaweb-deploy 0.0.0-rc-20251001215907
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +332 -0
- package/bin/dev.js +6 -0
- package/bin/run.js +6 -0
- package/dist/index.js +299 -0
- package/package.json +96 -0
package/README.md
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
# Permaweb Deploy
|
|
2
|
+
|
|
3
|
+
Inspired by the [cookbook github action deployment guide](https://cookbook.arweave.dev/guides/deployment/github-action.html), `permaweb-deploy` is a Node.js command-line tool designed to streamline the deployment of web applications to the permaweb using Arweave. It uploads your build folder or a single file, creates Arweave manifests, and updates ArNS (Arweave Name Service) records via ANT (Arweave Name Token) with the transaction ID.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Turbo SDK Integration:** Uses Turbo SDK for fast, reliable file uploads to Arweave
|
|
8
|
+
- **Arweave Manifest v0.2.0:** Creates manifests with fallback support for SPAs
|
|
9
|
+
- **ArNS Updates:** Updates ArNS records via ANT with new transaction IDs and metadata
|
|
10
|
+
- **Automated Workflow:** Integrates with GitHub Actions for continuous deployment
|
|
11
|
+
- **Git Hash Tagging:** Automatically tags deployments with Git commit hashes
|
|
12
|
+
- **404 Fallback Detection:** Automatically detects and sets 404.html as fallback
|
|
13
|
+
- **Network Support:** Supports mainnet, testnet, and custom ARIO process IDs
|
|
14
|
+
- **Flexible Deployment:** Supports deploying a folder or a single file
|
|
15
|
+
- **Modern CLI:** Built with oclif for a robust command-line experience
|
|
16
|
+
- **TypeScript:** Fully typed for better developer experience
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
Install the package using pnpm (recommended):
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pnpm add -D permaweb-deploy
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Or with npm:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
npm install --save-dev permaweb-deploy
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Or with yarn:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
yarn add --dev permaweb-deploy
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Prerequisites
|
|
39
|
+
|
|
40
|
+
1. **For Arweave signer (default):** Encode your Arweave wallet key in base64 format and set it as a GitHub secret:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
base64 -i wallet.json | pbcopy
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
2. **For Ethereum/Polygon/KYVE signers:** Use your raw private key (no encoding needed) as the `DEPLOY_KEY`.
|
|
47
|
+
|
|
48
|
+
3. Ensure that the secret name for the encoded wallet or private key is `DEPLOY_KEY`.
|
|
49
|
+
|
|
50
|
+
⚠️ **Important:** Use a dedicated wallet for deployments to minimize security risks. Ensure your wallet has sufficient Turbo Credits for uploads.
|
|
51
|
+
|
|
52
|
+
## Usage
|
|
53
|
+
|
|
54
|
+
### Interactive Mode (Easiest)
|
|
55
|
+
|
|
56
|
+
**Command Menu:**
|
|
57
|
+
|
|
58
|
+
Simply run the CLI for an interactive command selector:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
permaweb-deploy
|
|
62
|
+
# or explicitly
|
|
63
|
+
permaweb-deploy interactive
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
This shows a menu with options:
|
|
67
|
+
- **Deploy to Permaweb** - Start the deployment wizard
|
|
68
|
+
- **Show Help** - Display help information
|
|
69
|
+
- **Exit** - Exit the CLI
|
|
70
|
+
|
|
71
|
+
**Interactive Deploy (Guided):**
|
|
72
|
+
|
|
73
|
+
Run the deploy command without arguments to be guided through all deployment options:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
permaweb-deploy deploy
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
This will prompt you for:
|
|
80
|
+
- ArNS name
|
|
81
|
+
- Wallet method (file, string, or environment variable)
|
|
82
|
+
- Signer type (Arweave, Ethereum, Polygon, KYVE)
|
|
83
|
+
- What to deploy (folder or file)
|
|
84
|
+
- Advanced options (optional: undername, TTL, network)
|
|
85
|
+
|
|
86
|
+
### Direct Commands
|
|
87
|
+
|
|
88
|
+
Use flags for faster, scriptable deployments:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
# Basic deployment with wallet file
|
|
92
|
+
permaweb-deploy deploy --arns-name my-app --wallet ./wallet.json
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Deploy using private key directly:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
permaweb-deploy deploy --arns-name my-app --private-key "$(cat wallet.json)"
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Deploy using environment variable:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
DEPLOY_KEY=$(base64 -i wallet.json) permaweb-deploy deploy --arns-name my-app
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Deploy a specific folder:
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
permaweb-deploy deploy --arns-name my-app --wallet ./wallet.json --deploy-folder ./build
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Deploy a single file:
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
permaweb-deploy deploy --arns-name my-app --wallet ./wallet.json --deploy-file ./path/to/file.txt
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### Advanced Usage
|
|
120
|
+
|
|
121
|
+
Deploy to an undername (subdomain):
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
permaweb-deploy deploy --arns-name my-app --wallet ./wallet.json --undername staging
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Deploy with a custom TTL:
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
permaweb-deploy deploy --arns-name my-app --wallet ./wallet.json --ttl-seconds 7200
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Deploy using Ethereum wallet (file):
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
permaweb-deploy deploy --arns-name my-app --sig-type ethereum --wallet ./private-key.txt
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Deploy using Ethereum wallet (direct key):
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
permaweb-deploy deploy --arns-name my-app --sig-type ethereum --private-key "0x1234..."
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### Command Options
|
|
146
|
+
|
|
147
|
+
- `--arns-name, -n` (required): The ArNS name to update
|
|
148
|
+
- `--ario-process, -p`: ARIO process to use (`mainnet`, `testnet`, or a custom process ID). Default: `mainnet`
|
|
149
|
+
- `--deploy-folder, -d`: Folder to deploy. Default: `./dist`
|
|
150
|
+
- `--deploy-file, -f`: Deploy a single file instead of a folder
|
|
151
|
+
- `--undername, -u`: ANT undername to update. Default: `@`
|
|
152
|
+
- `--ttl-seconds, -t`: TTL in seconds for the ANT record (60-86400). Default: `60`
|
|
153
|
+
- `--sig-type, -s`: Signer type for deployment. Choices: `arweave`, `ethereum`, `polygon`, `kyve`. Default: `arweave`
|
|
154
|
+
- `--wallet, -w`: Path to wallet file (JWK for Arweave, private key for Ethereum/Polygon/KYVE)
|
|
155
|
+
- `--private-key, -k`: Private key or JWK JSON string (alternative to `--wallet`)
|
|
156
|
+
|
|
157
|
+
### Package.json Scripts
|
|
158
|
+
|
|
159
|
+
Add deployment scripts to your `package.json`:
|
|
160
|
+
|
|
161
|
+
```json
|
|
162
|
+
{
|
|
163
|
+
"scripts": {
|
|
164
|
+
"build": "vite build",
|
|
165
|
+
"deploy": "pnpm build && permaweb-deploy deploy --arns-name <ARNS_NAME>",
|
|
166
|
+
"deploy:staging": "pnpm build && permaweb-deploy deploy --arns-name <ARNS_NAME> --undername staging",
|
|
167
|
+
"deploy:testnet": "pnpm build && permaweb-deploy deploy --arns-name <ARNS_NAME> --ario-process testnet"
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Then deploy with:
|
|
173
|
+
|
|
174
|
+
```bash
|
|
175
|
+
DEPLOY_KEY=$(base64 -i wallet.json) pnpm deploy
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
## GitHub Actions Workflow
|
|
179
|
+
|
|
180
|
+
To automate deployments, set up a GitHub Actions workflow:
|
|
181
|
+
|
|
182
|
+
```yaml
|
|
183
|
+
name: Deploy to Permaweb
|
|
184
|
+
|
|
185
|
+
on:
|
|
186
|
+
push:
|
|
187
|
+
branches:
|
|
188
|
+
- main
|
|
189
|
+
|
|
190
|
+
jobs:
|
|
191
|
+
publish:
|
|
192
|
+
runs-on: ubuntu-latest
|
|
193
|
+
steps:
|
|
194
|
+
- uses: actions/checkout@v4
|
|
195
|
+
|
|
196
|
+
- uses: pnpm/action-setup@v3
|
|
197
|
+
with:
|
|
198
|
+
version: 9
|
|
199
|
+
|
|
200
|
+
- uses: actions/setup-node@v4
|
|
201
|
+
with:
|
|
202
|
+
node-version: 20
|
|
203
|
+
cache: 'pnpm'
|
|
204
|
+
|
|
205
|
+
- run: pnpm install
|
|
206
|
+
|
|
207
|
+
- run: pnpm deploy
|
|
208
|
+
env:
|
|
209
|
+
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
## Development
|
|
213
|
+
|
|
214
|
+
### Setup
|
|
215
|
+
|
|
216
|
+
```bash
|
|
217
|
+
# Install dependencies
|
|
218
|
+
pnpm install
|
|
219
|
+
|
|
220
|
+
# Build the project
|
|
221
|
+
pnpm build
|
|
222
|
+
|
|
223
|
+
# Run in development mode
|
|
224
|
+
pnpm dev
|
|
225
|
+
|
|
226
|
+
# Run tests
|
|
227
|
+
pnpm test
|
|
228
|
+
|
|
229
|
+
# Run linter
|
|
230
|
+
pnpm lint
|
|
231
|
+
|
|
232
|
+
# Format code
|
|
233
|
+
pnpm format
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
### Project Structure
|
|
237
|
+
|
|
238
|
+
```
|
|
239
|
+
permaweb-deploy/
|
|
240
|
+
├── src/
|
|
241
|
+
│ ├── commands/ # oclif commands
|
|
242
|
+
│ │ └── deploy.ts
|
|
243
|
+
│ ├── types/ # TypeScript type definitions
|
|
244
|
+
│ │ └── index.ts
|
|
245
|
+
│ ├── utils/ # Utility functions
|
|
246
|
+
│ │ ├── constants.ts
|
|
247
|
+
│ │ ├── signer.ts
|
|
248
|
+
│ │ ├── uploader.ts
|
|
249
|
+
│ │ └── __tests__/ # Unit tests
|
|
250
|
+
│ └── index.ts # Main entry point
|
|
251
|
+
├── bin/ # Executable scripts
|
|
252
|
+
│ ├── run.js
|
|
253
|
+
│ └── dev.js
|
|
254
|
+
├── .changeset/ # Changesets configuration
|
|
255
|
+
├── .husky/ # Git hooks
|
|
256
|
+
└── dist/ # Build output
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
## Security & Best Practices
|
|
260
|
+
|
|
261
|
+
- **Dedicated Wallet:** Always use a dedicated wallet for deployments to minimize security risks
|
|
262
|
+
- **Wallet Encoding:** Arweave wallets must be base64 encoded to be used in the deployment script
|
|
263
|
+
- **ArNS Name:** The ArNS Name must be passed so that the ANT Process can be resolved to update the target undername or root record
|
|
264
|
+
- **Turbo Credits:** Ensure your wallet has sufficient Turbo Credits before deployment
|
|
265
|
+
- **Secret Management:** Keep your `DEPLOY_KEY` secret secure and never commit it to your repository
|
|
266
|
+
- **Build Security:** Always check your build for exposed environmental secrets before deployment, as data on Arweave is permanent
|
|
267
|
+
|
|
268
|
+
## Troubleshooting
|
|
269
|
+
|
|
270
|
+
- **Error: "DEPLOY_KEY environment variable not set":** Verify your base64 encoded wallet is set as the `DEPLOY_KEY` environment variable
|
|
271
|
+
- **Error: "deploy-folder does not exist":** Check that your build folder exists and the path is correct
|
|
272
|
+
- **Error: "deploy-file does not exist":** Check that your build file exists and the path is correct
|
|
273
|
+
- **Error: "ArNS name does not exist":** Verify the ArNS name is correct and exists in the specified network
|
|
274
|
+
- **Upload timeouts:** Files have a timeout for upload. Large files may fail and require optimization
|
|
275
|
+
- **Insufficient Turbo Credits:** Ensure your wallet has enough Turbo Credits for the deployment
|
|
276
|
+
|
|
277
|
+
## Contributing
|
|
278
|
+
|
|
279
|
+
Contributions are welcome! Please follow these guidelines:
|
|
280
|
+
|
|
281
|
+
1. Fork the repository
|
|
282
|
+
2. Create a feature branch
|
|
283
|
+
3. Make your changes
|
|
284
|
+
4. Run tests and linter: `pnpm test && pnpm lint`
|
|
285
|
+
5. Create a changeset: `pnpm changeset`
|
|
286
|
+
6. Commit your changes using conventional commits
|
|
287
|
+
7. Push and create a pull request
|
|
288
|
+
|
|
289
|
+
### Conventional Commits
|
|
290
|
+
|
|
291
|
+
This project uses [Conventional Commits](https://www.conventionalcommits.org/). Commit messages should follow this format:
|
|
292
|
+
|
|
293
|
+
```
|
|
294
|
+
type(scope): subject
|
|
295
|
+
|
|
296
|
+
body (optional)
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
Types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert`
|
|
300
|
+
|
|
301
|
+
### Changesets
|
|
302
|
+
|
|
303
|
+
We use [changesets](https://github.com/changesets/changesets) for version management. When making changes:
|
|
304
|
+
|
|
305
|
+
```bash
|
|
306
|
+
pnpm changeset
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
Follow the prompts to describe your changes.
|
|
310
|
+
|
|
311
|
+
## Dependencies
|
|
312
|
+
|
|
313
|
+
- **@ar.io/sdk** - For ANT operations and ArNS management
|
|
314
|
+
- **@ardrive/turbo-sdk** - For fast file uploads to Arweave
|
|
315
|
+
- **@permaweb/aoconnect** - For AO network connectivity
|
|
316
|
+
- **@oclif/core** - CLI framework
|
|
317
|
+
- **mime-types** - MIME type detection
|
|
318
|
+
|
|
319
|
+
## License
|
|
320
|
+
|
|
321
|
+
ISC
|
|
322
|
+
|
|
323
|
+
## Author
|
|
324
|
+
|
|
325
|
+
NickJ202
|
|
326
|
+
|
|
327
|
+
## Links
|
|
328
|
+
|
|
329
|
+
- [GitHub Repository](https://github.com/permaweb/permaweb-deploy)
|
|
330
|
+
- [Issues](https://github.com/permaweb/permaweb-deploy/issues)
|
|
331
|
+
- [Arweave Documentation](https://docs.arweave.org/)
|
|
332
|
+
- [AR.IO Documentation](https://docs.ar.io/)
|
package/bin/dev.js
ADDED
package/bin/run.js
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
function _typeof(o) { "@babel/helpers - typeof"; return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function (o) { return typeof o; } : function (o) { return o && "function" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? "symbol" : typeof o; }, _typeof(o); }
|
|
5
|
+
var _turboSdk = require("@ardrive/turbo-sdk");
|
|
6
|
+
var _fs = _interopRequireDefault(require("fs"));
|
|
7
|
+
var _yargs = _interopRequireDefault(require("yargs"));
|
|
8
|
+
var _helpers = require("yargs/helpers");
|
|
9
|
+
var _mimeTypes = _interopRequireDefault(require("mime-types"));
|
|
10
|
+
var _sdk = require("@ar.io/sdk");
|
|
11
|
+
var _aoconnect = require("@permaweb/aoconnect");
|
|
12
|
+
var _nodeStream = require("node:stream");
|
|
13
|
+
function _interopRequireDefault(e) { return e && e.__esModule ? e : { "default": e }; }
|
|
14
|
+
function _regenerator() { /*! regenerator-runtime -- Copyright (c) 2014-present, Facebook, Inc. -- license (MIT): https://github.com/babel/babel/blob/main/packages/babel-helpers/LICENSE */ var e, t, r = "function" == typeof Symbol ? Symbol : {}, n = r.iterator || "@@iterator", o = r.toStringTag || "@@toStringTag"; function i(r, n, o, i) { var c = n && n.prototype instanceof Generator ? n : Generator, u = Object.create(c.prototype); return _regeneratorDefine2(u, "_invoke", function (r, n, o) { var i, c, u, f = 0, p = o || [], y = !1, G = { p: 0, n: 0, v: e, a: d, f: d.bind(e, 4), d: function d(t, r) { return i = t, c = 0, u = e, G.n = r, a; } }; function d(r, n) { for (c = r, u = n, t = 0; !y && f && !o && t < p.length; t++) { var o, i = p[t], d = G.p, l = i[2]; r > 3 ? (o = l === n) && (u = i[(c = i[4]) ? 5 : (c = 3, 3)], i[4] = i[5] = e) : i[0] <= d && ((o = r < 2 && d < i[1]) ? (c = 0, G.v = n, G.n = i[1]) : d < l && (o = r < 3 || i[0] > n || n > l) && (i[4] = r, i[5] = n, G.n = l, c = 0)); } if (o || r > 1) return a; throw y = !0, n; } return function (o, p, l) { if (f > 1) throw TypeError("Generator is already running"); for (y && 1 === p && d(p, l), c = p, u = l; (t = c < 2 ? e : u) || !y;) { i || (c ? c < 3 ? (c > 1 && (G.n = -1), d(c, u)) : G.n = u : G.v = u); try { if (f = 2, i) { if (c || (o = "next"), t = i[o]) { if (!(t = t.call(i, u))) throw TypeError("iterator result is not an object"); if (!t.done) return t; u = t.value, c < 2 && (c = 0); } else 1 === c && (t = i["return"]) && t.call(i), c < 2 && (u = TypeError("The iterator does not provide a '" + o + "' method"), c = 1); i = e; } else if ((t = (y = G.n < 0) ? u : r.call(n, G)) !== a) break; } catch (t) { i = e, c = 1, u = t; } finally { f = 1; } } return { value: t, done: y }; }; }(r, o, i), !0), u; } var a = {}; function Generator() {} function GeneratorFunction() {} function GeneratorFunctionPrototype() {} t = Object.getPrototypeOf; var c = [][n] ? t(t([][n]())) : (_regeneratorDefine2(t = {}, n, function () { return this; }), t), u = GeneratorFunctionPrototype.prototype = Generator.prototype = Object.create(c); function f(e) { return Object.setPrototypeOf ? Object.setPrototypeOf(e, GeneratorFunctionPrototype) : (e.__proto__ = GeneratorFunctionPrototype, _regeneratorDefine2(e, o, "GeneratorFunction")), e.prototype = Object.create(u), e; } return GeneratorFunction.prototype = GeneratorFunctionPrototype, _regeneratorDefine2(u, "constructor", GeneratorFunctionPrototype), _regeneratorDefine2(GeneratorFunctionPrototype, "constructor", GeneratorFunction), GeneratorFunction.displayName = "GeneratorFunction", _regeneratorDefine2(GeneratorFunctionPrototype, o, "GeneratorFunction"), _regeneratorDefine2(u), _regeneratorDefine2(u, o, "Generator"), _regeneratorDefine2(u, n, function () { return this; }), _regeneratorDefine2(u, "toString", function () { return "[object Generator]"; }), (_regenerator = function _regenerator() { return { w: i, m: f }; })(); }
|
|
15
|
+
function _regeneratorDefine2(e, r, n, t) { var i = Object.defineProperty; try { i({}, "", {}); } catch (e) { i = 0; } _regeneratorDefine2 = function _regeneratorDefine(e, r, n, t) { if (r) i ? i(e, r, { value: n, enumerable: !t, configurable: !t, writable: !t }) : e[r] = n;else { var o = function o(r, n) { _regeneratorDefine2(e, r, function (e) { return this._invoke(r, n, e); }); }; o("next", 0), o("throw", 1), o("return", 2); } }, _regeneratorDefine2(e, r, n, t); }
|
|
16
|
+
function _toConsumableArray(r) { return _arrayWithoutHoles(r) || _iterableToArray(r) || _unsupportedIterableToArray(r) || _nonIterableSpread(); }
|
|
17
|
+
function _nonIterableSpread() { throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); }
|
|
18
|
+
function _iterableToArray(r) { if ("undefined" != typeof Symbol && null != r[Symbol.iterator] || null != r["@@iterator"]) return Array.from(r); }
|
|
19
|
+
function _arrayWithoutHoles(r) { if (Array.isArray(r)) return _arrayLikeToArray(r); }
|
|
20
|
+
function ownKeys(e, r) { var t = Object.keys(e); if (Object.getOwnPropertySymbols) { var o = Object.getOwnPropertySymbols(e); r && (o = o.filter(function (r) { return Object.getOwnPropertyDescriptor(e, r).enumerable; })), t.push.apply(t, o); } return t; }
|
|
21
|
+
function _objectSpread(e) { for (var r = 1; r < arguments.length; r++) { var t = null != arguments[r] ? arguments[r] : {}; r % 2 ? ownKeys(Object(t), !0).forEach(function (r) { _defineProperty(e, r, t[r]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function (r) { Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r)); }); } return e; }
|
|
22
|
+
function _defineProperty(e, r, t) { return (r = _toPropertyKey(r)) in e ? Object.defineProperty(e, r, { value: t, enumerable: !0, configurable: !0, writable: !0 }) : e[r] = t, e; }
|
|
23
|
+
function _toPropertyKey(t) { var i = _toPrimitive(t, "string"); return "symbol" == _typeof(i) ? i : i + ""; }
|
|
24
|
+
function _toPrimitive(t, r) { if ("object" != _typeof(t) || !t) return t; var e = t[Symbol.toPrimitive]; if (void 0 !== e) { var i = e.call(t, r || "default"); if ("object" != _typeof(i)) return i; throw new TypeError("@@toPrimitive must return a primitive value."); } return ("string" === r ? String : Number)(t); }
|
|
25
|
+
function _slicedToArray(r, e) { return _arrayWithHoles(r) || _iterableToArrayLimit(r, e) || _unsupportedIterableToArray(r, e) || _nonIterableRest(); }
|
|
26
|
+
function _nonIterableRest() { throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); }
|
|
27
|
+
function _unsupportedIterableToArray(r, a) { if (r) { if ("string" == typeof r) return _arrayLikeToArray(r, a); var t = {}.toString.call(r).slice(8, -1); return "Object" === t && r.constructor && (t = r.constructor.name), "Map" === t || "Set" === t ? Array.from(r) : "Arguments" === t || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(t) ? _arrayLikeToArray(r, a) : void 0; } }
|
|
28
|
+
function _arrayLikeToArray(r, a) { (null == a || a > r.length) && (a = r.length); for (var e = 0, n = Array(a); e < a; e++) n[e] = r[e]; return n; }
|
|
29
|
+
function _iterableToArrayLimit(r, l) { var t = null == r ? null : "undefined" != typeof Symbol && r[Symbol.iterator] || r["@@iterator"]; if (null != t) { var e, n, i, u, a = [], f = !0, o = !1; try { if (i = (t = t.call(r)).next, 0 === l) { if (Object(t) !== t) return; f = !1; } else for (; !(f = (e = i.call(t)).done) && (a.push(e.value), a.length !== l); f = !0); } catch (r) { o = !0, n = r; } finally { try { if (!f && null != t["return"] && (u = t["return"](), Object(u) !== u)) return; } finally { if (o) throw n; } } return a; } }
|
|
30
|
+
function _arrayWithHoles(r) { if (Array.isArray(r)) return r; }
|
|
31
|
+
function asyncGeneratorStep(n, t, e, r, o, a, c) { try { var i = n[a](c), u = i.value; } catch (n) { return void e(n); } i.done ? t(u) : Promise.resolve(u).then(r, o); }
|
|
32
|
+
function _asyncToGenerator(n) { return function () { var t = this, e = arguments; return new Promise(function (r, o) { var a = n.apply(t, e); function _next(n) { asyncGeneratorStep(a, r, o, _next, _throw, "next", n); } function _throw(n) { asyncGeneratorStep(a, r, o, _next, _throw, "throw", n); } _next(void 0); }); }; }
|
|
33
|
+
var arweaveTxIdRegex = /^[a-zA-Z0-9-_]{43}$/;
|
|
34
|
+
var argv = (0, _yargs["default"])((0, _helpers.hideBin)(process.argv)).version('2.1.0').help().usage('Usage: $0 --arns-name <name> [options]').example('$0 --arns-name my-app', 'Deploy to my-app.arweave.dev').example('$0 --arns-name my-app --undername staging', 'Deploy to staging.my-app.arweave.dev').option('ario-process', {
|
|
35
|
+
alias: 'p',
|
|
36
|
+
type: 'string',
|
|
37
|
+
description: 'The ARIO process to use',
|
|
38
|
+
demandOption: true,
|
|
39
|
+
"default": _sdk.ARIO_MAINNET_PROCESS_ID
|
|
40
|
+
}).option('arns-name', {
|
|
41
|
+
alias: 'n',
|
|
42
|
+
type: 'string',
|
|
43
|
+
description: 'The ARNS name',
|
|
44
|
+
demandOption: true
|
|
45
|
+
}).option('deploy-folder', {
|
|
46
|
+
alias: 'd',
|
|
47
|
+
type: 'string',
|
|
48
|
+
description: 'Folder to deploy.',
|
|
49
|
+
"default": './dist'
|
|
50
|
+
}).option('deploy-file', {
|
|
51
|
+
alias: 'f',
|
|
52
|
+
type: 'string',
|
|
53
|
+
description: 'File to deploy.'
|
|
54
|
+
}).option('ttl-seconds', {
|
|
55
|
+
alias: 't',
|
|
56
|
+
type: 'number',
|
|
57
|
+
description: 'ArNS TTL Seconds',
|
|
58
|
+
"default": 60
|
|
59
|
+
}).option('undername', {
|
|
60
|
+
alias: 'u',
|
|
61
|
+
type: 'string',
|
|
62
|
+
description: 'ANT undername to update.',
|
|
63
|
+
"default": '@'
|
|
64
|
+
}).option('sig-type', {
|
|
65
|
+
alias: 's',
|
|
66
|
+
type: 'string',
|
|
67
|
+
description: 'The type of signer to be used for deployment.',
|
|
68
|
+
choices: ['arweave', 'ethereum', 'polygon',
|
|
69
|
+
// 'solana',
|
|
70
|
+
'kyve'],
|
|
71
|
+
"default": 'arweave'
|
|
72
|
+
}).check(function (argv) {
|
|
73
|
+
if (argv.ttl < 60 || argv.ttl > 86400) {
|
|
74
|
+
throw new Error('TTL must be between 60 seconds (1 minute) and 86400 seconds (1 day)');
|
|
75
|
+
}
|
|
76
|
+
return true;
|
|
77
|
+
}).argv;
|
|
78
|
+
var DEPLOY_KEY = process.env.DEPLOY_KEY;
|
|
79
|
+
var ARNS_NAME = argv['arns-name'];
|
|
80
|
+
var ARIO_PROCESS = argv['ario-process'];
|
|
81
|
+
var TTL_SECONDS = argv['ttl-seconds'];
|
|
82
|
+
if (ARIO_PROCESS === 'mainnet') {
|
|
83
|
+
ARIO_PROCESS = _sdk.ARIO_MAINNET_PROCESS_ID;
|
|
84
|
+
} else if (ARIO_PROCESS === 'testnet') {
|
|
85
|
+
ARIO_PROCESS = _sdk.ARIO_TESTNET_PROCESS_ID;
|
|
86
|
+
}
|
|
87
|
+
_asyncToGenerator(/*#__PURE__*/_regenerator().m(function _callee() {
|
|
88
|
+
var ario, arnsNameRecord, signer, token, jwk, turbo, uploadResult, txOrManifestId, mimeType, origPaths, newPaths, replaceManifest, _i, _Object$entries, _Object$entries$_i, key, value, newKey, newManifest, buffer, _yield$turbo$uploadFi, id, ant, _t, _t2;
|
|
89
|
+
return _regenerator().w(function (_context) {
|
|
90
|
+
while (1) switch (_context.n) {
|
|
91
|
+
case 0:
|
|
92
|
+
if (!ARIO_PROCESS || !arweaveTxIdRegex.test(ARIO_PROCESS)) {
|
|
93
|
+
console.error('ARIO_PROCESS must be a valid Arweave transaction ID, or "mainnet" or "testnet"');
|
|
94
|
+
process.exit(1);
|
|
95
|
+
}
|
|
96
|
+
if (!DEPLOY_KEY) {
|
|
97
|
+
console.error('DEPLOY_KEY not configured');
|
|
98
|
+
process.exit(1);
|
|
99
|
+
}
|
|
100
|
+
if (!ARNS_NAME) {
|
|
101
|
+
console.error('ARNS_NAME not configured');
|
|
102
|
+
process.exit(1);
|
|
103
|
+
}
|
|
104
|
+
if (!Number.isFinite(TTL_SECONDS) || TTL_SECONDS < 60 || TTL_SECONDS > 86400) {
|
|
105
|
+
console.error('TTL_SECONDS must be a number between 60 and 86400 seconds');
|
|
106
|
+
process.exit(1);
|
|
107
|
+
}
|
|
108
|
+
if (argv.deployFile && !_fs["default"].existsSync(argv.deployFile)) {
|
|
109
|
+
console.error("deploy-file [".concat(argv.deployFolder, "] does not exist"));
|
|
110
|
+
process.exit(1);
|
|
111
|
+
} else {
|
|
112
|
+
if (!_fs["default"].existsSync(argv.deployFolder)) {
|
|
113
|
+
console.error("deploy-folder [".concat(argv.deployFolder, "] does not exist"));
|
|
114
|
+
process.exit(1);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (argv.undername.length === 0) {
|
|
118
|
+
console.error('undername must be set');
|
|
119
|
+
process.exit(1);
|
|
120
|
+
}
|
|
121
|
+
ario = _sdk.ARIO.init({
|
|
122
|
+
process: new _sdk.AOProcess({
|
|
123
|
+
processId: ARIO_PROCESS,
|
|
124
|
+
ao: (0, _aoconnect.connect)({
|
|
125
|
+
MODE: 'legacy',
|
|
126
|
+
CU_URL: 'https://cu.ardrive.io'
|
|
127
|
+
})
|
|
128
|
+
})
|
|
129
|
+
});
|
|
130
|
+
_context.n = 1;
|
|
131
|
+
return ario.getArNSRecord({
|
|
132
|
+
name: ARNS_NAME
|
|
133
|
+
})["catch"](function (e) {
|
|
134
|
+
console.error("ARNS name [".concat(ARNS_NAME, "] does not exist"));
|
|
135
|
+
process.exit(1);
|
|
136
|
+
});
|
|
137
|
+
case 1:
|
|
138
|
+
arnsNameRecord = _context.v;
|
|
139
|
+
_context.p = 2;
|
|
140
|
+
_t = argv['sig-type'];
|
|
141
|
+
_context.n = _t === 'ethereum' ? 3 : _t === 'polygon' ? 4 : _t === 'arweave' ? 5 : _t === 'kyve' ? 6 : 7;
|
|
142
|
+
break;
|
|
143
|
+
case 3:
|
|
144
|
+
signer = new _turboSdk.EthereumSigner(DEPLOY_KEY);
|
|
145
|
+
token = 'ethereum';
|
|
146
|
+
return _context.a(3, 8);
|
|
147
|
+
case 4:
|
|
148
|
+
signer = new _turboSdk.EthereumSigner(DEPLOY_KEY);
|
|
149
|
+
token = 'pol';
|
|
150
|
+
return _context.a(3, 8);
|
|
151
|
+
case 5:
|
|
152
|
+
jwk = JSON.parse(Buffer.from(DEPLOY_KEY, 'base64').toString('utf-8'));
|
|
153
|
+
signer = new _sdk.ArweaveSigner(jwk);
|
|
154
|
+
token = 'arweave';
|
|
155
|
+
return _context.a(3, 8);
|
|
156
|
+
case 6:
|
|
157
|
+
signer = new _turboSdk.EthereumSigner(DEPLOY_KEY);
|
|
158
|
+
token = 'kyve';
|
|
159
|
+
return _context.a(3, 8);
|
|
160
|
+
case 7:
|
|
161
|
+
throw new Error("Invalid sig-type provided: ".concat(argv['sig-type'], ". Allowed values are 'arweave', 'ethereum', 'polygon', or 'kyve'."));
|
|
162
|
+
case 8:
|
|
163
|
+
turbo = _turboSdk.TurboFactory.authenticated({
|
|
164
|
+
signer: signer,
|
|
165
|
+
token: token
|
|
166
|
+
});
|
|
167
|
+
if (!argv['deploy-file']) {
|
|
168
|
+
_context.n = 10;
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
// Detect MIME type for the file
|
|
172
|
+
mimeType = _mimeTypes["default"].lookup(argv['deploy-file']) || 'application/octet-stream';
|
|
173
|
+
_context.n = 9;
|
|
174
|
+
return turbo.uploadFile({
|
|
175
|
+
file: argv['deploy-file'],
|
|
176
|
+
dataItemOpts: {
|
|
177
|
+
tags: [{
|
|
178
|
+
name: 'App-Name',
|
|
179
|
+
value: 'Permaweb-Deploy'
|
|
180
|
+
},
|
|
181
|
+
// prevents identical transaction Ids from eth wallets
|
|
182
|
+
{
|
|
183
|
+
name: 'anchor',
|
|
184
|
+
value: new Date().toISOString()
|
|
185
|
+
}, {
|
|
186
|
+
name: 'Content-Type',
|
|
187
|
+
value: mimeType
|
|
188
|
+
}]
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
case 9:
|
|
192
|
+
uploadResult = _context.v;
|
|
193
|
+
txOrManifestId = uploadResult.id;
|
|
194
|
+
_context.n = 13;
|
|
195
|
+
break;
|
|
196
|
+
case 10:
|
|
197
|
+
_context.n = 11;
|
|
198
|
+
return turbo.uploadFolder({
|
|
199
|
+
folderPath: argv['deploy-folder'],
|
|
200
|
+
dataItemOpts: {
|
|
201
|
+
tags: [{
|
|
202
|
+
name: 'App-Name',
|
|
203
|
+
value: 'Permaweb-Deploy'
|
|
204
|
+
},
|
|
205
|
+
// prevents identical transaction Ids from eth wallets
|
|
206
|
+
{
|
|
207
|
+
name: 'anchor',
|
|
208
|
+
value: new Date().toISOString()
|
|
209
|
+
}]
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
case 11:
|
|
213
|
+
uploadResult = _context.v;
|
|
214
|
+
txOrManifestId = uploadResult.manifestResponse.id; //might replace now
|
|
215
|
+
|
|
216
|
+
// Make default folder paths work by adding extra path entries
|
|
217
|
+
origPaths = uploadResult.manifest.paths;
|
|
218
|
+
newPaths = {};
|
|
219
|
+
replaceManifest = false;
|
|
220
|
+
for (_i = 0, _Object$entries = Object.entries(origPaths); _i < _Object$entries.length; _i++) {
|
|
221
|
+
_Object$entries$_i = _slicedToArray(_Object$entries[_i], 2), key = _Object$entries$_i[0], value = _Object$entries$_i[1];
|
|
222
|
+
newPaths[key] = value;
|
|
223
|
+
if (key.endsWith('/index.html')) {
|
|
224
|
+
newKey = key.replace(/\/index\.html$/, '');
|
|
225
|
+
newPaths[newKey] = value;
|
|
226
|
+
replaceManifest = true;
|
|
227
|
+
}
|
|
228
|
+
;
|
|
229
|
+
}
|
|
230
|
+
;
|
|
231
|
+
if (!replaceManifest) {
|
|
232
|
+
_context.n = 13;
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
console.info('replacing manifest');
|
|
236
|
+
newManifest = _objectSpread(_objectSpread({}, uploadResult.manifest), {}, {
|
|
237
|
+
paths: newPaths
|
|
238
|
+
});
|
|
239
|
+
buffer = Buffer.from(JSON.stringify(newManifest));
|
|
240
|
+
_context.n = 12;
|
|
241
|
+
return turbo.uploadFile({
|
|
242
|
+
fileStreamFactory: function fileStreamFactory() {
|
|
243
|
+
return _nodeStream.Readable.from(buffer);
|
|
244
|
+
},
|
|
245
|
+
fileSizeFactory: function fileSizeFactory() {
|
|
246
|
+
return buffer.length;
|
|
247
|
+
},
|
|
248
|
+
dataItemOpts: {
|
|
249
|
+
tags: [{
|
|
250
|
+
name: 'Content-Type',
|
|
251
|
+
value: 'application/x.arweave-manifest+json'
|
|
252
|
+
}]
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
case 12:
|
|
256
|
+
_yield$turbo$uploadFi = _context.v;
|
|
257
|
+
id = _yield$turbo$uploadFi.id;
|
|
258
|
+
txOrManifestId = id;
|
|
259
|
+
case 13:
|
|
260
|
+
console.log('-------------------- DEPLOY DETAILS --------------------');
|
|
261
|
+
console.log("Tx ID: ".concat(txOrManifestId));
|
|
262
|
+
console.log("ArNS Name: ".concat(ARNS_NAME));
|
|
263
|
+
console.log("Undername: ".concat(argv.undername));
|
|
264
|
+
console.log("ANT: ".concat(arnsNameRecord.processId));
|
|
265
|
+
console.log("AR IO Process: ".concat(ARIO_PROCESS));
|
|
266
|
+
console.log("TTL Seconds: ".concat(TTL_SECONDS));
|
|
267
|
+
console.log('--------------------------------------------------------');
|
|
268
|
+
ant = _sdk.ANT.init({
|
|
269
|
+
processId: arnsNameRecord.processId,
|
|
270
|
+
signer: signer
|
|
271
|
+
}); // Update the ANT record (assumes the JWK is a controller or owner)
|
|
272
|
+
_context.n = 14;
|
|
273
|
+
return ant.setRecord({
|
|
274
|
+
undername: argv.undername,
|
|
275
|
+
transactionId: txOrManifestId,
|
|
276
|
+
ttlSeconds: argv['ttl-seconds']
|
|
277
|
+
}, {
|
|
278
|
+
tags: [{
|
|
279
|
+
name: 'App-Name',
|
|
280
|
+
value: 'Permaweb-Deploy'
|
|
281
|
+
}].concat(_toConsumableArray(process.env.GITHUB_SHA ? [{
|
|
282
|
+
name: 'GIT-HASH',
|
|
283
|
+
value: process.env.GITHUB_SHA
|
|
284
|
+
}] : []))
|
|
285
|
+
});
|
|
286
|
+
case 14:
|
|
287
|
+
console.log("Deployed TxId [".concat(txOrManifestId, "] to name [").concat(ARNS_NAME, "] for ANT [").concat(arnsNameRecord.processId, "] using undername [").concat(argv.undername, "]"));
|
|
288
|
+
_context.n = 16;
|
|
289
|
+
break;
|
|
290
|
+
case 15:
|
|
291
|
+
_context.p = 15;
|
|
292
|
+
_t2 = _context.v;
|
|
293
|
+
console.error('Deployment failed:', _t2);
|
|
294
|
+
process.exit(1); // Exit with error code
|
|
295
|
+
case 16:
|
|
296
|
+
return _context.a(2);
|
|
297
|
+
}
|
|
298
|
+
}, _callee, null, [[2, 15]]);
|
|
299
|
+
}))();
|
package/package.json
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "permaweb-deploy",
|
|
3
|
+
"version": "0.0.0-rc-20251001215907",
|
|
4
|
+
"description": "Permaweb App Deployment Package",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"bin": {
|
|
9
|
+
"permaweb-deploy": "./bin/run.js"
|
|
10
|
+
},
|
|
11
|
+
"oclif": {
|
|
12
|
+
"bin": "permaweb-deploy",
|
|
13
|
+
"dirname": "permaweb-deploy",
|
|
14
|
+
"commands": "./dist/commands",
|
|
15
|
+
"topicSeparator": " ",
|
|
16
|
+
"default": "interactive",
|
|
17
|
+
"topics": {
|
|
18
|
+
"deploy": {
|
|
19
|
+
"description": "Deploy to the permaweb"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"dist",
|
|
25
|
+
"bin",
|
|
26
|
+
"oclif.manifest.json"
|
|
27
|
+
],
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@ar.io/sdk": "^3.10.1",
|
|
30
|
+
"@ardrive/turbo-sdk": "^1.28.2",
|
|
31
|
+
"@inquirer/prompts": "^7.2.0",
|
|
32
|
+
"@oclif/core": "^4.0.30",
|
|
33
|
+
"@permaweb/aoconnect": "^0.0.85",
|
|
34
|
+
"boxen": "^8.0.1",
|
|
35
|
+
"chalk": "^5.3.0",
|
|
36
|
+
"cli-table3": "^0.6.5",
|
|
37
|
+
"mime-types": "^3.0.1",
|
|
38
|
+
"ora": "^8.1.1"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@changesets/cli": "^2.27.10",
|
|
42
|
+
"@commitlint/cli": "^19.6.0",
|
|
43
|
+
"@commitlint/config-conventional": "^19.6.0",
|
|
44
|
+
"@oclif/prettier-config": "^0.2.1",
|
|
45
|
+
"@types/mime-types": "^2.1.4",
|
|
46
|
+
"@types/node": "^22.10.2",
|
|
47
|
+
"@typescript-eslint/eslint-plugin": "^8.18.1",
|
|
48
|
+
"@typescript-eslint/parser": "^8.18.1",
|
|
49
|
+
"@vitest/coverage-v8": "^2.1.8",
|
|
50
|
+
"eslint": "^8.57.1",
|
|
51
|
+
"eslint-config-oclif": "^5.2.1",
|
|
52
|
+
"eslint-config-oclif-typescript": "^3.1.12",
|
|
53
|
+
"eslint-config-prettier": "^9.1.0",
|
|
54
|
+
"eslint-import-resolver-typescript": "^3.7.0",
|
|
55
|
+
"eslint-plugin-import": "^2.31.0",
|
|
56
|
+
"eslint-plugin-prettier": "^5.2.1",
|
|
57
|
+
"eslint-plugin-simple-import-sort": "^12.1.1",
|
|
58
|
+
"husky": "^9.1.7",
|
|
59
|
+
"prettier": "^3.4.2",
|
|
60
|
+
"tsx": "^4.19.2",
|
|
61
|
+
"typescript": "^5.7.2",
|
|
62
|
+
"vite": "^6.0.5",
|
|
63
|
+
"vite-plugin-dts": "^4.3.0",
|
|
64
|
+
"vite-tsconfig-paths": "^5.1.4",
|
|
65
|
+
"vitest": "^2.1.8"
|
|
66
|
+
},
|
|
67
|
+
"engines": {
|
|
68
|
+
"node": ">=18.0.0"
|
|
69
|
+
},
|
|
70
|
+
"repository": {
|
|
71
|
+
"type": "git",
|
|
72
|
+
"url": "git+https://github.com/permaweb/permaweb-deploy.git"
|
|
73
|
+
},
|
|
74
|
+
"author": "NickJ202",
|
|
75
|
+
"license": "ISC",
|
|
76
|
+
"bugs": {
|
|
77
|
+
"url": "https://github.com/permaweb/permaweb-deploy/issues"
|
|
78
|
+
},
|
|
79
|
+
"homepage": "https://github.com/permaweb/permaweb-deploy#readme",
|
|
80
|
+
"scripts": {
|
|
81
|
+
"build": "vite build && tsc --emitDeclarationOnly",
|
|
82
|
+
"dev": "tsx src/index.ts",
|
|
83
|
+
"test": "vitest",
|
|
84
|
+
"test:run": "vitest run",
|
|
85
|
+
"test:coverage": "vitest run --coverage",
|
|
86
|
+
"lint": "eslint . --ext .ts",
|
|
87
|
+
"lint:fix": "eslint . --ext .ts --fix",
|
|
88
|
+
"format": "prettier --write \"src/**/*.ts\"",
|
|
89
|
+
"format:check": "prettier --check \"src/**/*.ts\"",
|
|
90
|
+
"changeset": "changeset",
|
|
91
|
+
"version": "changeset version",
|
|
92
|
+
"version:alpha": "changeset version --snapshot alpha",
|
|
93
|
+
"release": "changeset publish",
|
|
94
|
+
"release:alpha": "changeset publish --tag alpha"
|
|
95
|
+
}
|
|
96
|
+
}
|