agents-city 0.3.0-beta.21 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/README.es.md +310 -70
- package/README.md +297 -69
- package/bin/agents-city.js +3 -0
- package/bin/doctor +3 -0
- package/bin/hall.html +164 -24
- package/bin/navegador.mjs +415 -0
- package/bin/serve.py +383 -127
- package/bin/shortcut +3 -0
- package/bin/test +5 -2
- package/bin/test-actualiza.py +130 -0
- package/bin/test-atajos.py +301 -0
- package/bin/test-busca.py +216 -0
- package/bin/test-cage.py +170 -2
- package/bin/test-card.py +2 -2
- package/bin/test-cities.py +45 -0
- package/bin/test-contracts.py +12 -5
- package/bin/test-doctor.py +33 -0
- package/bin/test-navegador.py +164 -0
- package/bin/test-seat.py +245 -25
- package/bin/test-serve.py +214 -9
- package/bin/test-workspace.py +63 -0
- package/bin/testlib.py +23 -0
- package/bin/update +3 -0
- package/city/web/dist/city.js +47 -47
- package/city/web/dist/index.html +1 -1
- package/city/web/dist-hall/hall.js +2193 -174
- package/city/web/src/bienvenida.ts +686 -0
- package/city/web/src/es.ts +180 -0
- package/city/web/src/hall.ts +520 -168
- package/city/web/src/idioma.ts +86 -0
- package/city/web/src/main.ts +27 -0
- package/city/web/src/motores.ts +54 -0
- package/docs/agents-first.md +8 -1
- package/docs/security.md +46 -12
- package/docs/testing.md +1 -1
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/channel/bus.js +1 -1
- package/plugin/channel/bus.ts +1 -1
- package/plugin/channel/runtime/codex.ts +1 -1
- package/plugin/channel/runtime-gateway.js +1 -1
- package/plugin/scripts/actualiza.py +198 -0
- package/plugin/scripts/atajos.py +506 -0
- package/plugin/scripts/busca.py +436 -0
- package/plugin/scripts/cage.py +266 -26
- package/plugin/scripts/capabilities.py +17 -10
- package/plugin/scripts/card.py +10 -0
- package/plugin/scripts/cities.py +34 -0
- package/plugin/scripts/city-session.sh +33 -7
- package/plugin/scripts/doctor.py +122 -0
- package/plugin/scripts/find-repos.sh +12 -105
- package/plugin/scripts/read-card.py +6 -2
- package/plugin/scripts/report.py +5 -6
- package/plugin/scripts/reset.py +50 -14
- package/plugin/scripts/seat.py +445 -103
- package/plugin/scripts/workspace.py +197 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Two languages, one page.
|
|
3
|
+
*
|
|
4
|
+
* The READMEs shipped in Spanish and English from the start and the product did
|
|
5
|
+
* not, which is the wrong way round: a person reads the documentation once and
|
|
6
|
+
* lives in the interface every day. So the Hall speaks both.
|
|
7
|
+
*
|
|
8
|
+
* The dictionary is keyed by the ENGLISH sentence, not by an invented id. That
|
|
9
|
+
* matters here: an untranslated string falls back to the source and the page
|
|
10
|
+
* stays usable while coverage grows, instead of showing `hall.agents.title` to
|
|
11
|
+
* somebody. It also means the code still reads as English prose, so a
|
|
12
|
+
* contributor who speaks neither can follow it.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
type Diccionario = Record<string, string>;
|
|
16
|
+
|
|
17
|
+
const ES: Diccionario = {};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The lookup key: the source sentence with its whitespace flattened.
|
|
21
|
+
*
|
|
22
|
+
* A paragraph in a template literal is indented to match the code around it,
|
|
23
|
+
* and that indentation is invisible in HTML but fatal to an exact-match
|
|
24
|
+
* dictionary — every translated paragraph would have to be re-indented in
|
|
25
|
+
* lockstep with the file it came from. Flattening both sides means prose can be
|
|
26
|
+
* written where it reads best and translated as one line.
|
|
27
|
+
*/
|
|
28
|
+
function clave(fuente: string): string {
|
|
29
|
+
return fuente.replace(/\s+/g, ' ').trim();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Register translations. Split across files so each screen carries its own. */
|
|
33
|
+
export function anota(pares: Diccionario): void {
|
|
34
|
+
for (const [fuente, texto] of Object.entries(pares)) ES[clave(fuente)] = texto;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type Lengua = 'es' | 'en';
|
|
38
|
+
|
|
39
|
+
let ELEGIDO: Lengua | '' = '';
|
|
40
|
+
|
|
41
|
+
/** The language showing right now: the stored choice, else this browser's. */
|
|
42
|
+
export function idioma(): 'es' | 'en' {
|
|
43
|
+
if (ELEGIDO === 'es' || ELEGIDO === 'en') return ELEGIDO;
|
|
44
|
+
try {
|
|
45
|
+
const guardado = localStorage.getItem('hall-idioma');
|
|
46
|
+
if (guardado === 'es' || guardado === 'en') {
|
|
47
|
+
ELEGIDO = guardado;
|
|
48
|
+
return ELEGIDO;
|
|
49
|
+
}
|
|
50
|
+
} catch {
|
|
51
|
+
/* a browser with site data blocked still gets a working page */
|
|
52
|
+
}
|
|
53
|
+
const suyo = (navigator.language || 'en').toLowerCase();
|
|
54
|
+
ELEGIDO = suyo.startsWith('es') ? 'es' : 'en';
|
|
55
|
+
return ELEGIDO;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function ponIdioma(cual: 'es' | 'en'): void {
|
|
59
|
+
ELEGIDO = cual;
|
|
60
|
+
try {
|
|
61
|
+
localStorage.setItem('hall-idioma', cual);
|
|
62
|
+
} catch {
|
|
63
|
+
/* not being able to remember it is not a reason to refuse the switch */
|
|
64
|
+
}
|
|
65
|
+
document.documentElement.lang = cual;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* One string in the showing language.
|
|
70
|
+
*
|
|
71
|
+
* `t` takes the English source: `t('Your cities')`. Interpolation is by
|
|
72
|
+
* `{name}` placeholders so a translator can move them — Spanish does not put
|
|
73
|
+
* numbers and nouns where English does.
|
|
74
|
+
*/
|
|
75
|
+
export function t(fuente: string, valores?: Record<string, string | number>): string {
|
|
76
|
+
const texto = idioma() === 'es' ? (ES[clave(fuente)] ?? fuente) : fuente;
|
|
77
|
+
if (!valores) return texto;
|
|
78
|
+
return texto.replace(/\{(\w+)\}/g, (_, clave) =>
|
|
79
|
+
valores[clave] === undefined ? `{${clave}}` : String(valores[clave]),
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Plural without a library: two forms, which is all these two languages need. */
|
|
84
|
+
export function plural(n: number, una: string, varias: string): string {
|
|
85
|
+
return t(n === 1 ? una : varias, { n: String(n) });
|
|
86
|
+
}
|
package/city/web/src/main.ts
CHANGED
|
@@ -50,7 +50,30 @@ import { Puertas } from './puertas';
|
|
|
50
50
|
let COLOR: Record<string, number> = {};
|
|
51
51
|
let BARRIOS: { id: string; nom: string; cols: number; nota?: string }[] = [];
|
|
52
52
|
const COLOR_POR_DEFECTO = 0xc8b48a;
|
|
53
|
+
//: The canvas behind the city, per skin.
|
|
54
|
+
//:
|
|
55
|
+
//: The map itself stays a night city in both: its buildings, shadows and
|
|
56
|
+
//: asphalt are lit for a dark ground, and bleaching them would be a redesign
|
|
57
|
+
//: rather than a palette swap. What day mode changes is the margin around the
|
|
58
|
+
//: plan — it takes the ground's own colour, so a light Hall frames a deliberate
|
|
59
|
+
//: viewport instead of leaving paper around a black island.
|
|
53
60
|
const SUELO = 0x121821;
|
|
61
|
+
const SUELO_CLARO = 0x0b1119;
|
|
62
|
+
|
|
63
|
+
/** The Hall's theme message: the map is framed by it and must match its light. */
|
|
64
|
+
function esTema(dato: unknown): dato is { type: string; theme: 'light' | 'dark' } {
|
|
65
|
+
const m = dato as { type?: unknown; theme?: unknown } | null;
|
|
66
|
+
return !!m && m.type === 'agents-city-map-theme/1' && (m.theme === 'light' || m.theme === 'dark');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function ponTema(cual: 'light' | 'dark'): void {
|
|
70
|
+
const suelo = cual === 'light' ? SUELO_CLARO : SUELO;
|
|
71
|
+
document.documentElement.dataset.tema = cual;
|
|
72
|
+
document.body.style.background = '#' + suelo.toString(16).padStart(6, '0');
|
|
73
|
+
// The renderer may not exist yet (the message can beat the boot); the body
|
|
74
|
+
// colour above already carries the skin until it does.
|
|
75
|
+
if (appGlobal) appGlobal.renderer.background.color = suelo;
|
|
76
|
+
}
|
|
54
77
|
|
|
55
78
|
interface Parcela {
|
|
56
79
|
id: string;
|
|
@@ -175,6 +198,10 @@ window.addEventListener('message', (message) => {
|
|
|
175
198
|
else configPendiente = message.data;
|
|
176
199
|
return;
|
|
177
200
|
}
|
|
201
|
+
if (esTema(message.data)) {
|
|
202
|
+
ponTema(message.data.theme);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
178
205
|
if (!isMapActivityMessage(message.data)) return;
|
|
179
206
|
const event = message.data.event;
|
|
180
207
|
if (!isSpeechEvent(event) && !isPresenceEvent(event) && !event.kind.startsWith('committee.')) {
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What runs an agent: the provider, the engine and how hard it thinks.
|
|
3
|
+
*
|
|
4
|
+
* One list, imported by both the character sheet and the first-run guide. They
|
|
5
|
+
* used to be the sheet's private constants, which meant the guide could not ask
|
|
6
|
+
* the question at all — somebody built five houses and then went hunting for
|
|
7
|
+
* three dropdowns to set what they had already decided in their head. A curated
|
|
8
|
+
* list is never allowed to hold a card hostage, so `opciones` always keeps a
|
|
9
|
+
* value that is already written even when it is not one of these.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const RUNTIMES = ['claude', 'codex', 'opencode', 'kimi'];
|
|
13
|
+
export const MODELOS = ['haiku', 'sonnet', 'opus', 'fable'];
|
|
14
|
+
export const NIVEL_ESFUERZO: Record<string, number> = {
|
|
15
|
+
low: 1,
|
|
16
|
+
medium: 2,
|
|
17
|
+
high: 3,
|
|
18
|
+
xhigh: 4,
|
|
19
|
+
max: 5,
|
|
20
|
+
};
|
|
21
|
+
export const ESFUERZOS = Object.keys(NIVEL_ESFUERZO);
|
|
22
|
+
|
|
23
|
+
/** `<option>`s from known values plus the current one when it is off the list. */
|
|
24
|
+
export function opciones(
|
|
25
|
+
valores: string[],
|
|
26
|
+
actual: string,
|
|
27
|
+
esc: (s: unknown) => string,
|
|
28
|
+
porDefecto = 'default',
|
|
29
|
+
): string {
|
|
30
|
+
return valores
|
|
31
|
+
.concat(actual && !valores.includes(actual) ? [actual] : [])
|
|
32
|
+
.map(
|
|
33
|
+
(v) =>
|
|
34
|
+
`<option value="${esc(v)}"${v === actual ? ' selected' : ''}>${esc(v || porDefecto)}</option>`,
|
|
35
|
+
)
|
|
36
|
+
.join('');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The engine's honest power reading. An alias maps to its tier; empty means
|
|
40
|
+
* the owner's default — unknown here, so it reads "default" rather than
|
|
41
|
+
* inventing a number. */
|
|
42
|
+
export function nivelDeMotor(model: string): { ancho: number; texto: string; defecto: boolean } {
|
|
43
|
+
const alias = model.toLowerCase();
|
|
44
|
+
if (!alias) return { ancho: 0.5, texto: 'default', defecto: true };
|
|
45
|
+
for (const [clave, ancho] of [
|
|
46
|
+
['fable', 1],
|
|
47
|
+
['opus', 0.8],
|
|
48
|
+
['sonnet', 0.55],
|
|
49
|
+
['haiku', 0.35],
|
|
50
|
+
] as const) {
|
|
51
|
+
if (alias.includes(clave)) return { ancho, texto: alias, defecto: false };
|
|
52
|
+
}
|
|
53
|
+
return { ancho: 0.6, texto: alias, defecto: false };
|
|
54
|
+
}
|
package/docs/agents-first.md
CHANGED
|
@@ -31,7 +31,14 @@ what makes the map polymorphic instead of assuming everyone ships pull requests.
|
|
|
31
31
|
`Agente`, so the launcher, the cage and the map read one model regardless of how
|
|
32
32
|
the card was written.
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
**Read forever, written never.** The legacy shape below is still parsed by
|
|
35
|
+
every door, so a city written a year ago opens today exactly as it did. Nothing
|
|
36
|
+
writes it any more: the wizard and the Hall both produce the agent-first shape
|
|
37
|
+
through `workspace.claves_de_roster`, because two writers of one fact is how the
|
|
38
|
+
terminal and the web ended up able to produce different cities for the same
|
|
39
|
+
city. Re-running `./bin/seat --agents` over an old card upgrades it in place.
|
|
40
|
+
|
|
41
|
+
Legacy (still read — every repo is an agent whose single mount is that repo):
|
|
35
42
|
|
|
36
43
|
```yaml
|
|
37
44
|
repos: [nova, store-service]
|
package/docs/security.md
CHANGED
|
@@ -14,18 +14,32 @@ credential to hold, and a signed trail of everything it tried.
|
|
|
14
14
|
|
|
15
15
|
## Layer 1 — the cage (`plugin/scripts/cage.py`)
|
|
16
16
|
|
|
17
|
-
Claude, OpenCode and Kimi
|
|
18
|
-
|
|
17
|
+
Claude, OpenCode and Kimi agent windows launch confined by the kernel. Two
|
|
18
|
+
mechanisms, one meaning — and the meaning is what `cage.py` owns, so the
|
|
19
|
+
launcher asks for a prefix and never learns which kernel it is on:
|
|
19
20
|
|
|
20
21
|
```
|
|
21
|
-
sandbox-exec -f ~/.agents-city/.runtime/cage/<window>.sb <runtime …>
|
|
22
|
+
macOS sandbox-exec -f ~/.agents-city/.runtime/cage/<window>.sb <runtime …>
|
|
23
|
+
Linux bwrap --ro-bind / / … --tmpfs ~/.ssh … <runtime …>
|
|
22
24
|
```
|
|
23
25
|
|
|
24
|
-
|
|
25
|
-
the working set, then seal the secrets — reads and writes both. The
|
|
26
|
-
(last matching rule wins; children and grandchildren inherit) were
|
|
27
|
-
a real machine before a line of it was written
|
|
28
|
-
|
|
26
|
+
On macOS the profile reads top to bottom: allow everything, deny all writes,
|
|
27
|
+
re-allow the working set, then seal the secrets — reads and writes both. The
|
|
28
|
+
semantics (last matching rule wins; children and grandchildren inherit) were
|
|
29
|
+
verified on a real machine before a line of it was written.
|
|
30
|
+
|
|
31
|
+
On Linux the same shape is expressed as mounts, applied in the same order and
|
|
32
|
+
with the same last-one-wins rule: the whole filesystem read-only, the working
|
|
33
|
+
set re-bound writable, each sealed directory replaced by an empty tmpfs, each
|
|
34
|
+
sealed file replaced by `/dev/null`, and finally this window's own broker token
|
|
35
|
+
re-admitted read-only. A sealed path is not refused — it is *not there*.
|
|
36
|
+
|
|
37
|
+
Availability is checked by doing, not by looking: `bwrap_sirve()` builds a real
|
|
38
|
+
namespace once and remembers the answer, because Ubuntu 24's AppArmor policy and
|
|
39
|
+
hardened kernels can refuse unprivileged user namespaces even with bubblewrap
|
|
40
|
+
installed. Where the cage cannot run, the launcher says so and starts uncaged —
|
|
41
|
+
the behaviour the product always had. `bin/test-cage.py` re-proves both cages
|
|
42
|
+
live: seatbelt on every macOS run, bubblewrap on every Linux run.
|
|
29
43
|
|
|
30
44
|
Inside the cage a window **can**: work freely in its own repo, run builds,
|
|
31
45
|
reach the network, keep its runtime state (`~/.claude`, `~/.codex`,
|
|
@@ -33,10 +47,30 @@ reach the network, keep its runtime state (`~/.claude`, `~/.codex`,
|
|
|
33
47
|
|
|
34
48
|
Inside the cage a window **cannot**: read or write `~/.ssh`, `~/.aws`,
|
|
35
49
|
`~/.kube`, `~/.gnupg`, `~/.docker`, `~/.config/gcloud`, `~/.config/gh`,
|
|
36
|
-
`~/.git-credentials`, `~/.netrc`, `~/.pgpass`, cargo credentials,
|
|
37
|
-
road `.env` under
|
|
38
|
-
|
|
39
|
-
the machine included.
|
|
50
|
+
`~/.git-credentials`, `~/.netrc`, `~/.pgpass`, cargo credentials,
|
|
51
|
+
`~/.claude/.credentials.json`, any remote road `.env` under
|
|
52
|
+
`~/.claude/channels/`, or the broker's state; nor write anywhere outside its
|
|
53
|
+
repo and the allowed runtime/cache set — other repos on the machine included.
|
|
54
|
+
|
|
55
|
+
`~/.claude/.credentials.json` earns its own line because it is the one this
|
|
56
|
+
product created: `~/.claude` stays writable for runtime state, and outside
|
|
57
|
+
macOS there is no Keychain, so Claude Code writes its OAuth tokens there as
|
|
58
|
+
plain JSON. Every third-party credential store was sealed while ours was not,
|
|
59
|
+
which is the sort of hole that only a review looking for it finds.
|
|
60
|
+
|
|
61
|
+
### Where the two cages differ, exactly
|
|
62
|
+
|
|
63
|
+
They seal the same set, and both are re-proved live on every CI run — seatbelt
|
|
64
|
+
on macOS, bubblewrap on Linux. One difference is real and worth knowing:
|
|
65
|
+
|
|
66
|
+
macOS states the road-token rule as a **pattern** (`channels/*/.env`), so a
|
|
67
|
+
road created after the window started is sealed too. Linux states it as one
|
|
68
|
+
**mount per file**, and a mount needs a path that exists — so a road opened
|
|
69
|
+
mid-session is not sealed inside windows that were already running. It is
|
|
70
|
+
sealed for every window started afterwards, and closing and reopening the city
|
|
71
|
+
closes the gap. The alternative — hiding the whole `channels` directory — would
|
|
72
|
+
also hide what the in-window hooks legitimately read, so this stays a known,
|
|
73
|
+
bounded difference rather than a silent one.
|
|
40
74
|
|
|
41
75
|
Dials, all environment variables read at launch:
|
|
42
76
|
|
package/docs/testing.md
CHANGED
|
@@ -149,7 +149,7 @@ temporary prefix before touching a global installation:
|
|
|
149
149
|
```bash
|
|
150
150
|
npm pack
|
|
151
151
|
PREFIX=$(mktemp -d)
|
|
152
|
-
npm install -g --prefix "$PREFIX" ./agents-city
|
|
152
|
+
npm install -g --prefix "$PREFIX" ./agents-city-*.tgz
|
|
153
153
|
"$PREFIX/bin/agents-city" --version
|
|
154
154
|
```
|
|
155
155
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "city",
|
|
3
3
|
"displayName": "Agents City",
|
|
4
|
-
"version": "0.3.0
|
|
4
|
+
"version": "0.3.0",
|
|
5
5
|
"description": "One autonomous city seat with a work domain, role, goal, repo support agents, recognised skills and explicit roads to other cities.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "jlcases",
|
package/plugin/channel/bus.js
CHANGED
|
@@ -20100,7 +20100,7 @@ try {
|
|
|
20100
20100
|
}
|
|
20101
20101
|
var chair = Boolean(context && context.actors[actor]?.role === "chair" && actor === "seat");
|
|
20102
20102
|
var server = new Server(
|
|
20103
|
-
{ name: "agents-city-bus", version: "0.
|
|
20103
|
+
{ name: "agents-city-bus", version: "0.2.1" },
|
|
20104
20104
|
{
|
|
20105
20105
|
capabilities: {
|
|
20106
20106
|
...channelEnabled ? { experimental: { "claude/channel": {} } } : {},
|
package/plugin/channel/bus.ts
CHANGED
|
@@ -24,7 +24,7 @@ try {
|
|
|
24
24
|
|
|
25
25
|
const chair = Boolean(context && context.actors[actor]?.role === 'chair' && actor === 'seat');
|
|
26
26
|
const server = new Server(
|
|
27
|
-
{ name: 'agents-city-bus', version: '0.
|
|
27
|
+
{ name: 'agents-city-bus', version: '0.2.1' },
|
|
28
28
|
{
|
|
29
29
|
capabilities: {
|
|
30
30
|
...(channelEnabled ? { experimental: { 'claude/channel': {} } } : {}),
|
|
@@ -67,7 +67,7 @@ export class CodexConnector implements RuntimeConnector {
|
|
|
67
67
|
async (method, params) => this.providerRequest(method, params),
|
|
68
68
|
);
|
|
69
69
|
await this.rpc.request('initialize', {
|
|
70
|
-
clientInfo: { name: 'agents-city', title: 'Agents City', version: '0.
|
|
70
|
+
clientInfo: { name: 'agents-city', title: 'Agents City', version: '0.2.1' },
|
|
71
71
|
capabilities: { experimentalApi: true },
|
|
72
72
|
});
|
|
73
73
|
await this.rpc.notify('initialized');
|
|
@@ -4894,7 +4894,7 @@ var CodexConnector = class {
|
|
|
4894
4894
|
async (method, params) => this.providerRequest(method, params)
|
|
4895
4895
|
);
|
|
4896
4896
|
await this.rpc.request("initialize", {
|
|
4897
|
-
clientInfo: { name: "agents-city", title: "Agents City", version: "0.
|
|
4897
|
+
clientInfo: { name: "agents-city", title: "Agents City", version: "0.2.1" },
|
|
4898
4898
|
capabilities: { experimentalApi: true }
|
|
4899
4899
|
});
|
|
4900
4900
|
await this.rpc.notify("initialized");
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Knowing there is a new version, and getting it.
|
|
3
|
+
|
|
4
|
+
A tool that cannot tell you it is out of date leaves every owner running
|
|
5
|
+
whatever they installed the day they found it — including the day a security
|
|
6
|
+
fix shipped. So this asks npm, and it asks rarely: once a day at most, cached
|
|
7
|
+
under the runtime dir, and never on a plain command. The check runs where the
|
|
8
|
+
person deliberately opened something (`doctor`, `update`, the Hall), not behind
|
|
9
|
+
their back on every `agents-city cities`.
|
|
10
|
+
|
|
11
|
+
It is a GET to the public registry and nothing else: no identifiers, no
|
|
12
|
+
counters, nothing about this machine leaves it. `CITY_UPDATE_CHECK=0` turns it
|
|
13
|
+
off completely, and the answer is cached so a plane, a firewall or a dead
|
|
14
|
+
registry degrade to silence rather than to an error.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
import time
|
|
22
|
+
import urllib.error
|
|
23
|
+
import urllib.request
|
|
24
|
+
|
|
25
|
+
GUIONES = os.path.dirname(os.path.abspath(__file__))
|
|
26
|
+
sys.path.insert(0, GUIONES)
|
|
27
|
+
import cities # noqa: E402
|
|
28
|
+
|
|
29
|
+
PAQUETE = 'agents-city'
|
|
30
|
+
REGISTRO = f'https://registry.npmjs.org/{PAQUETE}/latest'
|
|
31
|
+
#: A day. The point is to notice a release, not to poll a registry.
|
|
32
|
+
VIDA = 24 * 3600
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def version_instalada():
|
|
36
|
+
"""What is running right now, read from the package this file ships in."""
|
|
37
|
+
raiz = os.path.dirname(os.path.dirname(GUIONES))
|
|
38
|
+
try:
|
|
39
|
+
with open(os.path.join(raiz, 'package.json'), encoding='utf-8') as f:
|
|
40
|
+
return str(json.load(f).get('version') or '')
|
|
41
|
+
except (OSError, ValueError):
|
|
42
|
+
return ''
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _cache():
|
|
46
|
+
return os.path.join(cities.raiz(), '.runtime', 'version.json')
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _lee_cache():
|
|
50
|
+
try:
|
|
51
|
+
with open(_cache(), encoding='utf-8') as f:
|
|
52
|
+
return json.load(f)
|
|
53
|
+
except (OSError, ValueError):
|
|
54
|
+
return {}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _guarda_cache(ultima):
|
|
58
|
+
ruta = _cache()
|
|
59
|
+
try:
|
|
60
|
+
os.makedirs(os.path.dirname(ruta), mode=0o700, exist_ok=True)
|
|
61
|
+
cities.escribe_atomico(ruta, json.dumps({'latest': ultima, 'when': time.time()}))
|
|
62
|
+
except OSError:
|
|
63
|
+
pass # not being able to remember is not a reason to fail a command
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def consulta_registro(timeout=8):
|
|
67
|
+
"""The published version, or '' when the network says nothing useful.
|
|
68
|
+
|
|
69
|
+
Eight seconds because the first call of the day pays DNS and a TLS
|
|
70
|
+
handshake from cold; a tighter budget turns "you are up to date" into a
|
|
71
|
+
lie told by a stopwatch.
|
|
72
|
+
"""
|
|
73
|
+
try:
|
|
74
|
+
peticion = urllib.request.Request(
|
|
75
|
+
REGISTRO, headers={'Accept': 'application/vnd.npm.install-v1+json'}
|
|
76
|
+
)
|
|
77
|
+
with urllib.request.urlopen(peticion, timeout=timeout) as r:
|
|
78
|
+
return str(json.load(r).get('version') or '')
|
|
79
|
+
except (urllib.error.URLError, OSError, ValueError, TimeoutError):
|
|
80
|
+
return ''
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _partes(v):
|
|
84
|
+
"""A version as comparable pieces. A prerelease sorts BELOW its release."""
|
|
85
|
+
base, _, pre = str(v).partition('-')
|
|
86
|
+
numeros = []
|
|
87
|
+
for trozo in base.split('.'):
|
|
88
|
+
try:
|
|
89
|
+
numeros.append(int(trozo))
|
|
90
|
+
except ValueError:
|
|
91
|
+
numeros.append(0)
|
|
92
|
+
while len(numeros) < 3:
|
|
93
|
+
numeros.append(0)
|
|
94
|
+
# No prerelease outranks one: 1.0.0 > 1.0.0-beta.1, as semver says.
|
|
95
|
+
return (numeros[0], numeros[1], numeros[2], 1 if not pre else 0, pre)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def es_mas_nueva(candidata, actual):
|
|
99
|
+
return bool(candidata) and bool(actual) and _partes(candidata) > _partes(actual)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def comprueba(forzar=False, vida=VIDA):
|
|
103
|
+
"""(installed, published, there-is-an-update). Cached, quiet, opt-out-able.
|
|
104
|
+
|
|
105
|
+
Returns the published version as '' when the check is switched off or the
|
|
106
|
+
registry could not be reached — the caller then says nothing, which is the
|
|
107
|
+
right thing to say when you do not know.
|
|
108
|
+
"""
|
|
109
|
+
instalada = version_instalada()
|
|
110
|
+
if os.environ.get('CITY_UPDATE_CHECK', '1') == '0':
|
|
111
|
+
return instalada, '', False
|
|
112
|
+
guardado = _lee_cache()
|
|
113
|
+
fresca = (time.time() - float(guardado.get('when') or 0)) < vida
|
|
114
|
+
if fresca and not forzar:
|
|
115
|
+
ultima = str(guardado.get('latest') or '')
|
|
116
|
+
else:
|
|
117
|
+
ultima = consulta_registro()
|
|
118
|
+
if ultima:
|
|
119
|
+
_guarda_cache(ultima)
|
|
120
|
+
else:
|
|
121
|
+
ultima = str(guardado.get('latest') or '')
|
|
122
|
+
return instalada, ultima, es_mas_nueva(ultima, instalada)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def aviso():
|
|
126
|
+
"""One line for a person who did not ask, or '' when there is nothing to say."""
|
|
127
|
+
instalada, ultima, hay = comprueba()
|
|
128
|
+
if not hay:
|
|
129
|
+
return ''
|
|
130
|
+
return (f' A newer Agents City is out: {instalada} → {ultima}. '
|
|
131
|
+
f'Update with: agents-city update')
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def como_se_instalo():
|
|
135
|
+
"""'npm' when this copy lives inside a global npm install, else 'clone'.
|
|
136
|
+
|
|
137
|
+
It decides what `update` may do: pulling somebody's git checkout from under
|
|
138
|
+
them is not an update, it is a surprise.
|
|
139
|
+
"""
|
|
140
|
+
raiz = os.path.dirname(os.path.dirname(GUIONES))
|
|
141
|
+
if os.path.isdir(os.path.join(raiz, '.git')):
|
|
142
|
+
return 'clone'
|
|
143
|
+
return 'npm' if f'node_modules{os.sep}{PAQUETE}' in raiz else 'clone'
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def actualiza(canal=''):
|
|
147
|
+
"""Install the newest published version over this one. Returns an exit code."""
|
|
148
|
+
if como_se_instalo() == 'clone':
|
|
149
|
+
print(' This is a git checkout, not an npm install.\n'
|
|
150
|
+
' Update it the way you got it:\n'
|
|
151
|
+
' git pull && npm pack && npm install -g ./agents-city-*.tgz')
|
|
152
|
+
return 1
|
|
153
|
+
destino = f'{PAQUETE}@{canal}' if canal else PAQUETE
|
|
154
|
+
print(f' Installing {destino} over {version_instalada()}…')
|
|
155
|
+
hecho = subprocess.run(['npm', 'install', '-g', destino])
|
|
156
|
+
if hecho.returncode != 0:
|
|
157
|
+
print(' npm could not install it. Nothing was changed.', file=sys.stderr)
|
|
158
|
+
return hecho.returncode
|
|
159
|
+
print(' Done. Open cities keep the code they already loaded;\n'
|
|
160
|
+
' `agents-city exit <city>` and then `agents-city seat` picks the new one up.')
|
|
161
|
+
return 0
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _uso():
|
|
165
|
+
print(
|
|
166
|
+
' usage: agents-city update [--check] [--tag beta]\n\n'
|
|
167
|
+
' Install the newest published version, or just ask whether there is one.\n\n'
|
|
168
|
+
' --check say what is installed and what is published, and stop\n'
|
|
169
|
+
' --tag NAME follow a dist-tag (for example: beta)\n\n'
|
|
170
|
+
' The check is one GET to the public npm registry, cached for a day.\n'
|
|
171
|
+
' Nothing about this machine is sent. CITY_UPDATE_CHECK=0 disables it.'
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def main():
|
|
176
|
+
args = sys.argv[1:]
|
|
177
|
+
if any(a in ('-h', '--help', 'help') for a in args):
|
|
178
|
+
_uso()
|
|
179
|
+
return 0
|
|
180
|
+
if '--check' in args:
|
|
181
|
+
instalada, ultima, hay = comprueba(forzar=True)
|
|
182
|
+
if not ultima:
|
|
183
|
+
print(f' Installed: {instalada}. The registry could not be reached '
|
|
184
|
+
'(or the check is off).')
|
|
185
|
+
return 0
|
|
186
|
+
print(f' Installed: {instalada}\n Published: {ultima}')
|
|
187
|
+
print(' There is a newer version — agents-city update' if hay
|
|
188
|
+
else ' You are up to date.')
|
|
189
|
+
return 0
|
|
190
|
+
canal = ''
|
|
191
|
+
if '--tag' in args:
|
|
192
|
+
i = args.index('--tag')
|
|
193
|
+
canal = args[i + 1] if len(args) > i + 1 else ''
|
|
194
|
+
return actualiza(canal)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
if __name__ == '__main__':
|
|
198
|
+
sys.exit(main())
|