Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ The CLI uses legacy ANS-104 bundlers for the default upload path, supports Hyper

- **Arweave uploads:** Upload a folder or a single file.
- **Names publishing:** Update a Permaweb Name after upload.
- **Permagit publishing:** Optionally publish the current committed Git `HEAD` to Permagit with the deployment wallet.
- **Legacy bundler uploads:** Use legacy ANS-104 upload endpoints by default.
- **HyperBEAM uploads:** Sign ANS-104 items and post them to a HyperBEAM bundler route.
- **Arweave manifests:** Create manifest `0.2.0` documents for folder deployments, with SPA fallback detection.
Expand Down Expand Up @@ -62,6 +63,12 @@ Deploy and update a Permaweb Name:
permaweb-deploy deploy --use-names --name my-app --deploy-folder ./dist
```

Deploy and publish the current Git `HEAD` to Permagit with the same Arweave JWK:

```bash
permaweb-deploy deploy --deploy-folder ./dist --permagit my-app
```

Or deploy and update a direct reference ID:

```bash
Expand Down Expand Up @@ -105,6 +112,8 @@ Set the encoded value as `DEPLOY_KEY`, or pass `--wallet ./wallet.json`.

Names updates currently require `--sig-type arweave`. Legacy references are signed as ANS-104 data items; carrier-backed names are signed as Arweave transactions. Ethereum, Polygon, and KYVE signers remain supported for upload-only flows.

Permagit publishing also requires `--sig-type arweave` and a Git executable. It publishes the current committed `HEAD`; it does not stage files, create a local commit, change the current branch, or add a Git remote.

Use a dedicated deployment wallet and make sure it has enough upload credits/balance for the selected legacy bundler.

## CLI Usage
Expand Down Expand Up @@ -209,6 +218,8 @@ HyperBEAM uploads require an Arweave JWK signer. The default route is `/~bundler
- `--sig-type, -s`: signer type. Choices: `arweave`, `ethereum`, `polygon`, `kyve`. Default: `arweave`.
- `--wallet, -w`: path to wallet file.
- `--private-key, -k`: private key or JWK JSON string.
- `--permagit`: publish the current committed Git `HEAD` to this Permagit repository after upload.
- `--permagit-ref`: Permagit branch name. Defaults to the current Git branch.
- `--no-dedupe`: disable dedupe cache.
- `--dedupe-cache-max-entries`: LRU cache size. Default: `10000`.
- `--uploader`: bundler service base URL. Legacy uploads default to `https://up.arweave.net`; HyperBEAM uploads auto-discover an active PermawebOS uploader when omitted.
Expand Down Expand Up @@ -342,6 +353,8 @@ permaweb-deploy/

- **`DEPLOY_KEY environment variable not set`:** pass `--wallet`, pass `--private-key`, or set `DEPLOY_KEY`.
- **`Names updates currently require --sig-type arweave`:** use an Arweave JWK for names updates.
- **`--permagit requires an Arweave JWK`:** use `--sig-type arweave` and provide an Arweave JWK.
- **`--permagit must be run inside a Git repository`:** run the command from a checked-out repository.
- **`Name [...] not found in namespace ...`:** verify the namespace manifest and spelling.
- **`Name [...] is controlled by ..., not signer ...`:** use the wallet that controls the name.
- **`signer is not reference authority`:** use the wallet that controls the target legacy reference.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@permaweb/deploy",
"version": "6.0.0",
"version": "6.1.0",
"description": "Permaweb App Deployment Package",
"main": "dist/index.js",
"types": "dist/index.d.ts",
Expand Down
48 changes: 42 additions & 6 deletions src/commands/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,25 @@ import { type DisplayRow, formatDisplayRows, formatUploadError } from '../utils/
import { hyperbeamBundlerLink } from '../utils/hyperbeam-uploader.js'
import { preflightNamesUpdate, publishNamesUpdate } from '../utils/names.js'
import { expandPath } from '../utils/path.js'
import {
type PermagitPublishResult,
preparePermagitTarget,
publishPermagit,
} from '../utils/permagit.js'
import { runUploadWorkflow } from '../workflows/upload-workflow.js'

function appendPermagitRows(rows: DisplayRow[], result: PermagitPublishResult | undefined): void {
if (!result) return

rows.push(
['Permagit Repo', chalk.cyan(`arweave://${result.repoName}`)],
['Permagit Ref', chalk.cyan(result.refName)],
['Git Commit', chalk.green(result.commitSha)],
['Permagit Pack ID', chalk.green(result.packTransactionId)],
['Permagit Ref ID', chalk.green(result.refTransactionId)],
)
}

export default class Deploy extends Command {
static override args = {}

Expand All @@ -28,6 +45,7 @@ export default class Deploy extends Command {
'<%= config.bin %> deploy --wallet ./wallet.json --deploy-folder ./dist',
'<%= config.bin %> deploy --wallet ./wallet.json --deploy-file ./dist/index.html',
'<%= config.bin %> deploy --wallet ./wallet.json --uploader-type hyperbeam --uploader https://hyperbeam.example.com',
'<%= config.bin %> deploy --wallet ./wallet.json --permagit my-app',
'<%= config.bin %> deploy --wallet ./wallet.json --use-names --name my-app',
'<%= config.bin %> deploy --wallet ./wallet.json --use-names --reference-id REFERENCE_ID',
]
Expand All @@ -49,6 +67,10 @@ export default class Deploy extends Command {
interactive,
})) as DeployConfig

if (baseConfig.permagit && baseConfig['sig-type'] !== 'arweave') {
this.error('--permagit requires an Arweave JWK (--sig-type arweave)')
}

let walletConfig: { privateKey?: string; wallet?: string } = {
privateKey: baseConfig['private-key'],
wallet: baseConfig.wallet,
Expand Down Expand Up @@ -131,10 +153,24 @@ export default class Deploy extends Command {

this.log(chalk.bold(chalk.cyan('\nStarting deployment...\n')))
try {
const permagitTarget = baseConfig.permagit
? preparePermagitTarget(baseConfig.permagit, deployKey, baseConfig['permagit-ref'])
: undefined
let permagitResult: PermagitPublishResult | undefined
const upload = () =>
runUploadWorkflow(
deployKey,
deployConfig,
{ error: (message) => this.error(message) },
permagitTarget
? async (uploadClient) => {
permagitResult = await publishPermagit(permagitTarget, uploadClient)
}
: undefined,
)

if (!deployConfig['use-names']) {
const uploadResult = await runUploadWorkflow(deployKey, deployConfig, {
error: (msg) => this.error(msg),
})
const uploadResult = await upload()
const txOrManifestId = uploadResult.transactionId
const effectiveUploader = uploadResult.uploader ?? deployConfig.uploader

Expand All @@ -161,6 +197,7 @@ export default class Deploy extends Command {
rows.push(['Bundler link', chalk.yellow(bundlerLink)])
}

appendPermagitRows(rows, permagitResult)
rows.push(['Arweave URL', chalk.yellow(`https://arweave.net/${txOrManifestId}`)])

this.log(chalk.bold(chalk.green('Deployment Successful!')))
Expand Down Expand Up @@ -196,9 +233,7 @@ export default class Deploy extends Command {

spinner.succeed('Names target validated')

const uploadResult = await runUploadWorkflow(deployKey, deployConfig, {
error: (msg) => this.error(msg),
})
const uploadResult = await upload()
const txOrManifestId = uploadResult.transactionId
const effectiveUploader = uploadResult.uploader ?? deployConfig.uploader

Expand Down Expand Up @@ -239,6 +274,7 @@ export default class Deploy extends Command {
rows.push(['Bundler link', chalk.yellow(bundlerLink)])
}

appendPermagitRows(rows, permagitResult)
rows.push(
...(namesUpdate.name ? ([['Name', chalk.yellow(namesUpdate.name)]] as DisplayRow[]) : []),
['Names Target Kind', chalk.cyan(namesUpdate.kind)],
Expand Down
17 changes: 17 additions & 0 deletions src/constants/flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,17 @@ export const globalFlags = {
required: false,
}),
}),
permagit: createFlagConfig<string | undefined>({
flag: Flags.string({
description: 'Publish the current Git HEAD to the named Permagit repository after upload',
}),
}),
permagitRef: createFlagConfig<string | undefined>({
flag: Flags.string({
dependsOn: ['permagit'],
description: 'Permagit branch name (defaults to the current Git branch)',
}),
}),
privateKey: createFlagConfig<string | undefined>({
flag: Flags.string({
char: 'k',
Expand Down Expand Up @@ -234,6 +245,8 @@ export const deployFlags = {
'names-namespace': globalFlags.namesNamespace.flag,
'names-node': globalFlags.namesNode.flag,
'no-dedupe': globalFlags.noDedupe.flag,
permagit: globalFlags.permagit.flag,
'permagit-ref': globalFlags.permagitRef.flag,
'private-key': globalFlags.privateKey.flag,
'reference-id': globalFlags.referenceId.flag,
'sig-type': globalFlags.sigType.flag,
Expand Down Expand Up @@ -285,6 +298,8 @@ export interface DeployConfig {
'names-node'?: string
'no-dedupe': boolean
'private-key'?: string
permagit?: string
'permagit-ref'?: string
'reference-id'?: string
'sig-type': string
'use-names': boolean
Expand Down Expand Up @@ -313,6 +328,8 @@ export const deployFlagConfigs = {
'names-namespace': globalFlags.namesNamespace,
'names-node': globalFlags.namesNode,
'no-dedupe': globalFlags.noDedupe,
permagit: globalFlags.permagit,
'permagit-ref': globalFlags.permagitRef,
'private-key': globalFlags.privateKey,
'reference-id': globalFlags.referenceId,
'sig-type': globalFlags.sigType,
Expand Down
141 changes: 141 additions & 0 deletions src/utils/__tests__/permagit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { createHash } from 'node:crypto'

import { describe, expect, it } from 'vitest'

import { TEST_ARWEAVE_WALLET } from '../../../tests/constants.js'
import type { UploadClient, UploadFileArgs } from '../hyperbeam-uploader.js'
import { preparePermagitTarget, publishPermagit } from '../permagit.js'

const DEPLOY_KEY = Buffer.from(JSON.stringify(TEST_ARWEAVE_WALLET)).toString('base64')

interface CapturedUpload {
data: Buffer
id: string
tags: Record<string, string>
}

function mockUploadClient(): { client: UploadClient; uploads: CapturedUpload[] } {
const uploads: CapturedUpload[] = []
const client: UploadClient = {
async uploadFile(args: UploadFileArgs) {
const data = Buffer.isBuffer(args.file) ? args.file : Buffer.from('')
const id = `upload-${uploads.length + 1}`
uploads.push({
data,
id,
tags: Object.fromEntries(
(args.dataItemOpts?.tags ?? []).map((tag) => [tag.name, tag.value]),
),
})
return { id }
},
}

return { client, uploads }
}

function target(repoName = 'my-app', branch = 'main') {
return preparePermagitTarget(repoName, DEPLOY_KEY, branch)
}

describe('Permagit publishing', () => {
it('prepares the current committed HEAD without changing the repository', () => {
const prepared = target('my-app', 'test-branch')

expect(prepared.branch).toBe('test-branch')
expect(prepared.commitSha).toMatch(/^[\da-f]{40}$/)
expect(prepared.gitRoot).toBe(process.cwd())
expect(prepared.ownerAddress).toBe(
createHash('sha256')
.update(Buffer.from(TEST_ARWEAVE_WALLET.n, 'base64url'))
.digest('base64url'),
)
})

it('uploads a Permagit-compatible full pack and ref', async () => {
const prepared = target()
const { client, uploads } = mockUploadClient()

const result = await publishPermagit(prepared, client, 10 * 1024 * 1024)

expect(uploads).toHaveLength(2)
expect(uploads[0].data.subarray(0, 4).toString('ascii')).toBe('PACK')
expect(uploads[0].tags).toEqual({
'App-Name': 'permagit',
'Content-Type': 'application/x-git-pack',
Owner: prepared.ownerAddress,
'Pack-Type': 'full',
Repo: 'my-app',
Type: 'pack',
})
expect(JSON.parse(uploads[1].data.toString('utf8'))).toEqual({
old: '0'.repeat(40),
packTx: uploads[0].id,
ref: 'refs/heads/main',
snapshotTx: null,
target: prepared.commitSha,
})
expect(uploads[1].tags).toEqual({
'App-Name': 'permagit',
Owner: prepared.ownerAddress,
'Pack-Tx': uploads[0].id,
'Ref-Name': 'refs/heads/main',
'Ref-Old': '0'.repeat(40),
'Ref-Target': prepared.commitSha,
Repo: 'my-app',
Type: 'ref',
})
expect(result.packTransactionId).toBe(uploads[0].id)
expect(result.refTransactionId).toBe(uploads[1].id)
})

it('uses Permagit pack chunks and a pack manifest for larger packs', async () => {
const prepared = target('chunked-app')
const { client, uploads } = mockUploadClient()

const result = await publishPermagit(prepared, client, 512 * 1024)

const chunks = uploads.filter((upload) => upload.tags.Type === 'pack-chunk')
const manifest = uploads.find(
(upload) => upload.tags.Type === 'pack' && upload.tags['Pack-Type'] === 'chunked',
)
expect(chunks.length).toBeGreaterThan(1)
expect(manifest).toBeDefined()
const group = manifest!.tags['Pack-Group']
expect(JSON.parse(manifest!.data.toString('utf8'))).toEqual({
chunks: chunks.map((chunk) => chunk.id),
group,
totalSize: chunks.reduce((total, chunk) => total + chunk.data.byteLength, 0),
type: 'pack-manifest',
})
expect(chunks[0].tags).toEqual({
'App-Name': 'permagit',
'Chunk-Index': '0',
'Chunk-Total': String(chunks.length),
'Content-Type': 'application/octet-stream',
Owner: prepared.ownerAddress,
'Pack-Group': group,
'Pack-Type': 'full',
Repo: 'chunked-app',
Type: 'pack-chunk',
})
expect(manifest!.tags).toEqual({
'App-Name': 'permagit',
'Chunk-Total': String(chunks.length),
'Content-Type': 'application/json',
Owner: prepared.ownerAddress,
'Pack-Group': group,
'Pack-Type': 'chunked',
Repo: 'chunked-app',
Type: 'pack',
})
expect(result.packTransactionId).toBe(manifest!.id)
})

it('rejects invalid repository names and wallet material', () => {
expect(() => preparePermagitTarget('bad repo', DEPLOY_KEY)).toThrow('Permagit repository name')
expect(() =>
preparePermagitTarget('my-app', Buffer.from('{}').toString('base64'), 'main'),
).toThrow('--permagit requires a valid Arweave JWK')
})
})
Loading
Loading