@crvouga/dev-fleet 0.1.0 → 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/README.md +265 -37
- package/agents/setup.md +42 -12
- package/ansible/macos/manager.yml +10 -7
- package/ansible/macos/policy.json +2 -10
- package/ansible/macos/t3-policy.yml +49 -0
- package/ansible/monitoring/README.md +86 -25
- package/ansible/monitoring/agent.yml +40 -2
- package/ansible/monitoring/hub-route-darwin.yml +51 -0
- package/ansible/monitoring/lock.json +5 -0
- package/ansible/pyproject.toml +12 -0
- package/ansible/ubuntu/baseline.yml +1 -1
- package/ansible/ubuntu/disk-gc.py +336 -0
- package/ansible/ubuntu/policy.json +13 -3
- package/ansible/ubuntu/t3-desktop.yml +19 -0
- package/ansible/ubuntu/t3-health.sh +93 -22
- package/ansible/ubuntu/t3-idle.py +42 -0
- package/ansible/ubuntu/t3-policy.yml +4 -11
- package/ansible/ubuntu/tailscale.yml +8 -5
- package/ansible/ubuntu/tasks/recovery.yml +60 -1
- package/ansible/ubuntu/tasks/resources.yml +61 -1
- package/ansible/ubuntu/tasks/t3-restart.yml +37 -0
- package/ansible/ubuntu/wip-guard.py +121 -0
- package/ansible/uv.lock +190 -0
- package/bootstrap/bootstrap.sh +25 -18
- package/bootstrap/doctor.ts +46 -1
- package/bootstrap/fleet-manager-run.sh +42 -15
- package/bootstrap/fleet-remote-bootstrap.sh +85 -12
- package/bootstrap/global-config.ts +64 -6
- package/bootstrap/install-launcher.sh +3 -0
- package/bootstrap/native-t3.ts +22 -11
- package/bootstrap/npm.ts +10 -2
- package/bootstrap/release-version.ts +157 -0
- package/cliproxy/README.md +3 -3
- package/cliproxy/scripts/accounts.py +216 -0
- package/cliproxy/scripts/checkpoint.py +29 -1
- package/cliproxy/scripts/cli.py +1 -1
- package/cliproxy/scripts/clientkeys.py +99 -0
- package/cliproxy/scripts/lease.py +80 -0
- package/cliproxy/scripts/ops +137 -25
- package/cliproxy/scripts/restore-verify +15 -0
- package/docs/accounts.md +154 -0
- package/docs/consumer-setup.md +408 -0
- package/docs/declared.md +91 -0
- package/docs/durability.md +26 -0
- package/docs/machine-setup.md +400 -0
- package/docs/operations.md +276 -0
- package/docs/releasing.md +103 -0
- package/docs/state.md +254 -0
- package/docs/topology.md +69 -0
- package/docs/ubuntu-sync.md +357 -0
- package/docs/worker-contract.md +67 -0
- package/examples/fleet.json +5 -0
- package/llms.txt +36 -0
- package/mise.lock +29 -0
- package/mise.toml +15 -2
- package/package.json +11 -2
- package/schema/fleet.schema.json +137 -15
- package/src/accounts/checks.ts +220 -0
- package/src/accounts/context.ts +76 -0
- package/src/accounts/propagate.ts +62 -0
- package/src/accounts/roster.ts +85 -0
- package/src/accounts/selection.ts +67 -0
- package/src/accounts/sessions.ts +120 -0
- package/src/agents/account-relay.ts +176 -26
- package/src/agents/fleet-verify.ts +9 -0
- package/src/agents/launch.ts +84 -5
- package/src/agents/native-verify.ts +293 -22
- package/src/agents/provider-errors.ts +9 -2
- package/src/agents/registry.ts +7 -1
- package/src/cli/agent-guide.ts +39 -5
- package/src/cli/app.ts +11 -4
- package/src/cli/cli.ts +7 -0
- package/src/cli/command-index.ts +7 -0
- package/src/cli/fleet-accounts.ts +253 -0
- package/src/cli/fleet-state.ts +499 -0
- package/src/cli/fleet-sync-accounts.ts +58 -0
- package/src/cli/fleet-sync-checkout.ts +56 -17
- package/src/cli/fleet-sync-enrollment.ts +19 -23
- package/src/cli/fleet-sync-forward.ts +1 -1
- package/src/cli/fleet-sync-manager.ts +84 -12
- package/src/cli/fleet-sync-monitoring.ts +5 -5
- package/src/cli/fleet-sync-output.ts +7 -3
- package/src/cli/fleet-sync-remote.ts +31 -18
- package/src/cli/fleet-sync-state.ts +86 -0
- package/src/cli/fleet-sync.ts +159 -57
- package/src/cli/fleet.ts +4 -2
- package/src/cli/managed-health.ts +4 -4
- package/src/cli/node-sync.ts +7 -0
- package/src/cli/program.ts +20 -4
- package/src/cli/project.ts +51 -0
- package/src/cli/register-fleet.ts +86 -1
- package/src/cli/registry.ts +41 -1
- package/src/cli/roster.ts +46 -30
- package/src/cli/settings.ts +15 -12
- package/src/cli/source-operations.ts +27 -3
- package/src/cli/tailnet.ts +2 -2
- package/src/definition.ts +116 -4
- package/src/diagnostics.ts +1 -1
- package/src/fleet-config.ts +105 -19
- package/src/ide/definition.ts +86 -0
- package/src/ide/ide.ts +43 -0
- package/src/ide/index.ts +5 -0
- package/src/ide/orchestrate.ts +50 -0
- package/src/ide/t3.ts +111 -0
- package/src/machine/source.ts +3 -3
- package/src/machine/state.ts +15 -27
- package/src/manager-sync.ts +9 -18
- package/src/monitoring/agent-evidence.ts +146 -0
- package/src/monitoring/apply.ts +15 -3
- package/src/monitoring/beszel.ts +11 -4
- package/src/monitoring/enroll.ts +2 -1
- package/src/monitoring/hub-access.ts +4 -3
- package/src/monitoring/hub-backup.ts +121 -0
- package/src/monitoring/hub-viewer.ts +8 -8
- package/src/monitoring/hub.ts +3 -3
- package/src/monitoring/manager-hub.ts +30 -14
- package/src/node-converge-linux.ts +30 -7
- package/src/package-info.ts +45 -0
- package/src/platform/ansible.ts +19 -15
- package/src/platform/darwin.ts +0 -13
- package/src/platform/fallback.ts +0 -8
- package/src/platform/linux.ts +0 -10
- package/src/platform/resource-verify.ts +8 -4
- package/src/platform/t3-policy.ts +33 -6
- package/src/platform/t3-service.ts +104 -0
- package/src/platform/types.ts +0 -4
- package/src/platform/ubuntu-audit.ts +5 -2
- package/src/platform/windows.ts +0 -14
- package/src/remote-bootstrap.ts +17 -19
- package/src/remote-entry.ts +15 -1
- package/src/repair-catalog.ts +1 -1
- package/src/runner.ts +1 -1
- package/src/service-auth.ts +15 -5
- package/src/state/bucket.ts +208 -0
- package/src/state/check.ts +74 -0
- package/src/state/connection.ts +92 -0
- package/src/state/enrollment.ts +45 -0
- package/src/state/legacy.ts +89 -0
- package/src/state/litestream.ts +143 -0
- package/src/state/operator.ts +20 -0
- package/src/state/paths.ts +40 -0
- package/src/state/roster.ts +62 -0
- package/src/state/store.ts +169 -0
- package/src/state/sync.ts +352 -0
- package/src/t3/accounts.ts +7 -1
- package/src/t3/models.ts +133 -0
- package/src/t3/pairing.ts +31 -0
- package/src/t3/profile.ts +203 -0
- package/src/t3/version.ts +119 -0
- package/src/t3-endpoint.ts +64 -8
- package/src/t3-linked-check.ts +24 -2
- package/src/toolchain-checks.ts +25 -0
- package/src/transport.ts +3 -2
- package/src/types.ts +4 -0
- package/src/worker-access-checks.ts +56 -3
- package/src/worker-host-checks.ts +41 -13
- package/src/workstation-checks.ts +1 -1
- package/src/index.ts +0 -6
- package/src/test-fixtures.ts +0 -36
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
existsSync,
|
|
5
5
|
mkdirSync,
|
|
6
6
|
readFileSync,
|
|
7
|
+
readdirSync,
|
|
7
8
|
statSync,
|
|
8
9
|
writeFileSync,
|
|
9
10
|
} from 'node:fs';
|
|
@@ -48,17 +49,23 @@ export function assertManagedConfig(dir: string): void {
|
|
|
48
49
|
}
|
|
49
50
|
}
|
|
50
51
|
|
|
52
|
+
/** What `publishGlobalConfig` writes to config.toml, given what is there. */
|
|
53
|
+
function publishedConfig(root: string, previous: string | undefined): string {
|
|
54
|
+
return (
|
|
55
|
+
MARKER +
|
|
56
|
+
withTrustedPaths(
|
|
57
|
+
readFileSync(join(root, 'mise.toml'), 'utf8'),
|
|
58
|
+
trustedPaths(previous)
|
|
59
|
+
)
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
51
63
|
export function publishGlobalConfig(dir: string, root = workspaceRoot): void {
|
|
52
64
|
assertManagedConfig(dir);
|
|
53
65
|
mkdirSync(dir, { recursive: true });
|
|
54
66
|
const path = join(dir, 'config.toml');
|
|
55
67
|
const previous = existsSync(path) ? readFileSync(path, 'utf8') : undefined;
|
|
56
|
-
const config =
|
|
57
|
-
MARKER +
|
|
58
|
-
withTrustedPaths(
|
|
59
|
-
readFileSync(join(root, 'mise.toml'), 'utf8'),
|
|
60
|
-
trustedPaths(previous)
|
|
61
|
-
);
|
|
68
|
+
const config = publishedConfig(root, previous);
|
|
62
69
|
if (previous !== config) writeFileSync(path, config);
|
|
63
70
|
cpSync(join(root, 'mise.lock'), join(dir, 'mise.lock'), {
|
|
64
71
|
filter: changedFile,
|
|
@@ -71,6 +78,57 @@ export function publishGlobalConfig(dir: string, root = workspaceRoot): void {
|
|
|
71
78
|
});
|
|
72
79
|
}
|
|
73
80
|
|
|
81
|
+
/**
|
|
82
|
+
* How the global mise configuration stands against what this revision
|
|
83
|
+
* publishes. On a fleet machine it belongs to dev-fleet: a project pins its
|
|
84
|
+
* tools in its own mise.toml and never publishes a global one.
|
|
85
|
+
*
|
|
86
|
+
* - `published`: dev-fleet's marker, this revision's pins and lock
|
|
87
|
+
* - `missing`: there is no global config.toml
|
|
88
|
+
* - `replaced`: another owner (or nobody's marker) wrote it
|
|
89
|
+
* - `changed`: dev-fleet's marker, but not this revision's content
|
|
90
|
+
*/
|
|
91
|
+
export type GlobalConfigStanding =
|
|
92
|
+
'published' | 'missing' | 'replaced' | 'changed';
|
|
93
|
+
|
|
94
|
+
export function globalConfigStanding(
|
|
95
|
+
dir: string,
|
|
96
|
+
root = workspaceRoot
|
|
97
|
+
): GlobalConfigStanding {
|
|
98
|
+
const path = join(dir, 'config.toml');
|
|
99
|
+
if (!existsSync(path)) return 'missing';
|
|
100
|
+
const current = readFileSync(path, 'utf8');
|
|
101
|
+
if (!current.startsWith(MARKER)) return 'replaced';
|
|
102
|
+
const same = (source: string, target: string): boolean =>
|
|
103
|
+
existsSync(target) && readFileSync(source).equals(readFileSync(target));
|
|
104
|
+
const sidecars = join(root, '.mise/locks');
|
|
105
|
+
const locks = existsSync(sidecars)
|
|
106
|
+
? (readdirSync(sidecars, { recursive: true }) as string[])
|
|
107
|
+
.map((name) => join('.mise/locks', name))
|
|
108
|
+
.filter((name) => statSync(join(root, name)).isFile())
|
|
109
|
+
: [];
|
|
110
|
+
// Paths trusted on the machine are kept by every publish, in any order;
|
|
111
|
+
// the agent worktree roots must be among them.
|
|
112
|
+
const TRUSTED = /^trusted_config_paths\s*=.*\n/m;
|
|
113
|
+
const trusted = trustedPaths(current);
|
|
114
|
+
let listed: unknown;
|
|
115
|
+
try {
|
|
116
|
+
listed = (Bun.TOML.parse(current) as { settings?: Record<string, unknown> })
|
|
117
|
+
.settings?.['trusted_config_paths'];
|
|
118
|
+
} catch {
|
|
119
|
+
listed = undefined;
|
|
120
|
+
}
|
|
121
|
+
const unchanged =
|
|
122
|
+
Array.isArray(listed) &&
|
|
123
|
+
trusted.every((path) => listed.includes(path)) &&
|
|
124
|
+
current.replace(TRUSTED, '') ===
|
|
125
|
+
publishedConfig(root, current).replace(TRUSTED, '') &&
|
|
126
|
+
['mise.lock', ...locks].every((name) =>
|
|
127
|
+
same(join(root, name), join(dir, name))
|
|
128
|
+
);
|
|
129
|
+
return unchanged ? 'published' : 'changed';
|
|
130
|
+
}
|
|
131
|
+
|
|
74
132
|
/**
|
|
75
133
|
* The managed roots plus any entries already in the published config, so a
|
|
76
134
|
* local `mise settings add trusted_config_paths ...` survives the next publish.
|
|
@@ -3,5 +3,8 @@
|
|
|
3
3
|
set -eu
|
|
4
4
|
root=${1:-$(cd "$(dirname "$0")/.." && pwd)}
|
|
5
5
|
[ -x "$root/bootstrap/devfleet" ] || exit 1
|
|
6
|
+
# Only ever a checkout: an installed package sits wherever a package manager
|
|
7
|
+
# put it and may be gone tomorrow.
|
|
8
|
+
[ -e "$root/.git" ] || { echo 'The devfleet launcher links to a checkout of dev-fleet; fleet-sync installs it.' >&2; exit 1; }
|
|
6
9
|
mkdir -p "$HOME/.local/bin"
|
|
7
10
|
ln -sfn "$root/bootstrap/devfleet" "$HOME/.local/bin/devfleet"
|
package/bootstrap/native-t3.ts
CHANGED
|
@@ -19,6 +19,7 @@ interface Configuration {
|
|
|
19
19
|
t3_version: string;
|
|
20
20
|
t3_linux_x64_sha256: string;
|
|
21
21
|
t3_linux_arm64_sha256: string;
|
|
22
|
+
t3_darwin_arm64_sha256?: string;
|
|
22
23
|
t3_desktop_amd64_sha256: string;
|
|
23
24
|
t3_desktop_arm64_sha256: string;
|
|
24
25
|
};
|
|
@@ -52,20 +53,27 @@ export function managedT3Desktop() {
|
|
|
52
53
|
) as Configuration;
|
|
53
54
|
return desktopT3Artifact(config, process.arch);
|
|
54
55
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
/** The pinned server archive: Ubuntu workers on either architecture, and
|
|
57
|
+
* the manager (macOS on Apple silicon, the only macOS build upstream ships).
|
|
58
|
+
*/
|
|
59
|
+
export function nativeT3Artifact(
|
|
60
|
+
config: Configuration,
|
|
61
|
+
architecture: string,
|
|
62
|
+
platform: string = 'linux'
|
|
63
|
+
) {
|
|
64
|
+
const sha256 = (<Record<string, string | undefined>>{
|
|
65
|
+
'linux-x64': config.vars.t3_linux_x64_sha256,
|
|
66
|
+
'linux-arm64': config.vars.t3_linux_arm64_sha256,
|
|
67
|
+
'darwin-arm64': config.vars.t3_darwin_arm64_sha256,
|
|
68
|
+
})[`${platform}-${architecture}`];
|
|
69
|
+
if (sha256 === undefined) throw new Error('Unsupported T3 architecture.');
|
|
58
70
|
const version = config.vars.t3_version;
|
|
59
|
-
const sha256 =
|
|
60
|
-
architecture === 'x64'
|
|
61
|
-
? config.vars.t3_linux_x64_sha256
|
|
62
|
-
: config.vars.t3_linux_arm64_sha256;
|
|
63
71
|
if (
|
|
64
72
|
!/^\d+\.\d+\.\d+(?:-nightly\.\d{8}\.\d+)?$/.test(version) ||
|
|
65
73
|
!/^[a-f0-9]{64}$/.test(sha256)
|
|
66
74
|
)
|
|
67
75
|
throw new Error('Invalid T3 pin.');
|
|
68
|
-
const name = `t3-${version}
|
|
76
|
+
const name = `t3-${version}-${platform}-${architecture}`;
|
|
69
77
|
return {
|
|
70
78
|
version,
|
|
71
79
|
sha256,
|
|
@@ -77,18 +85,21 @@ export function managedT3(): { path: string; version: string } {
|
|
|
77
85
|
const config = Bun.TOML.parse(
|
|
78
86
|
readFileSync(join(workspaceRoot, 'mise.toml'), 'utf8')
|
|
79
87
|
) as Configuration;
|
|
80
|
-
const artifact = nativeT3Artifact(config, process.arch);
|
|
88
|
+
const artifact = nativeT3Artifact(config, process.arch, process.platform);
|
|
81
89
|
return {
|
|
82
90
|
path: join(homedir(), '.local/share/personal/tools', artifact.name, 't3'),
|
|
83
91
|
version: artifact.version,
|
|
84
92
|
};
|
|
85
93
|
}
|
|
94
|
+
/** Installs the pinned, checksum-verified server archive on a worker and on
|
|
95
|
+
* the manager, so both run the T3 version the fleet pins.
|
|
96
|
+
*/
|
|
86
97
|
export async function installNativeT3(): Promise<void> {
|
|
87
|
-
if (process.platform
|
|
98
|
+
if (!['linux', 'darwin'].includes(process.platform)) return;
|
|
88
99
|
const config = Bun.TOML.parse(
|
|
89
100
|
readFileSync(join(workspaceRoot, 'mise.toml'), 'utf8')
|
|
90
101
|
) as Configuration;
|
|
91
|
-
const artifact = nativeT3Artifact(config, process.arch);
|
|
102
|
+
const artifact = nativeT3Artifact(config, process.arch, process.platform);
|
|
92
103
|
const root = join(homedir(), '.local/share/personal/tools');
|
|
93
104
|
const archive = join(root, `${artifact.name}.tar.gz`);
|
|
94
105
|
mkdirSync(root, { recursive: true, mode: 0o700 });
|
package/bootstrap/npm.ts
CHANGED
|
@@ -162,7 +162,11 @@ export function plan(state: State): Step[] {
|
|
|
162
162
|
{
|
|
163
163
|
id: 'tag',
|
|
164
164
|
title: `Tag ${seedTag} on the commit that was published`,
|
|
165
|
-
|
|
165
|
+
// npm recorded no commit for a seed published without one, so there is
|
|
166
|
+
// nothing to tag: the Release workflow then counts from the newest
|
|
167
|
+
// version on npm (bootstrap/release-version.ts).
|
|
168
|
+
done:
|
|
169
|
+
state.tagRemote || (state.published && state.publishedCommit === null),
|
|
166
170
|
blocked: null,
|
|
167
171
|
},
|
|
168
172
|
];
|
|
@@ -204,9 +208,13 @@ function publishSeed(exec: Exec, target: Target): boolean {
|
|
|
204
208
|
);
|
|
205
209
|
const file = join(checkout, 'package.json');
|
|
206
210
|
const manifest = JSON.parse(readFileSync(file, 'utf8')) as object;
|
|
211
|
+
// As the Release workflow does: the package names the commit it is.
|
|
212
|
+
const gitHead = exec
|
|
213
|
+
.capture(['git', 'rev-parse', 'HEAD'], checkout)
|
|
214
|
+
.stdout.trim();
|
|
207
215
|
writeFileSync(
|
|
208
216
|
file,
|
|
209
|
-
`${JSON.stringify({ ...manifest, version: SEED_VERSION }, null, 2)}\n`
|
|
217
|
+
`${JSON.stringify({ ...manifest, version: SEED_VERSION, gitHead }, null, 2)}\n`
|
|
210
218
|
);
|
|
211
219
|
return exec.interactive(['npm', 'publish', '--access', 'public'], checkout);
|
|
212
220
|
} finally {
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prints the version the Release workflow should publish for `HEAD`, or
|
|
3
|
+
* nothing when there is nothing to release (the reason goes to stderr).
|
|
4
|
+
*
|
|
5
|
+
* The newest `vX.Y.Z` tag is the last release. Without one, the newest
|
|
6
|
+
* version on npm is: it was published by hand before the workflow tagged
|
|
7
|
+
* anything, and npm may not have recorded its commit, so every commit up to
|
|
8
|
+
* `HEAD` counts. Commits since the last release pick the bump, Conventional
|
|
9
|
+
* Commits style:
|
|
10
|
+
*
|
|
11
|
+
* `feat!:` / `BREAKING CHANGE:` major (minor while the major is 0)
|
|
12
|
+
* `feat:` minor
|
|
13
|
+
* anything else patch
|
|
14
|
+
*
|
|
15
|
+
* Exits 1 when nothing can be released because publishing was never set up
|
|
16
|
+
* (no tag and nothing on npm), so the Release workflow does not report a
|
|
17
|
+
* skipped release as a success.
|
|
18
|
+
*
|
|
19
|
+
* bun bootstrap/release-version.ts
|
|
20
|
+
*/
|
|
21
|
+
import { spawnSync } from 'node:child_process';
|
|
22
|
+
import { appendFileSync, readFileSync } from 'node:fs';
|
|
23
|
+
import { join } from 'node:path';
|
|
24
|
+
|
|
25
|
+
export interface Git {
|
|
26
|
+
/** Run git and return stdout, or null when it exits non-zero. */
|
|
27
|
+
(args: string[]): string | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type Decision =
|
|
31
|
+
| { release: true; version: string; previous: string }
|
|
32
|
+
| { release: false; reason: string; setup?: true };
|
|
33
|
+
|
|
34
|
+
const TAG = /^v(\d+)\.(\d+)\.(\d+)$/;
|
|
35
|
+
const BREAKING_SUBJECT = /^\w+(\([^)]*\))?!:/;
|
|
36
|
+
const BREAKING_FOOTER = /^BREAKING[ -]CHANGE:/m;
|
|
37
|
+
const FEATURE = /^feat(\([^)]*\))?:/;
|
|
38
|
+
|
|
39
|
+
export type Bump = 'major' | 'minor' | 'patch';
|
|
40
|
+
|
|
41
|
+
export function bumpFor(messages: string[]): Bump {
|
|
42
|
+
let bump: Bump = 'patch';
|
|
43
|
+
for (const message of messages) {
|
|
44
|
+
const subject = message.split('\n', 1)[0]!.trim();
|
|
45
|
+
if (BREAKING_SUBJECT.test(subject) || BREAKING_FOOTER.test(message))
|
|
46
|
+
return 'major';
|
|
47
|
+
if (FEATURE.test(subject)) bump = 'minor';
|
|
48
|
+
}
|
|
49
|
+
return bump;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function bumpVersion(tag: string, bump: Bump): string {
|
|
53
|
+
const match = TAG.exec(tag);
|
|
54
|
+
if (!match) throw new Error(`Not a release tag: ${tag}`);
|
|
55
|
+
const [major, minor, patch] = match.slice(1).map(Number) as [
|
|
56
|
+
number,
|
|
57
|
+
number,
|
|
58
|
+
number,
|
|
59
|
+
];
|
|
60
|
+
if (bump === 'major' && major > 0) return `${major + 1}.0.0`;
|
|
61
|
+
if (bump === 'patch') return `${major}.${minor}.${patch + 1}`;
|
|
62
|
+
return `${major}.${minor + 1}.0`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function latestTag(git: Git): string | null {
|
|
66
|
+
const tags = git(['tag', '--list', 'v*', '--sort=-v:refname']) ?? '';
|
|
67
|
+
return (
|
|
68
|
+
tags
|
|
69
|
+
.split('\n')
|
|
70
|
+
.map((tag) => tag.trim())
|
|
71
|
+
.find((tag) => TAG.test(tag)) ?? null
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The newest stable `X.Y.Z` of these npm versions, or null. */
|
|
76
|
+
export function newestPublished(versions: string[]): string | null {
|
|
77
|
+
const stable = versions
|
|
78
|
+
.filter((version) => TAG.test(`v${version}`))
|
|
79
|
+
.map((version) => version.split('.').map(Number) as number[]);
|
|
80
|
+
stable.sort((a, b) => b[0]! - a[0]! || b[1]! - a[1]! || b[2]! - a[2]!);
|
|
81
|
+
return stable[0]?.join('.') ?? null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* `published` is the newest stable version on npm, or null when the package
|
|
86
|
+
* is not there.
|
|
87
|
+
*/
|
|
88
|
+
export function decide(git: Git, published: string | null = null): Decision {
|
|
89
|
+
const tag = latestTag(git);
|
|
90
|
+
if (!tag && !published)
|
|
91
|
+
return {
|
|
92
|
+
release: false,
|
|
93
|
+
setup: true,
|
|
94
|
+
reason:
|
|
95
|
+
'No vX.Y.Z tag and nothing on npm yet: run `bun run bootstrap` once first.',
|
|
96
|
+
};
|
|
97
|
+
const previous = tag ?? `v${published}`;
|
|
98
|
+
if (tag && git(['merge-base', '--is-ancestor', 'HEAD', tag]) !== null)
|
|
99
|
+
return { release: false, reason: `HEAD is already in ${tag}.` };
|
|
100
|
+
const log =
|
|
101
|
+
git(['log', '--format=%B%x00', tag ? `${tag}..HEAD` : 'HEAD']) ?? '';
|
|
102
|
+
const messages = log
|
|
103
|
+
.split('\0')
|
|
104
|
+
.map((message) => message.trim())
|
|
105
|
+
.filter(Boolean);
|
|
106
|
+
if (messages.length === 0)
|
|
107
|
+
return { release: false, reason: `No commits since ${previous}.` };
|
|
108
|
+
return {
|
|
109
|
+
release: true,
|
|
110
|
+
version: bumpVersion(previous, bumpFor(messages)),
|
|
111
|
+
previous,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const system: Git = (args) => {
|
|
116
|
+
const result = spawnSync('git', args, { encoding: 'utf8' });
|
|
117
|
+
return result.status === 0 ? (result.stdout ?? '') : null;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/** The versions of the package on npm; empty when it is not there. */
|
|
121
|
+
function npmVersions(): string[] {
|
|
122
|
+
const name = (
|
|
123
|
+
JSON.parse(
|
|
124
|
+
readFileSync(join(import.meta.dir, '..', 'package.json'), 'utf8')
|
|
125
|
+
) as { name: string }
|
|
126
|
+
).name;
|
|
127
|
+
const result = spawnSync('npm', ['view', name, 'versions', '--json'], {
|
|
128
|
+
encoding: 'utf8',
|
|
129
|
+
});
|
|
130
|
+
if (result.status !== 0) return [];
|
|
131
|
+
const versions: unknown = JSON.parse(result.stdout || '[]');
|
|
132
|
+
return Array.isArray(versions)
|
|
133
|
+
? versions.filter((v): v is string => typeof v === 'string')
|
|
134
|
+
: typeof versions === 'string'
|
|
135
|
+
? [versions]
|
|
136
|
+
: [];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (import.meta.main) {
|
|
140
|
+
const decision = decide(system, newestPublished(npmVersions()));
|
|
141
|
+
const summary = process.env['GITHUB_STEP_SUMMARY'];
|
|
142
|
+
const note = (line: string) => {
|
|
143
|
+
if (summary) appendFileSync(summary, `${line}\n`);
|
|
144
|
+
};
|
|
145
|
+
if (decision.release) {
|
|
146
|
+
console.error(`${decision.previous} -> v${decision.version}`);
|
|
147
|
+
note(`Releasing v${decision.version} (after ${decision.previous}).`);
|
|
148
|
+
console.log(decision.version);
|
|
149
|
+
} else if (decision.setup) {
|
|
150
|
+
console.error(`::error::${decision.reason}`);
|
|
151
|
+
note(`**Nothing was released.** ${decision.reason}`);
|
|
152
|
+
process.exitCode = 1;
|
|
153
|
+
} else {
|
|
154
|
+
console.error(decision.reason);
|
|
155
|
+
note(`Nothing to release: ${decision.reason}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
package/cliproxy/README.md
CHANGED
|
@@ -146,7 +146,7 @@ devfleet cliproxy inference responses EXACT_CODEX_MODEL_ID
|
|
|
146
146
|
devfleet cliproxy inference messages EXACT_CLAUDE_MODEL_ID
|
|
147
147
|
```
|
|
148
148
|
|
|
149
|
-
Codex uses the upstream device-code flow. Remote callback flows may require the documented dashboard callback flow or temporary SSH loopback forwarding; callback ports are not exposed by default. This pinned version has no generic Gemini CLI login flag; use its supported dashboard/API-key integration. Provider rules still govern permissible account use.
|
|
149
|
+
`devfleet fleet-accounts --add|--relogin|--remove` wraps these and propagates the result to every node; a login writes into a staging directory and only a complete credential is moved in. Codex uses the upstream device-code flow. Remote callback flows may require the documented dashboard callback flow or temporary SSH loopback forwarding; callback ports are not exposed by default. This pinned version has no generic Gemini CLI login flag; use its supported dashboard/API-key integration. Provider rules still govern permissible account use.
|
|
150
150
|
|
|
151
151
|
**Account routing.** Credential files are the only list of accounts. `ops declared` (run by every fleet-sync) gives each Claude, Codex and Antigravity credential the model prefix `<type>-<email>` through the proxy's management API (`scripts/accounts.py`; a credential file is never edited directly). A request for `<type>-<email>/<model>` is served only by that account, and `/v1/models` lists those ids, which is how every fleet node discovers the accounts to offer in T3. Nothing in Git names an account.
|
|
152
152
|
|
|
@@ -167,7 +167,7 @@ python3 cliproxy/clients/bootstrap.py --validate
|
|
|
167
167
|
|
|
168
168
|
Enter the inference key at the hidden prompt. It is available in Vault `secret/personal/prd:CLIPROXY_FLEET_CLIENT_API_KEY`; dashboard login uses `CLIPROXY_FLEET_MANAGEMENT_KEY`. Secrets never appear in command arguments. Keys are saved mode 0600 and loaded only into child environments. Give runners separate revocable inference keys where needed, never provider OAuth credentials or the admin key.
|
|
169
169
|
|
|
170
|
-
Codex uses custom-provider CLI overrides and Responses HTTP/SSE. Claude uses its supported base URL/auth-token variables and an explicit Claude model. OpenCode uses its config-file override, with separate Responses and Anthropic SDK providers. Once Claude is authenticated, rerun with `--claude-model MODEL_ID`; otherwise the Claude launcher fails with an explicit setup requirement. Native client config files are preserved.
|
|
170
|
+
Codex uses custom-provider CLI overrides and Responses HTTP/SSE. Claude uses its supported base URL/auth-token variables and an explicit Claude model. OpenCode uses its config-file override, with separate Responses and Anthropic SDK providers. Once Claude is authenticated, rerun with `--claude-model MODEL_ID`; otherwise the Claude launcher fails with an explicit setup requirement. Native client config files are preserved. Fleet nodes never fall back to native clients: provider accounts exist only on the proxy (see [docs/accounts.md](../docs/accounts.md)), and during a proxy outage every relayed launcher retries for about a minute, then reports "proxy unreachable".
|
|
171
171
|
|
|
172
172
|
Run bootstrap from a repository checkout containing `clients/launcher.py`; detached downloaded bootstrap files are unsupported. Bootstrap is idempotent and refuses unrelated launcher collisions. `--uninstall` removes launcher links while retaining private settings for deliberate cleanup. On Windows run inside WSL; native PowerShell launchers are not provided. Validate from each actual machine and target repository because OpenCode merges project settings.
|
|
173
173
|
|
|
@@ -187,7 +187,7 @@ Vault login is interactive with hidden input; its token is restricted locally an
|
|
|
187
187
|
|
|
188
188
|
Encrypted cloud checkpoints run every 15 minutes (legacy systemd adds up to 30 seconds jitter). A checkpoint never stops, pauses or restarts the stack: agents use the proxy around the clock, and a stopped connector fails every request. The capture copies the live state and keeps the copy only if no state file changed while it was taken (retrying a few times); a stack that never holds still fails that backup. A failed capture, restart, upload or retention operation records failure and never advances the success marker. Retention keeps the latest 96 snapshots plus 7 daily, 5 weekly and 12 monthly. Weekly integrity checks read 10% of stored data; `check-full` reads all data. Monitoring fails when the last successful checkpoint is older than 45 minutes. Failures are visible in `cliproxy status` (and `launchctl print gui/$UID/LABEL` or systemd); off-machine checks also run through the CLI proxy health workflow described below.
|
|
189
189
|
|
|
190
|
-
R2 checkpoints are the durable recovery source for config, provider OAuth/account state and the dedicated Tailscale identity. Local state is a working cache with asynchronous durability: normally up to approximately 15 minutes plus upload duration can be lost, and an outage can extend that indefinitely. Run a successful manual checkpoint after important login/config changes or before moving machines. Provider refresh-token invalidation can still require a fresh provider login after recovering an older checkpoint. This is a single-writer deployment, with operator fencing of the previous node; there is no distributed lease or active-active support.
|
|
190
|
+
R2 checkpoints are the durable recovery source for config, provider OAuth/account state and the dedicated Tailscale identity. Local state is a working cache with asynchronous durability: normally up to approximately 15 minutes plus upload duration can be lost, and an outage can extend that indefinitely. Run a successful manual checkpoint after important login/config changes or before moving machines. Provider refresh-token invalidation can still require a fresh provider login after recovering an older checkpoint. Each checkpoint records a digest of every refresh token, so `restore-verify RESTORED --newest NEWEST` names exactly the accounts that need one. This is a single-writer deployment, with operator fencing of the previous node; there is no distributed lease or active-active support.
|
|
191
191
|
|
|
192
192
|
Checkpoints include only allowlisted runtime state, endpoint intent, repository identity and source revision metadata. GitHub supplies code; Vault supplies client/admin keys, the encryption password and current cloud credentials. Vault/tunnel tokens, local secret files, logs, prior config copies, status files and executable source are excluded. Config and OAuth files themselves contain secrets and remain encrypted in R2. Local capture/restore directories are private plaintext caches, deleted after normal completion. Abandoned generated `checkpoint-*` and `restore-*` directories under `recovery/` expire after 24 hours. Cleanup runs under the operation lock before each backup operation, so active local captures/restores cannot be collected. Unknown/manual rollback directories are preserved. Restic runs without a persistent local metadata cache.
|
|
193
193
|
|
|
@@ -36,3 +36,219 @@ def pending(auths):
|
|
|
36
36
|
if wanted and data.get('prefix') != wanted:
|
|
37
37
|
result.append((path.name, wanted))
|
|
38
38
|
return result
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ── Account state ────────────────────────────────────────────────────────────
|
|
42
|
+
#
|
|
43
|
+
# One state per credential, from its file (never a token, only whether one is
|
|
44
|
+
# there and when the access token expires) and from the proxy's management
|
|
45
|
+
# API, which knows about refreshes, cooldowns and disabled credentials.
|
|
46
|
+
|
|
47
|
+
STATES = ('usable', 'cooling-down', 'needs-login', 'refresh-failing', 'removed')
|
|
48
|
+
|
|
49
|
+
# A refresh the provider refused for good: only a new sign-in helps.
|
|
50
|
+
REJECTED = re.compile(r'invalid_grant|invalid_token|unauthorized|revoked|refresh token (?:is )?(?:invalid|expired|rejected|reused)', re.I)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _instant(value):
|
|
54
|
+
"""Seconds since the epoch from an ISO 8601 string or a number, or None."""
|
|
55
|
+
import datetime
|
|
56
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0:
|
|
57
|
+
return float(value) / (1000 if value > 1e11 else 1)
|
|
58
|
+
if isinstance(value, str) and value and not value.startswith('0001-'):
|
|
59
|
+
try:
|
|
60
|
+
parsed = datetime.datetime.fromisoformat(value.replace('Z', '+00:00'))
|
|
61
|
+
except ValueError:
|
|
62
|
+
return None
|
|
63
|
+
if parsed.tzinfo is None:
|
|
64
|
+
parsed = parsed.replace(tzinfo=datetime.timezone.utc)
|
|
65
|
+
return parsed.timestamp()
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def iso(seconds):
|
|
70
|
+
import datetime
|
|
71
|
+
return datetime.datetime.fromtimestamp(seconds, datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def classify(credential, entry, now):
|
|
75
|
+
"""(state, until): `credential` is the auth file, `entry` what the
|
|
76
|
+
management API says about it (None when it does not list it). `until` is
|
|
77
|
+
when a cooldown clears, as epoch seconds, or None."""
|
|
78
|
+
entry = entry or {}
|
|
79
|
+
if entry.get('disabled') is True or credential.get('disabled') is True:
|
|
80
|
+
return 'removed', None
|
|
81
|
+
message = str(entry.get('status_message') or entry.get('last_error') or '')
|
|
82
|
+
expires = _instant(credential.get('expired') or credential.get('expiry'))
|
|
83
|
+
expired = expires is not None and expires <= now
|
|
84
|
+
has_refresh = bool(credential.get('refresh_token'))
|
|
85
|
+
if REJECTED.search(message) or (expired and not has_refresh):
|
|
86
|
+
return 'needs-login', None
|
|
87
|
+
if entry.get('status') == 'error' and 'refresh' in message.lower():
|
|
88
|
+
return ('needs-login' if expired else 'refresh-failing'), None
|
|
89
|
+
quota = entry.get('quota') if isinstance(entry.get('quota'), dict) else {}
|
|
90
|
+
until = max(
|
|
91
|
+
[t for t in (_instant(entry.get('next_retry_after')),
|
|
92
|
+
_instant(quota.get('next_recover_at'))) if t is not None and t > now]
|
|
93
|
+
or [0]
|
|
94
|
+
)
|
|
95
|
+
if until or entry.get('unavailable') is True or quota.get('exceeded') is True:
|
|
96
|
+
return 'cooling-down', (until or None)
|
|
97
|
+
return 'usable', None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def credentials(auths):
|
|
101
|
+
"""[(file name, credential)] for every readable credential, in a stable
|
|
102
|
+
order: by provider, then sign-in email, then file name. Each provider's
|
|
103
|
+
accounts are numbered in this order, the same order every node lists
|
|
104
|
+
them in (src/agents/accounts.ts), so an index means one account
|
|
105
|
+
everywhere."""
|
|
106
|
+
found = []
|
|
107
|
+
for path in pathlib.Path(auths).glob('*.json'):
|
|
108
|
+
try:
|
|
109
|
+
data = json.loads(path.read_text())
|
|
110
|
+
except (OSError, ValueError):
|
|
111
|
+
continue
|
|
112
|
+
if isinstance(data, dict) and isinstance(data.get('type'), str):
|
|
113
|
+
found.append((path.name, data))
|
|
114
|
+
email = lambda data: data.get('email') if isinstance(data.get('email'), str) else ''
|
|
115
|
+
return sorted(found, key=lambda item: (item[1]['type'], email(item[1]), item[0]))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def roster(auths, entries=(), now=None):
|
|
119
|
+
"""Every provider account: provider, opaque index (1, 2, … per provider),
|
|
120
|
+
label, state and, while cooling down, when it clears. `entries` is the
|
|
121
|
+
management API's list of auth files. Never a token."""
|
|
122
|
+
import time
|
|
123
|
+
now = time.time() if now is None else now
|
|
124
|
+
by_name = {e.get('name'): e for e in entries if isinstance(e, dict)}
|
|
125
|
+
counters = {}
|
|
126
|
+
result = []
|
|
127
|
+
for name, credential in credentials(auths):
|
|
128
|
+
provider = credential['type']
|
|
129
|
+
counters[provider] = counters.get(provider, 0) + 1
|
|
130
|
+
state, until = classify(credential, by_name.get(name), now)
|
|
131
|
+
email = credential.get('email')
|
|
132
|
+
result.append({
|
|
133
|
+
'provider': provider,
|
|
134
|
+
'index': counters[provider],
|
|
135
|
+
'label': email if isinstance(email, str) else provider + '-' + str(counters[provider]),
|
|
136
|
+
'prefix': prefix(provider, email),
|
|
137
|
+
'state': state,
|
|
138
|
+
'until': iso(until) if until else None,
|
|
139
|
+
'file': name,
|
|
140
|
+
})
|
|
141
|
+
return result
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def version(accounts):
|
|
145
|
+
"""The roster version: a digest of the non-secret metadata of the accounts
|
|
146
|
+
every node offers (their routing prefixes). Every node computes the same
|
|
147
|
+
digest from the proxy's model list (src/accounts/roster.ts)."""
|
|
148
|
+
import hashlib
|
|
149
|
+
prefixes = sorted({a['prefix'] for a in accounts if a.get('prefix') and a.get('state') != 'removed'})
|
|
150
|
+
return hashlib.sha256('\n'.join(prefixes).encode()).hexdigest()[:16]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def public(accounts):
|
|
154
|
+
"""The roster as reported: no file name."""
|
|
155
|
+
return [{k: v for k, v in a.items() if k != 'file'} for a in accounts]
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def find(accounts, provider, index):
|
|
159
|
+
"""The one account `provider` `index` names, or None."""
|
|
160
|
+
for account in accounts:
|
|
161
|
+
if account['provider'] == provider and account['index'] == index:
|
|
162
|
+
return account
|
|
163
|
+
return None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ── Sign-in, re-login and removal ────────────────────────────────────────────
|
|
167
|
+
#
|
|
168
|
+
# A login writes into a staging directory, never into the auth directory. Only
|
|
169
|
+
# a complete credential (readable JSON with a type) is moved in, with one
|
|
170
|
+
# rename, so an interrupted login leaves no half-written auth file.
|
|
171
|
+
|
|
172
|
+
def staging(auths):
|
|
173
|
+
return pathlib.Path(auths).parent / 'auths-staging'
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def begin(auths):
|
|
177
|
+
"""A fresh, empty staging directory; leftovers of an interrupted login are
|
|
178
|
+
discarded."""
|
|
179
|
+
import shutil
|
|
180
|
+
stage = staging(auths)
|
|
181
|
+
shutil.rmtree(stage, ignore_errors=True)
|
|
182
|
+
stage.mkdir(mode=0o700, parents=True)
|
|
183
|
+
return stage
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def adopt(stage, auths, replace=None):
|
|
187
|
+
"""Moves every complete credential from `stage` into `auths` and returns
|
|
188
|
+
their file names. `replace` (a file name) is the credential a re-login
|
|
189
|
+
signs in again: it is replaced by the new one in the same rename. The
|
|
190
|
+
staging directory is removed whatever happens."""
|
|
191
|
+
import os
|
|
192
|
+
import shutil
|
|
193
|
+
stage, auths = pathlib.Path(stage), pathlib.Path(auths)
|
|
194
|
+
adopted = []
|
|
195
|
+
try:
|
|
196
|
+
complete = []
|
|
197
|
+
for path in sorted(stage.glob('*.json')):
|
|
198
|
+
try:
|
|
199
|
+
data = json.loads(path.read_text())
|
|
200
|
+
except (OSError, ValueError):
|
|
201
|
+
continue
|
|
202
|
+
if isinstance(data, dict) and isinstance(data.get('type'), str):
|
|
203
|
+
complete.append(path)
|
|
204
|
+
if replace and len(complete) != 1:
|
|
205
|
+
raise ValueError('A re-login must produce exactly one credential')
|
|
206
|
+
for path in complete:
|
|
207
|
+
target = auths / (replace or path.name)
|
|
208
|
+
os.chmod(path, 0o600)
|
|
209
|
+
os.replace(path, target)
|
|
210
|
+
adopted.append(target.name)
|
|
211
|
+
if replace and adopted and adopted[0] != replace:
|
|
212
|
+
(auths / replace).unlink(missing_ok=True)
|
|
213
|
+
return adopted
|
|
214
|
+
finally:
|
|
215
|
+
shutil.rmtree(stage, ignore_errors=True)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def remove(auths, provider, index):
|
|
219
|
+
"""Deletes the credential of one account; returns its file name."""
|
|
220
|
+
account = find(roster(auths), provider, index)
|
|
221
|
+
if account is None:
|
|
222
|
+
raise ValueError('No such account')
|
|
223
|
+
(pathlib.Path(auths) / account['file']).unlink()
|
|
224
|
+
return account['file']
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# ── Refresh tokens across checkpoints ────────────────────────────────────────
|
|
228
|
+
#
|
|
229
|
+
# Providers rotate refresh tokens. Each checkpoint records a short digest of
|
|
230
|
+
# every credential's refresh token (never the token). After a restore, a
|
|
231
|
+
# credential whose digest differs from the newest checkpoint's carries a
|
|
232
|
+
# token the provider has already replaced: that account needs a new login.
|
|
233
|
+
|
|
234
|
+
def token_digests(auths):
|
|
235
|
+
import hashlib
|
|
236
|
+
digests = {}
|
|
237
|
+
for name, credential in credentials(auths):
|
|
238
|
+
token = credential.get('refresh_token')
|
|
239
|
+
if isinstance(token, str) and token:
|
|
240
|
+
digests[name] = hashlib.sha256(token.encode()).hexdigest()[:16]
|
|
241
|
+
return digests
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def stale_after_restore(auths, newest):
|
|
245
|
+
"""[(provider, index)] of the restored accounts whose refresh token is
|
|
246
|
+
older than the one `newest` (the token digests of the newest checkpoint)
|
|
247
|
+
records."""
|
|
248
|
+
current = token_digests(auths)
|
|
249
|
+
stale = []
|
|
250
|
+
for account in roster(auths):
|
|
251
|
+
name = account['file']
|
|
252
|
+
if name in newest and name in current and current[name] != newest[name]:
|
|
253
|
+
stale.append((account['provider'], account['index']))
|
|
254
|
+
return stale
|
|
@@ -127,11 +127,39 @@ def copy_state(root, target):
|
|
|
127
127
|
shutil.copyfile(source, destination)
|
|
128
128
|
shutil.copyfile(root / "INSTALLATION.json", target / "INSTALLATION.json")
|
|
129
129
|
(target / "CHECKPOINT.json").write_text(
|
|
130
|
-
json.dumps(
|
|
130
|
+
json.dumps(
|
|
131
|
+
{
|
|
132
|
+
"version": 1,
|
|
133
|
+
"files": hashes(target),
|
|
134
|
+
"tokens": _accounts().token_digests(target / "state/runtime/auths"),
|
|
135
|
+
}
|
|
136
|
+
)
|
|
137
|
+
+ "\n"
|
|
131
138
|
)
|
|
132
139
|
validate(target)
|
|
133
140
|
|
|
134
141
|
|
|
142
|
+
def _accounts():
|
|
143
|
+
"""accounts.py beside this file, however this module was loaded."""
|
|
144
|
+
import importlib.util
|
|
145
|
+
|
|
146
|
+
spec = importlib.util.spec_from_file_location(
|
|
147
|
+
"accounts", pathlib.Path(__file__).resolve().parent / "accounts.py"
|
|
148
|
+
)
|
|
149
|
+
module = importlib.util.module_from_spec(spec)
|
|
150
|
+
spec.loader.exec_module(module)
|
|
151
|
+
return module
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def stale_accounts(restored, newest):
|
|
155
|
+
"""[(provider, index)] of the accounts in the restored checkpoint
|
|
156
|
+
`restored` whose refresh token the provider has since replaced, judged
|
|
157
|
+
against the manifest of the newest checkpoint `newest`. Restoring the
|
|
158
|
+
newest checkpoint itself finds none."""
|
|
159
|
+
tokens = json.loads((newest / "CHECKPOINT.json").read_text()).get("tokens") or {}
|
|
160
|
+
return _accounts().stale_after_restore(restored / "state/runtime/auths", tokens)
|
|
161
|
+
|
|
162
|
+
|
|
135
163
|
def capture(root, target, attempts=6, sleep=time.sleep):
|
|
136
164
|
"""Copies the state of the running stack without ever stopping it: agents
|
|
137
165
|
use the proxy around the clock, and a stopped connector fails every one of
|
package/cliproxy/scripts/cli.py
CHANGED
|
@@ -21,7 +21,7 @@ root = cliproxy_host.default_root().resolve()
|
|
|
21
21
|
args = sys.argv[1:]
|
|
22
22
|
if not args or args[0] in ["help", "--help", "-h"]:
|
|
23
23
|
print(
|
|
24
|
-
"Usage: devfleet cliproxy COMMAND\nCommands: install [--revision REV|--rollback|--dry-run|--prepare-only], audit, public-endpoint [enable|start|disable|verify], status, start, stop, restart, health, models, logs, validate, apply FILE, add-account PROVIDER, inference PROTOCOL MODEL, model-health [--profile liveness|readiness|light|nightly|manual] [--suite core|cursor|all] [--target live|mock], model-suite (same flags; defaults to nightly), backup, gc, snapshots, check, check-full, restore SNAPSHOT EMPTY_DIRECTORY, restore-cache --previous-node-stopped, vault-login, recover, recover-node NEW_DIRECTORY [--vault-token-file FILE], verify-recovery, portable-login, activate, drain"
|
|
24
|
+
"Usage: devfleet cliproxy COMMAND\nCommands: install [--revision REV|--rollback|--dry-run|--prepare-only], audit, public-endpoint [enable|start|disable|verify], status, start, stop, restart, health, models, logs, validate, apply FILE, add-account PROVIDER, relogin PROVIDER INDEX, remove-account PROVIDER INDEX, accounts [--json], client-keys, issue-key|rotate-key|retire-key|revoke-key NODE, inference PROTOCOL MODEL, model-health [--profile liveness|readiness|light|nightly|manual] [--suite core|cursor|all] [--target live|mock], model-suite (same flags; defaults to nightly), backup, gc, snapshots, check, check-full, restore SNAPSHOT EMPTY_DIRECTORY, restore-cache --previous-node-stopped, vault-login, recover, recover-node NEW_DIRECTORY [--vault-token-file FILE], verify-recovery, portable-login, activate, drain"
|
|
25
25
|
)
|
|
26
26
|
sys.exit(0)
|
|
27
27
|
command, *rest = args
|