@volter/world 3.0.138 → 3.0.140
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/dist/examples/customer-onboarding/.env.example +3 -0
- package/dist/examples/customer-onboarding/LICENSE +202 -0
- package/dist/examples/customer-onboarding/README.md +99 -0
- package/dist/examples/customer-onboarding/demo.mjs +30 -0
- package/dist/examples/customer-onboarding/gitignore.template +8 -0
- package/dist/examples/customer-onboarding/onboarding.mjs +55 -0
- package/dist/examples/customer-onboarding/package.json +21 -0
- package/dist/examples/customer-onboarding/prepare-ui.mjs +20 -0
- package/dist/examples/customer-onboarding/public-files.json +14 -0
- package/dist/examples/customer-onboarding/run.mjs +22 -0
- package/dist/examples/customer-onboarding/server.mjs +32 -0
- package/dist/examples/customer-onboarding/style.css +8 -0
- package/dist/examples/index.json +52 -0
- package/dist/examples/integration-branches/.env.example +1 -0
- package/dist/examples/integration-branches/LICENSE +202 -0
- package/dist/examples/integration-branches/README.md +102 -0
- package/dist/examples/integration-branches/customer.mjs +11 -0
- package/dist/examples/integration-branches/gitignore.template +8 -0
- package/dist/examples/integration-branches/package.json +18 -0
- package/dist/examples/integration-branches/public-files.json +10 -0
- package/dist/examples/integration-branches/receipt.mjs +3 -0
- package/dist/examples/workplace-agent/.env.example +3 -0
- package/dist/examples/workplace-agent/LICENSE +202 -0
- package/dist/examples/workplace-agent/README.md +106 -0
- package/dist/examples/workplace-agent/clients.mjs +7 -0
- package/dist/examples/workplace-agent/create-organization.mjs +11 -0
- package/dist/examples/workplace-agent/gitignore.template +8 -0
- package/dist/examples/workplace-agent/handoff.mjs +30 -0
- package/dist/examples/workplace-agent/operate.mjs +21 -0
- package/dist/examples/workplace-agent/package.json +22 -0
- package/dist/examples/workplace-agent/public-files.json +14 -0
- package/dist/examples/workplace-agent/reset-receipts.mjs +5 -0
- package/dist/examples/workplace-agent/seed.mjs +16 -0
- package/dist/src/agent-prompt.js +5 -5
- package/dist/src/cli.d.ts +1 -1
- package/dist/src/cli.js +56 -11
- package/dist/src/examples.d.ts +8 -0
- package/dist/src/examples.js +30 -0
- package/dist/src/mcp.js +2 -2
- package/dist/src/world.d.ts +4 -3
- package/dist/src/world.js +27 -14
- package/examples/customer-onboarding/.env.example +3 -0
- package/examples/customer-onboarding/LICENSE +202 -0
- package/examples/customer-onboarding/README.md +99 -0
- package/examples/customer-onboarding/demo.mjs +30 -0
- package/examples/customer-onboarding/gitignore.template +8 -0
- package/examples/customer-onboarding/onboarding.mjs +55 -0
- package/examples/customer-onboarding/package.json +21 -0
- package/examples/customer-onboarding/prepare-ui.mjs +20 -0
- package/examples/customer-onboarding/public-files.json +14 -0
- package/examples/customer-onboarding/run.mjs +22 -0
- package/examples/customer-onboarding/server.mjs +32 -0
- package/examples/customer-onboarding/style.css +8 -0
- package/examples/index.json +52 -0
- package/examples/integration-branches/.env.example +1 -0
- package/examples/integration-branches/LICENSE +202 -0
- package/examples/integration-branches/README.md +102 -0
- package/examples/integration-branches/customer.mjs +11 -0
- package/examples/integration-branches/gitignore.template +8 -0
- package/examples/integration-branches/package.json +18 -0
- package/examples/integration-branches/public-files.json +10 -0
- package/examples/integration-branches/receipt.mjs +3 -0
- package/examples/workplace-agent/.env.example +3 -0
- package/examples/workplace-agent/LICENSE +202 -0
- package/examples/workplace-agent/README.md +106 -0
- package/examples/workplace-agent/clients.mjs +7 -0
- package/examples/workplace-agent/create-organization.mjs +11 -0
- package/examples/workplace-agent/gitignore.template +8 -0
- package/examples/workplace-agent/handoff.mjs +30 -0
- package/examples/workplace-agent/operate.mjs +21 -0
- package/examples/workplace-agent/package.json +22 -0
- package/examples/workplace-agent/public-files.json +14 -0
- package/examples/workplace-agent/reset-receipts.mjs +5 -0
- package/examples/workplace-agent/seed.mjs +16 -0
- package/package.json +9 -8
- package/src/agent-prompt.ts +5 -5
- package/src/cli.ts +45 -7
- package/src/examples.ts +24 -0
- package/src/mcp.ts +2 -2
- package/src/world.ts +27 -15
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: GitHub issue to Linear ticket and Slack update
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# GitHub issue to Linear ticket and Slack update
|
|
6
|
+
|
|
7
|
+
Give a tool-using agent an isolated workplace: a GitHub repository and source issue, a Linear team and existing runbook task, and a Slack triage channel with a policy message. The starter reads an issue, creates a linked ticket and posts the result to the channel through the real SDKs.
|
|
8
|
+
|
|
9
|
+
This application uses a labeled fixed policy: titles beginning with `P1:` receive priority 1; other titles receive priority 3. It exercises tools and stored outcomes. To assess an agent's planning or judgment, replace that decision with your own agent and configure its model twin's scripted answers separately. A successful fixed-rule handoff does not assess a model.
|
|
10
|
+
|
|
11
|
+
You need Node 22.6+, npm and registry access during installation. No platform account, private application checkout or real vendor key is required.
|
|
12
|
+
|
|
13
|
+
## Prepare an ordinary workplace
|
|
14
|
+
|
|
15
|
+
```console
|
|
16
|
+
$ npx @volter/world@3.0.140 example workplace-agent --dir workplace
|
|
17
|
+
$ cd workplace
|
|
18
|
+
$ npm install
|
|
19
|
+
$ npx volter world init --name workplace --twins github,linear,slack
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Read [package.json](./package.json), [package-lock.json](./package-lock.json) and [clients.mjs](./clients.mjs) for exact tool, twin and SDK versions. Keep the lockfile created by your installation. Review the three configured vendors and synthetic credentials before booting. No model vendor is selected by this starter.
|
|
23
|
+
|
|
24
|
+
```console
|
|
25
|
+
$ npx volter world up
|
|
26
|
+
$ npx volter world clock set 2026-01-15T12:00:00Z
|
|
27
|
+
$ npx volter world run -- node seed.mjs
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
[seed.mjs](./seed.mjs) creates stored records through each vendor's API. Run it once per fresh or reset World. It writes `company.json` with the returned repository owner, issue number, team and channel IDs. Existing runbook and policy records give your agent something to read before acting.
|
|
31
|
+
|
|
32
|
+
## Complete a handoff
|
|
33
|
+
|
|
34
|
+
```console
|
|
35
|
+
$ npx volter world run -- node handoff.mjs
|
|
36
|
+
$ npx volter world run -- node operate.mjs read
|
|
37
|
+
$ npx volter world log
|
|
38
|
+
$ npx volter world diff
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Read the ticket's title, priority and team, then the channel and posted source/ticket links. [handoff.mjs](./handoff.mjs) writes an application receipt under `outcomes/` as soon as the ticket exists. A repeated completed handoff prints that receipt; it does not create another ticket. [operate.mjs](./operate.mjs) reads the vendor's stored result rather than trusting the receipt alone.
|
|
42
|
+
|
|
43
|
+
## Give the task to your coding agent
|
|
44
|
+
|
|
45
|
+
Install the World skill and MCP tools into your agent's configuration:
|
|
46
|
+
|
|
47
|
+
```console
|
|
48
|
+
$ npx volter agents install
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
If you already completed the example handoff, create another source issue with `world run -- node operate.mjs issue`. Give your agent that returned issue number and this task:
|
|
52
|
+
|
|
53
|
+
> Read this starter's README and `company.json`. Work in its existing World. Read the source GitHub issue, the Linear backlog and the Slack policy message using the installed real SDKs inside `volter world run`. Apply the supplied priority policy, create a ticket with the source link, then notify the channel with both links. Preserve an application receipt as soon as the ticket exists. If notification is refused, report the stored ticket and pending notification separately. Read the resulting vendor records and show the World log; do not claim success from the script's exit alone. Inspect existing receipts before repeating a mutation.
|
|
54
|
+
|
|
55
|
+
The agent uses the same public SDKs as [clients.mjs](./clients.mjs); the [coding-agent guide](https://world.volter.ai/docs/guides/use-with-a-coding-agent.html) owns CLI/MCP setup. You can leave the fixed policy in place to exercise tool use, or supply your own agent decision and model scenario. The requested outcomes are stored links, team, priority and completion state; a scripted response still does not measure model judgment.
|
|
56
|
+
|
|
57
|
+
## Keep a partial result and retry it
|
|
58
|
+
|
|
59
|
+
Archive the channel, then create another source issue:
|
|
60
|
+
|
|
61
|
+
```console
|
|
62
|
+
$ npx volter world run -- node operate.mjs archive
|
|
63
|
+
$ npx volter world run -- node operate.mjs issue
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Use the returned issue number in both commands below:
|
|
67
|
+
|
|
68
|
+
```console
|
|
69
|
+
$ npx volter world run -- node handoff.mjs <issue-number>
|
|
70
|
+
$ npx volter world run -- node operate.mjs read <issue-number>
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Slack's archived-channel refusal leaves the Linear ticket stored. The application reports `partial-ticket-created-notification-failed` and preserves the vendor error in its receipt. Unarchive the channel and resume that same handoff:
|
|
74
|
+
|
|
75
|
+
```console
|
|
76
|
+
$ npx volter world run -- node operate.mjs unarchive
|
|
77
|
+
$ npx volter world run -- node handoff.mjs <issue-number>
|
|
78
|
+
$ npx volter world run -- node operate.mjs read <issue-number>
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
The retry uses the recorded ticket and attempts only the notification. An uncertain process exit between a successful vendor call and writing its receipt requires inspecting the vendor before retrying; this example does not claim exactly-once external execution.
|
|
82
|
+
|
|
83
|
+
`company.json` and `outcomes/` are application files outside declared World services. They are not branched or reset with vendor history. After explicitly resetting this disposable World, clear only this example's receipts with `node reset-receipts.mjs`, restore the frozen clock, then seed again. Do not reuse receipts from one World with another.
|
|
84
|
+
|
|
85
|
+
## Create a synthetic GitHub organization
|
|
86
|
+
|
|
87
|
+
Personal repositories use GitHub's ordinary create-repository API. Organization creation is a settings-page operation; this twin provides the declared `POST /_twin/orgs` endpoint for synthetic setup. [create-organization.mjs](./create-organization.mjs) discovers the authenticated account and sends:
|
|
88
|
+
|
|
89
|
+
```json
|
|
90
|
+
{
|
|
91
|
+
"login": "example-payments",
|
|
92
|
+
"name": "Example Payments",
|
|
93
|
+
"owner": "<login returned by GitHub users.getAuthenticated()>",
|
|
94
|
+
"billing_email": "owner@example.invalid"
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
It expects HTTP 201 with the organization's `login` and `id`, then creates a private repository using `github.repos.createInOrg`. Run it once in this World:
|
|
99
|
+
|
|
100
|
+
```console
|
|
101
|
+
$ npx volter world run -- node create-organization.mjs
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Without the owner or signed-in browser identity, the settings operation can redirect to sign-in. Inspect `GET /twin` for its declared setup endpoints rather than guessing a request body or inserting state files.
|
|
105
|
+
|
|
106
|
+
Stop with `npx volter world down` to retain the workplace. [Inspect a World](https://world.volter.ai/docs/guides/inspect-a-world.html) shows its vendor screens, semantic relations and event history. [Failure handoff](https://world.volter.ai/docs/guides/reproduce-a-failure.html) describes how to share source, clock, handlers and application receipt conditions. [Source files](./public-files.json) identify the starter. The source instructions carry no new execution claim.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { Octokit } from '@octokit/rest';
|
|
2
|
+
import { LinearClient } from '@linear/sdk';
|
|
3
|
+
import { WebClient } from '@slack/web-api';
|
|
4
|
+
if (!process.env.VOLTER_WORLD) throw new Error('Run this application with volter world run');
|
|
5
|
+
export const github = new Octokit({ auth: process.env.GITHUB_TOKEN, request: { timeout: 1500 } });
|
|
6
|
+
export const linear = new LinearClient({ apiKey: process.env.LINEAR_API_KEY });
|
|
7
|
+
export const slack = new WebClient(process.env.SLACK_BOT_TOKEN, { retryConfig: { retries: 0 }, timeout: 1500 });
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { github } from './clients.mjs';
|
|
2
|
+
const owner = (await github.users.getAuthenticated()).data.login;
|
|
3
|
+
const response = await fetch(`${process.env.GITHUB_TWIN_URL}/_twin/orgs`, {
|
|
4
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
5
|
+
body: JSON.stringify({ login: 'example-payments', name: 'Example Payments', owner, billing_email: 'owner@example.invalid' }),
|
|
6
|
+
signal: AbortSignal.timeout(1500),
|
|
7
|
+
});
|
|
8
|
+
if (!response.ok) throw new Error(`Organization setup: ${response.status} ${await response.text()}`);
|
|
9
|
+
console.log(await response.json());
|
|
10
|
+
// GitHub's API exposes repository creation once its organization exists; org creation itself is a settings-page door.
|
|
11
|
+
console.log((await github.repos.createInOrg({ org: 'example-payments', name: 'service', private: true })).data.full_name);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { github, linear, slack } from './clients.mjs';
|
|
3
|
+
const company = JSON.parse(readFileSync('company.json', 'utf8'));
|
|
4
|
+
const number = Number(process.argv[2] ?? company.sourceIssue);
|
|
5
|
+
const path = `outcomes/issue-${number}.json`;
|
|
6
|
+
const source = (await github.issues.get({ owner: company.owner, repo: company.repo, issue_number: number })).data;
|
|
7
|
+
// A labeled fixed policy, not an assessment of a model's judgment. Bring your own agent here.
|
|
8
|
+
const priority = source.title.startsWith('P1:') ? 1 : 3;
|
|
9
|
+
let outcome;
|
|
10
|
+
try { outcome = JSON.parse(readFileSync(path, 'utf8')); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
11
|
+
if (outcome && outcome.world !== process.env.VOLTER_WORLD) throw new Error('This receipt belongs to another World; inspect its vendor state before proceeding');
|
|
12
|
+
if (outcome?.completion === 'complete') { console.log(JSON.stringify(outcome, null, 2)); process.exit(0); }
|
|
13
|
+
if (!outcome) {
|
|
14
|
+
const payload = await linear.createIssue({ teamId: company.teamId, title: source.title, description: `Source: ${source.html_url}\n\n${source.body}`, priority });
|
|
15
|
+
const ticket = await payload.issue;
|
|
16
|
+
if (!payload.success || !ticket) throw new Error('Linear did not create a ticket');
|
|
17
|
+
outcome = { world: process.env.VOLTER_WORLD, source: { number, url: source.html_url }, ticket: { id: ticket.id, identifier: ticket.identifier, url: ticket.url, title: ticket.title, priority }, message: null, completion: 'ticket-created-notification-pending' };
|
|
18
|
+
writeFileSync(path, JSON.stringify(outcome, null, 2) + '\n');
|
|
19
|
+
}
|
|
20
|
+
// Resume a refused notification from its application receipt; never create a second ticket on that retry.
|
|
21
|
+
try {
|
|
22
|
+
const posted = await slack.chat.postMessage({ channel: company.channelId, text: `${outcome.ticket.identifier}: ${source.title}. Ticket: ${outcome.ticket.url}. Source: ${source.html_url}. Team: Reliability. Priority: ${priority}.` });
|
|
23
|
+
outcome.message = { channel: posted.channel, ts: posted.ts };
|
|
24
|
+
outcome.completion = 'complete'; delete outcome.error;
|
|
25
|
+
} catch (error) {
|
|
26
|
+
outcome.completion = 'partial-ticket-created-notification-failed';
|
|
27
|
+
outcome.error = { code: error.code, vendorError: error.data?.error, message: error.message };
|
|
28
|
+
}
|
|
29
|
+
writeFileSync(path, JSON.stringify(outcome, null, 2) + '\n');
|
|
30
|
+
console.log(JSON.stringify(outcome, null, 2));
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { github, linear, slack } from './clients.mjs';
|
|
3
|
+
const company = JSON.parse(readFileSync('company.json', 'utf8'));
|
|
4
|
+
switch (process.argv[2] ?? 'read') {
|
|
5
|
+
case 'issue': {
|
|
6
|
+
const made = await github.issues.create({ owner: company.owner, repo: company.repo, title: 'P1: Duplicate receipt after a retry', body: 'Synthetic second issue: deduplicate receipts while keeping history.' });
|
|
7
|
+
console.log(`New source issue ${made.data.number}`); break;
|
|
8
|
+
}
|
|
9
|
+
case 'archive': console.log(await slack.conversations.archive({ channel: company.channelId })); break;
|
|
10
|
+
case 'unarchive': console.log(await slack.conversations.unarchive({ channel: company.channelId })); break;
|
|
11
|
+
case 'read': {
|
|
12
|
+
const number = Number(process.argv[3] ?? company.sourceIssue);
|
|
13
|
+
const outcome = JSON.parse(readFileSync(`outcomes/issue-${number}.json`, 'utf8'));
|
|
14
|
+
const ticket = await linear.issue(outcome.ticket.id);
|
|
15
|
+
const team = await ticket.team;
|
|
16
|
+
const channel = await slack.conversations.info({ channel: company.channelId });
|
|
17
|
+
const messages = await slack.conversations.history({ channel: company.channelId, limit: 20 });
|
|
18
|
+
console.log(JSON.stringify({ completion: outcome.completion, ticket: { id: ticket.id, identifier: ticket.identifier, title: ticket.title, description: ticket.description, priority: ticket.priority, team: { id: team.id, name: team.name } }, channel: channel.channel, messages: messages.messages }, null, 2)); break;
|
|
19
|
+
}
|
|
20
|
+
default: throw new Error('Use issue, archive, unarchive or read [issue-number]');
|
|
21
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "world-workplace-agent",
|
|
3
|
+
"private": true,
|
|
4
|
+
"type": "module",
|
|
5
|
+
"engines": {
|
|
6
|
+
"node": ">=22.6"
|
|
7
|
+
},
|
|
8
|
+
"dependencies": {
|
|
9
|
+
"@octokit/rest": "22.0.1",
|
|
10
|
+
"@linear/sdk": "86.0.0",
|
|
11
|
+
"@slack/web-api": "7.19.0"
|
|
12
|
+
},
|
|
13
|
+
"devDependencies": {
|
|
14
|
+
"@volter/world": "3.0.140",
|
|
15
|
+
"@volter/world-core": "3.0.140",
|
|
16
|
+
"@volter/world-runtime": "3.0.140",
|
|
17
|
+
"@volter/world-console": "3.0.135",
|
|
18
|
+
"@volter/twin-github": "3.0.6",
|
|
19
|
+
"@volter/twin-linear": "3.0.4",
|
|
20
|
+
"@volter/twin-slack": "3.0.5"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Only this example's named application receipts. Never a World, credential or unknown directory.
|
|
2
|
+
import { existsSync, readdirSync, unlinkSync } from 'node:fs';
|
|
3
|
+
if (existsSync('company.json')) unlinkSync('company.json');
|
|
4
|
+
if (existsSync('outcomes')) for (const name of readdirSync('outcomes')) if (/^issue-\d+\.json$/.test(name)) unlinkSync('outcomes/' + name);
|
|
5
|
+
console.log('Application receipts cleared; vendor state was not changed');
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { github, linear, slack } from './clients.mjs';
|
|
3
|
+
const owner = (await github.users.getAuthenticated()).data.login;
|
|
4
|
+
await github.repos.createForAuthenticatedUser({ name: 'payments', private: true, description: 'Synthetic payments app' });
|
|
5
|
+
const source = await github.issues.create({ owner, repo: 'payments', title: 'P1: Retried webhook creates duplicate orders', body: 'Deliver the same provider event twice. Expected: one order and one receipt. Preserve audit history; deduplicate by provider event ID.' });
|
|
6
|
+
const teamPayload = await linear.createTeam({ name: 'Reliability', key: 'REL' });
|
|
7
|
+
const team = await teamPayload.team;
|
|
8
|
+
if (!teamPayload.success || !team) throw new Error('Linear did not create the Reliability team');
|
|
9
|
+
await linear.createIssue({ teamId: team.id, title: 'Document the on-call runbook', priority: 3 });
|
|
10
|
+
const channel = await slack.conversations.create({ name: 'payments-triage', is_private: false });
|
|
11
|
+
if (!channel.channel?.id) throw new Error('Slack did not create the triage channel');
|
|
12
|
+
await slack.chat.postMessage({ channel: channel.channel.id, text: 'Payment integrity issues go to Reliability. Every update includes the source and ticket links.' });
|
|
13
|
+
mkdirSync('outcomes', { recursive: true });
|
|
14
|
+
const company = { name: 'Example Payments', owner, repo: 'payments', sourceIssue: source.data.number, teamId: team.id, channelId: channel.channel.id, policy: 'P1 payment integrity → Reliability, priority 1' };
|
|
15
|
+
writeFileSync('company.json', JSON.stringify(company, null, 2) + '\n');
|
|
16
|
+
console.log(JSON.stringify(company, null, 2));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/world",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.140",
|
|
4
4
|
"description": "Run your app against a world of twins. `World` and `Repo` — one method per verb: init, up, run, log, diff, reset, branch, checkout, clone, fetch, changeset, push — and the `volter` command, one client of them.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"volter",
|
|
@@ -26,16 +26,17 @@
|
|
|
26
26
|
"files": [
|
|
27
27
|
"src",
|
|
28
28
|
"dist",
|
|
29
|
-
"skills"
|
|
29
|
+
"skills",
|
|
30
|
+
"examples"
|
|
30
31
|
],
|
|
31
32
|
"dependencies": {
|
|
32
33
|
"@modelcontextprotocol/sdk": "^1.30.1",
|
|
33
|
-
"@volter/world-core": "3.0.
|
|
34
|
-
"@volter/world-runtime": "3.0.
|
|
34
|
+
"@volter/world-core": "3.0.140",
|
|
35
|
+
"@volter/world-runtime": "3.0.140",
|
|
35
36
|
"zod": "^4.6.5"
|
|
36
37
|
},
|
|
37
38
|
"peerDependencies": {
|
|
38
|
-
"@volter/world-console": "3.0.
|
|
39
|
+
"@volter/world-console": "3.0.135"
|
|
39
40
|
},
|
|
40
41
|
"peerDependenciesMeta": {
|
|
41
42
|
"@volter/world-console": {
|
|
@@ -43,14 +44,14 @@
|
|
|
43
44
|
}
|
|
44
45
|
},
|
|
45
46
|
"devDependencies": {
|
|
46
|
-
"@volter/world-console": "3.0.
|
|
47
|
+
"@volter/world-console": "3.0.135"
|
|
47
48
|
},
|
|
48
49
|
"bin": {
|
|
49
50
|
"volter": "dist/src/cli.js"
|
|
50
51
|
},
|
|
51
52
|
"scripts": {
|
|
52
|
-
"build": "node ../../scripts/publish/build.mjs",
|
|
53
|
-
"prepack": "node ./scripts/ship-skill.mjs in && node ../../scripts/publish/prepare-publish.mjs prepack",
|
|
53
|
+
"build": "node ../../scripts/prepare-world-examples.mjs && node ../../scripts/publish/build.mjs",
|
|
54
|
+
"prepack": "node ../../scripts/prepare-world-examples.mjs && node ./scripts/ship-skill.mjs in && node ../../scripts/publish/prepare-publish.mjs prepack",
|
|
54
55
|
"postpack": "node ../../scripts/publish/prepare-publish.mjs postpack && node ./scripts/ship-skill.mjs out"
|
|
55
56
|
},
|
|
56
57
|
"homepage": "https://world.volter.ai/docs/getting-started"
|
package/src/agent-prompt.ts
CHANGED
|
@@ -15,13 +15,13 @@ export type AgentPromptOptions = {
|
|
|
15
15
|
export function agentPrompt(o: AgentPromptOptions = {}): string {
|
|
16
16
|
const cmd = (shell: string, tool: string): string => (o.tools ? `${tool} (\`${shell}\`)` : `\`${shell}\``);
|
|
17
17
|
const steps = [
|
|
18
|
-
'Set up Volter World for
|
|
18
|
+
'Set up Volter World for the workflow I want to run in this app. Select only the vendors that workflow deliberately substitutes with local twins. Keep the app\'s real SDKs.',
|
|
19
19
|
'',
|
|
20
20
|
o.tools
|
|
21
|
-
? '1. You have the Volter World tools (volter mcp). If the `volter` command is missing, install it: `npm install -g @volter/world`.'
|
|
22
|
-
: '1.
|
|
23
|
-
`2.
|
|
24
|
-
`3. ${o.tools ? 'Start it with world_up' : 'Start it: `volter world up`'}, then run
|
|
21
|
+
? '1. You have the Volter World tools (volter mcp). Keep an app\'s existing pinned World tools. If the `volter` command is missing, install it: `npm install -g @volter/world`.'
|
|
22
|
+
: '1. Keep the app\'s pinned World tools if present, using its local CLI through `npx`. Otherwise install the command: `npm install -g @volter/world`. Install the agent tooling with `volter agents install --yes`.',
|
|
23
|
+
`2. Read the app's manifests, env names and any .volter/world.json. Show me the likely vendors and why, before keeping them. Preserve a saved World and its state; do not initialize it again to resume. For a new World, choose the exact catalog releases and initialize only the selected vendors with ${cmd('volter world init --twins <selected-vendors> --source <vendor>=<package>', 'world_init with vendors and sources')}.`,
|
|
24
|
+
`3. ${o.tools ? 'Start it with world_up' : 'Start it: `volter world up`'}, then run my usual app command inside it: ${cmd('volter world run -- <the app command>', 'world_run')}. Use throwaway credentials. Diagnose a failure as routing, data, scenario, unsupported operation or runtime; preserve the refusal's evidence rather than bypassing it.`,
|
|
25
25
|
`4. Tell me what the app did at each vendor (${cmd('volter world log', 'world_log')}), and give me the dashboard link (${o.tools ? 'world_view' : '`volter world view --no-open`, started in the background: it keeps running'}).`,
|
|
26
26
|
];
|
|
27
27
|
if (o.platform) {
|
package/src/cli.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { existsSync } from 'node:fs';
|
|
|
14
14
|
import { join, relative, resolve } from 'node:path';
|
|
15
15
|
import { forgetPlatform, forgetToken, platformOrigin, requireToken, storePlatform, storeToken, tokenFor } from './credentials.ts';
|
|
16
16
|
import { parseOriginUrl, World } from './world.ts';
|
|
17
|
+
import { copyExample, examples } from './examples.ts';
|
|
17
18
|
import { waitForServeShutdown } from './serve-shutdown.ts';
|
|
18
19
|
import { startUpdateCheck } from './update-notice.ts';
|
|
19
20
|
import type { ViewConsole } from '@volter/world-runtime';
|
|
@@ -37,6 +38,8 @@ world — the twins your app needs, running together (the world of the current
|
|
|
37
38
|
volter world activate eval "$(volter world activate)": vendor CLIs and curl in this shell reach the twins
|
|
38
39
|
volter world shell a subshell with the world active
|
|
39
40
|
volter world init --bare <org>/<world> --twins a,b a world with no app: a shared world for a team, or one standing in for a vendor
|
|
41
|
+
volter example list ready application starters included in this CLI release
|
|
42
|
+
volter example <name> [--dir <path>] copy a cookbook starter into an empty directory; does not install or start it
|
|
40
43
|
volter world serve [--port <p>] [--console-port <c>] [--origin <url>] serve this world on a URL under /<org>/<world>/ for apps; prints the token and the console (on loopback: view's front, no token in the browser)
|
|
41
44
|
volter world view [--port <p>] [--host <h>] [--no-open] [--no-origins] [--origin <url>] step into this world in a browser: its twins' own UIs, timeline, clock, branches (a non-loopback --host serves them on one origin)
|
|
42
45
|
volter world console [<https://host/org/world>] [--port <p>] the console on this machine for a World served elsewhere (default: this world's origin); needs @volter/world-console
|
|
@@ -55,7 +58,7 @@ world — the twins your app needs, running together (the world of the current
|
|
|
55
58
|
volter world origin where this world clones from and pushes to
|
|
56
59
|
volter world changeset [<name>] -m "<message>" cut the unpushed changes into a reviewable changeset
|
|
57
60
|
volter world pull [--token <token>] fetch, then move this branch onto what came in (git's pull)
|
|
58
|
-
volter world push [<name>] [--token <token>] [--force] send entries to the remote; vendor deployment follows its root policy
|
|
61
|
+
volter world push [<name>] [--remote <remote>] [--token <token>] [--force] send entries to the selected remote (default origin); vendor deployment follows its root policy
|
|
59
62
|
volter world rebase [<changeset>] rebase this branch onto its moved base, or a changeset onto the moved remote
|
|
60
63
|
volter world verify <changeset> run the world's checks over a changeset and record the result
|
|
61
64
|
volter world approve <changeset> --as <principal> sign a changeset's current hash
|
|
@@ -106,7 +109,19 @@ function positionals(args: string[], valueFlags: string[]): string[] {
|
|
|
106
109
|
return out;
|
|
107
110
|
}
|
|
108
111
|
function afterDashes(args: string[]): string[] { const i = args.indexOf('--'); return i < 0 ? [] : args.slice(i + 1); }
|
|
109
|
-
const VALUE_FLAGS = ['--world', '--branch', '--name', '--token', '--admin-token', '-m', '--message', '--base', '--org', '--vendors', '--into', '--deploy', '--as', '--note', '--port', '--twins', '--source', '--host', '--origin', '--scope', '--refresh', '--at-most', '--at'];
|
|
112
|
+
const VALUE_FLAGS = ['--world', '--branch', '--name', '--token', '--admin-token', '-m', '--message', '--base', '--org', '--vendors', '--into', '--deploy', '--as', '--note', '--port', '--twins', '--source', '--host', '--origin', '--remote', '--dir', '--scope', '--refresh', '--at-most', '--at'];
|
|
113
|
+
|
|
114
|
+
/** Help is resolved before opening a World; the child after `run --` owns its own flags. */
|
|
115
|
+
function wantsHelp(noun: string | undefined, verb: string | undefined, args: string[]): boolean {
|
|
116
|
+
const boundary = args.indexOf('--');
|
|
117
|
+
const options = boundary < 0 ? args : args.slice(0, boundary);
|
|
118
|
+
return !noun || noun === 'help' || noun === '--help' || noun === '-h'
|
|
119
|
+
|| verb === '--help' || verb === '-h' || options.some((arg) => arg === '--help' || arg === '-h');
|
|
120
|
+
}
|
|
121
|
+
function printHelp(noun?: string, verb?: string): void {
|
|
122
|
+
const lines = noun && verb ? HELP.split('\n').filter((line) => line.startsWith(` volter ${noun} ${verb} `)) : [];
|
|
123
|
+
out(lines.length ? `${lines.join('\n')}\n\n --world <dir> select the app's World\n --branch <name> select a branch\n --json machine-readable output where offered` : HELP);
|
|
124
|
+
}
|
|
110
125
|
|
|
111
126
|
/** A subject as a person names it: an id that already carries its context (`acme/web#1`, `C-ops/ts`) stands alone, with an embedded `<type>:` dropped; a bare id gets its type. */
|
|
112
127
|
function subjectLabel(subject: { type: string; id: string }): string {
|
|
@@ -272,8 +287,9 @@ async function worldCommand(verb: string, args: string[]): Promise<void> {
|
|
|
272
287
|
}
|
|
273
288
|
return { positions, views };
|
|
274
289
|
})();
|
|
290
|
+
const parent = world.name;
|
|
275
291
|
const made = await world.branch(name, at ? { at } : {});
|
|
276
|
-
out(`branch ${made.name} from ${
|
|
292
|
+
out(`branch ${made.name} from ${parent}${at ? ` at ${atText}` : ''}`);
|
|
277
293
|
return;
|
|
278
294
|
}
|
|
279
295
|
case 'checkout': {
|
|
@@ -419,8 +435,16 @@ async function worldCommand(verb: string, args: string[]): Promise<void> {
|
|
|
419
435
|
return;
|
|
420
436
|
}
|
|
421
437
|
case 'push': {
|
|
422
|
-
|
|
423
|
-
|
|
438
|
+
if (pos.length > 1) throw new Error('volter world push [<changeset>] [--remote <name>]: choose the destination with --remote');
|
|
439
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
440
|
+
const arg = args[i]!;
|
|
441
|
+
if (['--remote', '--token', '--world', '--branch'].includes(arg)) {
|
|
442
|
+
if (!args[i + 1] || args[i + 1]!.startsWith('-')) throw new Error(`volter world push: ${arg} needs a value`);
|
|
443
|
+
i += 1;
|
|
444
|
+
} else if (arg.startsWith('-') && !['--force', '--json'].includes(arg)) throw new Error(`volter world push: unknown option ${arg}`);
|
|
445
|
+
}
|
|
446
|
+
const remoteName = value(args, '--remote') ?? 'origin';
|
|
447
|
+
const outcomes = await world.push({ remote: remoteName, ...(pos[0] ? { name: pos[0] } : {}), ...(value(args, '--token') ? { token: value(args, '--token')! } : {}), force: flag(args, '--force') });
|
|
424
448
|
if (asJson) { json(outcomes); return; }
|
|
425
449
|
if (outcomes.length === 0) { out('nothing to push'); return; }
|
|
426
450
|
for (const o of outcomes) {
|
|
@@ -799,16 +823,30 @@ function ownVersion(): string {
|
|
|
799
823
|
|
|
800
824
|
async function main(): Promise<void> {
|
|
801
825
|
const [noun, verb, ...args] = process.argv.slice(2);
|
|
826
|
+
if (wantsHelp(noun, verb, args)) { printHelp(noun, verb); return; }
|
|
802
827
|
const notice = startUpdateCheck(process.argv.slice(2));
|
|
803
828
|
await dispatch(noun, verb, args);
|
|
804
829
|
await notice();
|
|
805
830
|
}
|
|
806
831
|
|
|
807
832
|
async function dispatch(noun: string | undefined, verb: string | undefined, args: string[]): Promise<void> {
|
|
808
|
-
if (
|
|
809
|
-
if (verb === '--help' || verb === '-h') { out(HELP); return; }
|
|
833
|
+
if (wantsHelp(noun, verb, args)) { printHelp(noun, verb); return; }
|
|
810
834
|
if (noun === '--version' || noun === '-v' || noun === 'version') { out(`volter ${ownVersion()}`); return; }
|
|
811
835
|
switch (noun) {
|
|
836
|
+
case 'example': {
|
|
837
|
+
if (!verb || verb === 'list') {
|
|
838
|
+
if (args.length) throw new Error('volter example list takes no arguments');
|
|
839
|
+
for (const e of examples()) out(`${e.id} ${e.title}`); return;
|
|
840
|
+
}
|
|
841
|
+
for (let i = 0; i < args.length; i++) {
|
|
842
|
+
if (args[i] !== '--dir' || !args[i + 1] || args[i + 1]!.startsWith('-')) throw new Error('volter example <name> [--dir <path>]');
|
|
843
|
+
i++;
|
|
844
|
+
}
|
|
845
|
+
const target = copyExample(verb, value(args, '--dir') ?? verb);
|
|
846
|
+
out(`example ${verb} ${target}`);
|
|
847
|
+
out(`Read ${target}/README.md, install its dependencies, then review the vendors before world init.`);
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
812
850
|
case 'world': {
|
|
813
851
|
if (!verb) throw new Error(`volter world: which verb?\n\n${HELP}`);
|
|
814
852
|
await worldCommand(verb, args);
|
package/src/examples.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { dirname, join, resolve } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
type Example = { id: string; title: string; files: string[] };
|
|
5
|
+
const packaged = () => fileURLToPath(new URL('../examples/', import.meta.url));
|
|
6
|
+
export function examples(): Example[] {
|
|
7
|
+
const index = join(packaged(), 'index.json');
|
|
8
|
+
if (!existsSync(index)) throw new Error('Starter files are absent from this CLI build; build @volter/world or install a release containing examples');
|
|
9
|
+
return JSON.parse(readFileSync(index, 'utf8')) as Example[];
|
|
10
|
+
}
|
|
11
|
+
export function copyExample(id: string, directory: string): string {
|
|
12
|
+
const example = examples().find(e => e.id === id);
|
|
13
|
+
if (!example) throw new Error(`Unknown example ${id}; use volter example list`);
|
|
14
|
+
const target = resolve(directory);
|
|
15
|
+
if (existsSync(target) && (!lstatSync(target).isDirectory() || lstatSync(target).isSymbolicLink() || readdirSync(target).length)) throw new Error(`Example destination must be an empty directory: ${target}`);
|
|
16
|
+
const source = join(packaged(), example.id);
|
|
17
|
+
const sourceFile = (file: string): string => join(source, file === '.gitignore' ? 'gitignore.template' : file);
|
|
18
|
+
for (const file of example.files) {
|
|
19
|
+
if (!file || file.includes('..') || file.includes('\\') || file.startsWith('/') || file === '.env' || !lstatSync(sourceFile(file)).isFile() || lstatSync(sourceFile(file)).isSymbolicLink()) throw new Error(`Invalid packaged example file ${file}`);
|
|
20
|
+
}
|
|
21
|
+
mkdirSync(target, { recursive: true });
|
|
22
|
+
for (const file of example.files) { mkdirSync(dirname(join(target, file)), { recursive: true }); copyFileSync(sourceFile(file), join(target, file)); }
|
|
23
|
+
return target;
|
|
24
|
+
}
|
package/src/mcp.ts
CHANGED
|
@@ -165,8 +165,8 @@ export async function serveMcp(version: string): Promise<void> {
|
|
|
165
165
|
|
|
166
166
|
server.registerTool('world_push', {
|
|
167
167
|
title: 'Push to the remote', description: "Send this World's unpushed changes to its remote (the platform's World after remote_link); the remote answers with a receipt for each change.",
|
|
168
|
-
inputSchema: { ...dirArg, changeset: z.string().optional().describe('Push one changeset by name; default all unpushed.') },
|
|
169
|
-
}, async ({ dir, changeset }) => ran(await volter(['world', 'push', ...(changeset ? [changeset] : [])], here(dir))));
|
|
168
|
+
inputSchema: { ...dirArg, changeset: z.string().optional().describe('Push one changeset by name; default all unpushed.'), remote: z.string().optional().describe('Named destination; default origin.') },
|
|
169
|
+
}, async ({ dir, changeset, remote }) => ran(await volter(['world', 'push', ...(changeset ? [changeset] : []), ...(remote ? ['--remote', remote] : [])], here(dir))));
|
|
170
170
|
|
|
171
171
|
server.registerPrompt('get-started', {
|
|
172
172
|
title: 'Set up Volter World for this app', description: 'Walks through setting up a World for the app in this folder, running its tests in it, and optionally sharing it on a platform.',
|
package/src/world.ts
CHANGED
|
@@ -61,10 +61,10 @@ export type WorldStatus = {
|
|
|
61
61
|
};
|
|
62
62
|
|
|
63
63
|
/** `https://host/org/world` → the remote's base URL and the namespace it addresses. */
|
|
64
|
-
export function parseOriginUrl(url: string): { url: string; namespace: string } {
|
|
64
|
+
export function parseOriginUrl(url: string, worldRoot = process.cwd()): { url: string; namespace: string } {
|
|
65
65
|
// a PATH is a world on this machine (git's file transport): its directory, and its served name
|
|
66
66
|
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(url)) {
|
|
67
|
-
const root = resolve(url);
|
|
67
|
+
const root = resolve(worldRoot, url);
|
|
68
68
|
const configPath = join(root, '.volter', 'world.json');
|
|
69
69
|
if (!existsSync(configPath)) throw new Error(`Not a remote URL or world directory: ${url} (want https://<host>/<org>/<world>, or a directory holding .volter/world.json)`);
|
|
70
70
|
const config = loadWorldConfig(configPath, root).config;
|
|
@@ -134,7 +134,7 @@ export class World {
|
|
|
134
134
|
const existing = this.instance();
|
|
135
135
|
if (existing?.running) return existing; // already up: nothing to do
|
|
136
136
|
const instance = await upWorld(worldConfigRelative(), {
|
|
137
|
-
name: this.name, root: this.root, envFile: worldEnvPath(this.root),
|
|
137
|
+
name: this.name, root: this.root, envFile: existing?.envFile ?? worldEnvPath(this.root),
|
|
138
138
|
...(opts.mode ? { mode: opts.mode } : existing ? { mode: existing.mode } : {}),
|
|
139
139
|
...(existing ? { keepState: true } : {}),
|
|
140
140
|
});
|
|
@@ -182,7 +182,7 @@ export class World {
|
|
|
182
182
|
// pushed to it has not fetched, but it has an origin all the same)
|
|
183
183
|
origin: (() => { const o = this.origin(); return o ? { url: o.url, namespace: o.namespace, ...(o.fetchedAt ? { fetchedAt: o.fetchedAt } : {}) } : null; })(),
|
|
184
184
|
unpushed: instance ? this.unpushed().length : 0,
|
|
185
|
-
changesets: { total: changesets.length, pushed: changesets.filter((c) => c.
|
|
185
|
+
changesets: { total: changesets.length, pushed: changesets.filter(({ changeset: c }) => c.applied?.outcome === 'applied' && (!c.applied.destination || (c.applied.destination.url === this.origin()?.url && c.applied.destination.namespace === this.origin()?.namespace))).length },
|
|
186
186
|
services,
|
|
187
187
|
...(instance ? { envFile: instance.envFile } : {}),
|
|
188
188
|
};
|
|
@@ -198,7 +198,7 @@ export class World {
|
|
|
198
198
|
}
|
|
199
199
|
|
|
200
200
|
// ── the log ─────────────────────────────────────────────────────────────────────────────
|
|
201
|
-
/** Every write
|
|
201
|
+
/** Every recorded write grouped by vendor in its position order, with delivery receipts where available. */
|
|
202
202
|
log(): LogEntry[] {
|
|
203
203
|
const receipts = new Map<string, { receipt: ApplyReceipt; changeset: string }>();
|
|
204
204
|
for (const located of this.changesets()) for (const receipt of located.changeset.applied?.receipts ?? []) receipts.set(receipt.actionId, { receipt, changeset: located.changeset.name });
|
|
@@ -227,7 +227,10 @@ export class World {
|
|
|
227
227
|
rows.push(l ? { ...row, landed: l } : r ? { ...row, receipt: r.receipt, changeset: r.changeset } : row);
|
|
228
228
|
}
|
|
229
229
|
}
|
|
230
|
-
|
|
230
|
+
// Each vendor has its own position; timestamps (including a frozen clock) never shuffle it.
|
|
231
|
+
// Group vendors rather than inventing a global sequence from independent positions.
|
|
232
|
+
return rows.sort((a, b) => a.service.localeCompare(b.service) ||
|
|
233
|
+
(a.position !== undefined && b.position !== undefined ? a.position - b.position : a.occurredAt.localeCompare(b.occurredAt)));
|
|
231
234
|
}
|
|
232
235
|
/** Changes not yet pushed (`log origin..HEAD`), across the twins. */
|
|
233
236
|
unpushed(): TwinAction[] { return this.repos().flatMap((r) => r.unpushed({ pushable: true })).sort((a, b) => a.occurredAt.localeCompare(b.occurredAt)); }
|
|
@@ -363,7 +366,7 @@ export class World {
|
|
|
363
366
|
const { path, config } = loadWorldConfig(this.configRef(), this.root);
|
|
364
367
|
const remotes = { ...(config.remotes ?? {}), [name]: target };
|
|
365
368
|
writeWorldConfig(path, { ...config, remotes });
|
|
366
|
-
if (opts.token) storeToken(parseOriginUrl(target).url, opts.token);
|
|
369
|
+
if (opts.token) storeToken(parseOriginUrl(target, this.root).url, opts.token);
|
|
367
370
|
}
|
|
368
371
|
removeRemote(name: string): void {
|
|
369
372
|
const { path, config } = loadWorldConfig(this.configRef(), this.root);
|
|
@@ -376,7 +379,7 @@ export class World {
|
|
|
376
379
|
const instance = name === 'origin' ? this.instance() : null;
|
|
377
380
|
if (instance && instance.name !== instance.config) return instance.origin ? { url: instance.origin.url, namespace: instance.origin.namespace } : null;
|
|
378
381
|
const target = this.remotes()[name];
|
|
379
|
-
if (target) return parseOriginUrl(target);
|
|
382
|
+
if (target) return parseOriginUrl(target, this.root);
|
|
380
383
|
if (name !== 'origin') return null;
|
|
381
384
|
const recorded = this.recordedOrigin();
|
|
382
385
|
return recorded ? { url: recorded.url, namespace: recorded.namespace } : null;
|
|
@@ -398,7 +401,7 @@ export class World {
|
|
|
398
401
|
* the world's, which is what `fetch` and `push` use afterwards.
|
|
399
402
|
*/
|
|
400
403
|
async clone(url: string, opts: { token?: string; adminToken?: string } = {}): Promise<ReturnType<typeof fetchFromOrigin>> {
|
|
401
|
-
const origin = parseOriginUrl(url);
|
|
404
|
+
const origin = parseOriginUrl(url, this.root);
|
|
402
405
|
if (opts.token !== undefined && opts.token !== '') storeToken(origin.url, opts.token);
|
|
403
406
|
if (opts.adminToken !== undefined && opts.adminToken !== '') {
|
|
404
407
|
if (opts.token === undefined || opts.token === '') storeToken(origin.url, opts.adminToken);
|
|
@@ -448,23 +451,32 @@ export class World {
|
|
|
448
451
|
}
|
|
449
452
|
changesets(): ChangesetLocation[] { return listWorldChangesets({ root: this.root, world: this.name }); }
|
|
450
453
|
/**
|
|
451
|
-
* Push to
|
|
454
|
+
* Push to a selected remote (origin by default), which answers with receipts under its policy:
|
|
452
455
|
* the named changeset, or every changeset not yet pushed, oldest first. A local world never
|
|
453
456
|
* holds a vendor credential.
|
|
454
457
|
*/
|
|
455
|
-
async push(opts: { name?: string; token?: string; force?: boolean } = {}): Promise<Array<Awaited<ReturnType<typeof pushWorldChangeset>>>> {
|
|
456
|
-
const
|
|
457
|
-
|
|
458
|
+
async push(opts: { name?: string; remote?: string; token?: string; force?: boolean } = {}): Promise<Array<Awaited<ReturnType<typeof pushWorldChangeset>>>> {
|
|
459
|
+
const remoteName = opts.remote ?? 'origin';
|
|
460
|
+
const origin = this.remote(remoteName);
|
|
461
|
+
if (!origin) throw new Error(remoteName === 'origin'
|
|
462
|
+
? `World "${this.name}" has no origin to push to — the default data cannot be pushed to. \`volter world clone <url>\` connects a remote.`
|
|
463
|
+
: `World "${this.name}" has no remote "${remoteName}" — \`volter remote add ${remoteName} <url|path>\` names one.`);
|
|
464
|
+
const tracked = this.origin();
|
|
465
|
+
const trackOrigin = tracked?.url === origin.url && tracked.namespace === origin.namespace;
|
|
458
466
|
if (opts.token !== undefined && opts.token !== '') storeToken(origin.url, opts.token);
|
|
459
467
|
const key = requireToken(origin.url, opts.token);
|
|
460
468
|
const to = { to: origin.url, namespace: origin.namespace };
|
|
461
469
|
const queue = opts.name !== undefined
|
|
462
470
|
? [findWorldChangeset(opts.name, { root: this.root, world: this.name })]
|
|
463
|
-
: this.changesets().filter((c) =>
|
|
471
|
+
: this.changesets().filter((c) => {
|
|
472
|
+
const applied = c.changeset.applied;
|
|
473
|
+
if (!applied || applied.outcome === 'refused') return true;
|
|
474
|
+
return applied.destination ? applied.destination.url !== origin.url || applied.destination.namespace !== origin.namespace : !trackOrigin;
|
|
475
|
+
}).sort((a, b) => a.changeset.createdAt.localeCompare(b.changeset.createdAt));
|
|
464
476
|
if (queue.length === 0) throw new Error(this.unpushed().length ? 'Nothing to push: cut a changeset first — `volter world changeset -m "<what and why>"`' : 'Nothing to push: no unpushed changes');
|
|
465
477
|
const outcomes: Array<Awaited<ReturnType<typeof pushWorldChangeset>>> = [];
|
|
466
478
|
for (const located of queue) {
|
|
467
|
-
const outcome = await pushWorldChangeset(located.changeset.name, { root: this.root, world: this.name, ...to, key, ...(opts.force ? { force: true } : {}) });
|
|
479
|
+
const outcome = await pushWorldChangeset(located.changeset.name, { root: this.root, world: this.name, ...to, key, trackOrigin, ...(opts.force ? { force: true } : {}) });
|
|
468
480
|
outcomes.push(outcome);
|
|
469
481
|
if (outcome.application.outcome === 'refused') break;
|
|
470
482
|
}
|