auggy 0.3.0 → 0.3.1
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/CHANGELOG.md +35 -0
- package/README.md +16 -3
- package/package.json +1 -1
- package/src/augments/layered-memory/extractor/parse.ts +72 -14
- package/src/cli/agent-index.ts +44 -0
- package/src/cli/commands/deploy.ts +178 -0
- package/src/cli/commands/remove.ts +37 -2
- package/src/cli/deploy/bundle.ts +64 -0
- package/src/cli/deploy/dockerfile.ts +89 -0
- package/src/cli/deploy/railway-cli.ts +190 -0
- package/src/cli/deploy/secrets.ts +116 -0
- package/src/cli/index.ts +51 -5
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.3.1] - 2026-05-12
|
|
11
|
+
|
|
12
|
+
The deployable-runtime release. First npm-installable Auggy CLI with shipped feature set (0.3.0 was a name-claim publish with no functional changes). End-to-end Railway deployment support and a structural eval suite for the layered-memory autoSave path.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
#### Deployment
|
|
17
|
+
|
|
18
|
+
- **`auggy deploy <name> --to railway` command.** Ships an agent to Railway end-to-end: presence + auth checks, bundle staging with secure exclusions (`.env`, `*.db*`, `workspace/`, `node_modules/`, `.git/`, `.worktrees/`, `.claude/`, `.DS_Store`, `*.tmp`), Dockerfile + entrypoint generation, `railway link`, `railway volume add` (one-time, mounted at `/app/data`), `railway domain --generate`, secrets push including `AUGGY_PUBLIC_URL`, then `railway up`. Redeploys reuse the existing `CloudRecord` from `~/.auggy/agents.json` for idempotency. (See `docs/18-deploy.md`, ADR-021.)
|
|
19
|
+
- **`auggy remove <name> --cloud` flag.** Destroys the Railway service alongside the local index entry. Tolerates Railway destruction failures with a warning — local cleanup proceeds regardless.
|
|
20
|
+
- **`agent-index` cloud mutators.** `setCloud(name, record)` + `clearCloud(name)` with the same atomic-write + advisory-lock discipline as `addAgent` / `removeAgent`. Cloud state persists in `~/.auggy/agents.json` per the existing `CloudRecord` type.
|
|
21
|
+
- **Operator deployment guide** (`docs/18-deploy.md`) — prerequisites, first-deploy + redeploy flows, autoSave cost surface guidance (citing the new eval suite), persistent state contract, visitorAuth's `${AUGGY_PUBLIC_URL}` interpolation, tear-down, troubleshooting.
|
|
22
|
+
- **npm publish workflow** (`.github/workflows/publish.yml`) — publishes `auggy` to npm on `v*.*.*` tag push. Runs tests + typecheck + version-matches-tag check before `npm publish --provenance --access public`. Uses `NPM_TOKEN` secret.
|
|
23
|
+
|
|
24
|
+
#### Quality
|
|
25
|
+
|
|
26
|
+
- **`evals/layered-memory/` integration eval suite.** Seven fixtures × seven structural graders measure end-to-end autoSave behavior under real `agent.inject()` machinery: `factual-recall`, `peer-isolation`, `prompt-rendering`, `cost-overhead`, `false-extract`, `cross-session-recall` (multi-session persistence headliner), and `cross-identity-promotion` (anon → recognized flush). Mock-mode runner is deterministic, no API key required, <100ms. Live Haiku smoke (`evals/layered-memory/smoke.ts`) validates end-to-end against a real model with seven pass criteria at ~$0.005 spend. (See `evals/layered-memory/README.md`.)
|
|
27
|
+
- **`extractJsonArray` JSON extractor.** Replaces the strict `JSON.parse` in `src/augments/layered-memory/extractor/parse.ts` with balanced-bracket extraction — structurally robust to any model wrapper style (markdown fences, leading/trailing prose, language tags, single-line layout, CRLF, escaped quotes, nested objects). Closed the 100% extraction-failure rate on Haiku 4.5 caught by the smoke test.
|
|
28
|
+
|
|
29
|
+
### Changed
|
|
30
|
+
|
|
31
|
+
- **`auggy --version`** now reads from `package.json` instead of a hardcoded string. Eliminates the drift class that surfaced after the first npm publish.
|
|
32
|
+
|
|
33
|
+
### Process
|
|
34
|
+
|
|
35
|
+
- **First npm publish.** `auggy` is now available via `npm i -g auggy`. Distribution pattern matches Wrangler / Vercel: install → create → dev/start/deploy.
|
|
36
|
+
|
|
37
|
+
## [0.3.0] - 2026-05-12
|
|
38
|
+
|
|
39
|
+
Name-claim release. Same code as `c15d3cb` + `@auggy/link@0.1.2` bump. Published manually to claim the unscoped `auggy` package name on npm; no functional changes vs. the prior `0.2.0` release.
|
|
40
|
+
|
|
41
|
+
## [0.2.0-pre] (pre-OSS items, now folded into 0.4.0)
|
|
42
|
+
|
|
43
|
+
Items below shipped during the pre-OSS phase and are functionally part of 0.4.0 in the OSS distribution. Kept here for historical reference:
|
|
44
|
+
|
|
10
45
|
### Architecture
|
|
11
46
|
|
|
12
47
|
- **ADR-030 — model-facing skill surface separation.** The three Auggy primitives now surface to the engine on three orthogonal channels: **tools** (eager full schema in `tools[]`), **skills** (new built-in `skills` augment emits one system-placement context block sourced from each SKILL.md's YAML frontmatter, body on-demand via `fs_read`), and **augments** (invisible to the model). `{SKILL_MANIFEST}` is gone from `src/scaffold-templates/identity.md`; `scaffold-skills.ts` shed `buildSkillManifest` + `TOOL_INVENTORY`; `src/cli/skill-manifest.ts` is deleted; the kernel context allocator no longer wraps blocks with `[AUGMENT CONTEXT: <source>]` (the augment-name attribution is suppressed pre-send, preserved only in operator-facing trace data). The 8 bundled SKILL.md files already shipped agentskills.io-compatible frontmatter. `auggy create` default-mounts the new `skills` augment.
|
package/README.md
CHANGED
|
@@ -20,8 +20,8 @@ Auggy (augment-1) is a modular agent runtime in TypeScript/Bun, purpose-built fo
|
|
|
20
20
|
## Quick start
|
|
21
21
|
|
|
22
22
|
```bash
|
|
23
|
-
# Install
|
|
24
|
-
|
|
23
|
+
# Install the CLI globally (requires Bun: https://bun.sh/install)
|
|
24
|
+
npm i -g auggy
|
|
25
25
|
|
|
26
26
|
# Create an agent (interactive augment selection)
|
|
27
27
|
auggy create zip
|
|
@@ -30,10 +30,23 @@ auggy create zip
|
|
|
30
30
|
cp zip/.env.example zip/.env
|
|
31
31
|
# Add your API key to zip/.env
|
|
32
32
|
|
|
33
|
-
# Run
|
|
33
|
+
# Run locally (foreground)
|
|
34
34
|
auggy dev zip
|
|
35
|
+
|
|
36
|
+
# Or install as a launchd service (macOS, always-on)
|
|
37
|
+
auggy start zip
|
|
38
|
+
|
|
39
|
+
# Or deploy to the cloud (requires Railway CLI + `railway login`)
|
|
40
|
+
auggy deploy zip --to railway
|
|
35
41
|
```
|
|
36
42
|
|
|
43
|
+
The `auggy` binary requires [Bun](https://bun.sh) at runtime. The package
|
|
44
|
+
ships TypeScript sources; Bun executes them directly without a build step.
|
|
45
|
+
|
|
46
|
+
> Until the package is on npm, use the development install:
|
|
47
|
+
> `git clone …augment-1 && cd augment-1 && bun install && bun link`.
|
|
48
|
+
> The `auggy` binary lands on PATH the same way.
|
|
49
|
+
|
|
37
50
|
## How it works
|
|
38
51
|
|
|
39
52
|
**Engines** drive the model call (one per agent). **Augments** plug in around it — context, tools, transport, memory (many per agent). Both are swappable via YAML.
|
package/package.json
CHANGED
|
@@ -17,25 +17,83 @@ export type ParseResult =
|
|
|
17
17
|
| { success: false; error: string };
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
|
-
* Defensive JSON parser for the extraction LLM's response. The model
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* type mismatches). This parser:
|
|
20
|
+
* Defensive JSON parser for the extraction LLM's response. The model should
|
|
21
|
+
* emit a top-level JSON array of fact objects per `prompt.md`, but real
|
|
22
|
+
* models drift (markdown code fences, leading/trailing prose, language tags,
|
|
23
|
+
* extra fields, type mismatches). This parser:
|
|
24
24
|
*
|
|
25
|
-
* -
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* -
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
25
|
+
* - Locates the JSON array via balanced-bracket extraction so wrappers
|
|
26
|
+
* (Haiku's ```json fences, Sonnet's leading prose, etc) are tolerated
|
|
27
|
+
* structurally — see `extractJsonArray` for the why.
|
|
28
|
+
* - Returns `{ success: false, error }` on any failure mode rather than
|
|
29
|
+
* throwing, so the auto-save handler can log and skip without killing
|
|
30
|
+
* the injected turn's tool-call execution.
|
|
31
|
+
* - Validates each entry's shape strictly: all five required fields must
|
|
32
|
+
* be present and the right primitive type. One bad entry fails the
|
|
33
|
+
* whole batch — partial writes would leave inconsistent storage and the
|
|
34
|
+
* cost of a re-extraction is bounded.
|
|
35
|
+
* - Strips unknown keys to keep the storage schema clean across prompt-
|
|
36
|
+
* template revisions.
|
|
34
37
|
*/
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Locate and return the first top-level JSON array in the response, ignoring
|
|
41
|
+
* any wrapping (markdown code fences, leading prose, trailing prose, language
|
|
42
|
+
* tags, single-line vs multi-line layout, etc).
|
|
43
|
+
*
|
|
44
|
+
* Walks the raw string looking for the first `[`, then advances depth-tracking
|
|
45
|
+
* through nested arrays and objects, respecting JSON string boundaries and
|
|
46
|
+
* backslash escapes. Returns the substring `[..matching..]` or null when no
|
|
47
|
+
* balanced array is found.
|
|
48
|
+
*
|
|
49
|
+
* Why this approach: models wrap JSON output in different ways depending on
|
|
50
|
+
* the model, the prompt phrasing, and the moon's phase. Haiku 4.5 emits
|
|
51
|
+
* ```json\n[...]\n```; Sonnet sometimes leads with prose ("Here's the JSON:");
|
|
52
|
+
* Gemini occasionally appends "Hope this helps!". A regex tuned to one style
|
|
53
|
+
* breaks on the next variant. Balanced-bracket extraction is structurally
|
|
54
|
+
* robust — any wrapping the model adds is just text outside the array.
|
|
55
|
+
*/
|
|
56
|
+
function extractJsonArray(raw: string): string | null {
|
|
57
|
+
const start = raw.indexOf("[");
|
|
58
|
+
if (start === -1) return null;
|
|
59
|
+
|
|
60
|
+
let depth = 0;
|
|
61
|
+
let inString = false;
|
|
62
|
+
let escaped = false;
|
|
63
|
+
|
|
64
|
+
for (let i = start; i < raw.length; i++) {
|
|
65
|
+
const c = raw[i];
|
|
66
|
+
if (escaped) {
|
|
67
|
+
escaped = false;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (inString) {
|
|
71
|
+
if (c === "\\") escaped = true;
|
|
72
|
+
else if (c === '"') inString = false;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (c === '"') {
|
|
76
|
+
inString = true;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (c === "[") depth++;
|
|
80
|
+
else if (c === "]") {
|
|
81
|
+
depth--;
|
|
82
|
+
if (depth === 0) return raw.slice(start, i + 1);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
35
89
|
export function parseExtractionResponse(raw: string): ParseResult {
|
|
90
|
+
const jsonText = extractJsonArray(raw);
|
|
91
|
+
if (jsonText === null) {
|
|
92
|
+
return { success: false, error: "no balanced JSON array found in response" };
|
|
93
|
+
}
|
|
36
94
|
let parsed: unknown;
|
|
37
95
|
try {
|
|
38
|
-
parsed = JSON.parse(
|
|
96
|
+
parsed = JSON.parse(jsonText);
|
|
39
97
|
} catch (err) {
|
|
40
98
|
return { success: false, error: `failed to parse JSON: ${(err as Error).message}` };
|
|
41
99
|
}
|
package/src/cli/agent-index.ts
CHANGED
|
@@ -287,3 +287,47 @@ export function listAgents(opts: IndexOptions = {}): Array<IndexEntry & { name:
|
|
|
287
287
|
...entry,
|
|
288
288
|
}));
|
|
289
289
|
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Attach a cloud deployment record to a registered agent. Throws when the
|
|
293
|
+
* agent isn't registered — first call `addAgent`, then `setCloud`. Overwrites
|
|
294
|
+
* any prior cloud record (redeploy case).
|
|
295
|
+
*
|
|
296
|
+
* Holds the advisory lock for the read-modify-write window.
|
|
297
|
+
*/
|
|
298
|
+
export function setCloud(
|
|
299
|
+
name: string,
|
|
300
|
+
record: NonNullable<import("./types").CloudRecord>,
|
|
301
|
+
opts: IndexOptions = {},
|
|
302
|
+
): void {
|
|
303
|
+
const lock = acquireLock(opts);
|
|
304
|
+
try {
|
|
305
|
+
const idx = readIndex(opts);
|
|
306
|
+
const entry = idx.agents[name];
|
|
307
|
+
if (!entry) {
|
|
308
|
+
throw new Error(`Agent "${name}" not registered — run \`auggy create ${name}\` first.`);
|
|
309
|
+
}
|
|
310
|
+
entry.cloud = record;
|
|
311
|
+
writeIndex(idx, opts);
|
|
312
|
+
} finally {
|
|
313
|
+
lock.release();
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Clear an agent's cloud deployment record. Idempotent — no-op when already
|
|
319
|
+
* null. Does not throw when the agent isn't registered (matches removeAgent
|
|
320
|
+
* idempotency).
|
|
321
|
+
*/
|
|
322
|
+
export function clearCloud(name: string, opts: IndexOptions = {}): void {
|
|
323
|
+
const lock = acquireLock(opts);
|
|
324
|
+
try {
|
|
325
|
+
const idx = readIndex(opts);
|
|
326
|
+
const entry = idx.agents[name];
|
|
327
|
+
if (!entry) return;
|
|
328
|
+
entry.cloud = null;
|
|
329
|
+
writeIndex(idx, opts);
|
|
330
|
+
} finally {
|
|
331
|
+
lock.release();
|
|
332
|
+
}
|
|
333
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `auggy deploy <name> --to railway` command.
|
|
3
|
+
*
|
|
4
|
+
* Orchestrates the first-deploy + redeploy flows:
|
|
5
|
+
*
|
|
6
|
+
* first-deploy: presence + auth checks → operator prompt for projectId →
|
|
7
|
+
* stage bundle → write Dockerfile + entrypoint → link → addVolume →
|
|
8
|
+
* generateDomain → push secrets (.env + AUGGY_PUBLIC_URL) → up →
|
|
9
|
+
* capture status → write CloudRecord to index.
|
|
10
|
+
*
|
|
11
|
+
* redeploy: presence + auth checks → stage bundle → write Dockerfile +
|
|
12
|
+
* entrypoint → link (idempotent) → re-push secrets → up → status →
|
|
13
|
+
* update CloudRecord.deployedAt.
|
|
14
|
+
*
|
|
15
|
+
* Cleanly testable via a dependency-injected `RailwayCli` (real or mocked)
|
|
16
|
+
* plus pluggable prompt + logger helpers — no I/O hardcoded into the orchestrator.
|
|
17
|
+
*
|
|
18
|
+
* Per D7 (architecture deltas in the v1.0 plan): the URL is captured BEFORE
|
|
19
|
+
* `up` runs so AUGGY_PUBLIC_URL is set as a Railway env var, ensuring
|
|
20
|
+
* visitorAuth sees the publicUrl on first boot. This avoids a first-boot
|
|
21
|
+
* crash when agent.yaml interpolates `${AUGGY_PUBLIC_URL}` or similar.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { writeFileSync } from "node:fs";
|
|
25
|
+
import { join } from "node:path";
|
|
26
|
+
import { getAgent, setCloud } from "../agent-index";
|
|
27
|
+
import { stageBundle } from "../deploy/bundle";
|
|
28
|
+
import { generateDockerfile, generateEntrypoint } from "../deploy/dockerfile";
|
|
29
|
+
import type { RailwayCli } from "../deploy/railway-cli";
|
|
30
|
+
import { loadSecretsPlan } from "../deploy/secrets";
|
|
31
|
+
|
|
32
|
+
export interface DeployLogger {
|
|
33
|
+
info(msg: string): void;
|
|
34
|
+
warn(msg: string): void;
|
|
35
|
+
error(msg: string): void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface DeployOptions {
|
|
39
|
+
to: "railway";
|
|
40
|
+
yes: boolean;
|
|
41
|
+
auggyDir?: string;
|
|
42
|
+
cli: RailwayCli;
|
|
43
|
+
/** Prompt the operator for a Railway project ID. */
|
|
44
|
+
promptProjectId: () => Promise<string>;
|
|
45
|
+
/** Prompt the operator for yes/no confirmation. Receives a human-readable message. */
|
|
46
|
+
promptConfirm: (message: string) => Promise<boolean>;
|
|
47
|
+
logger: DeployLogger;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface DeployResult {
|
|
51
|
+
url: string;
|
|
52
|
+
projectId: string;
|
|
53
|
+
serviceId: string;
|
|
54
|
+
volumeId: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const VOLUME_MOUNT_PATH = "/app/data";
|
|
58
|
+
|
|
59
|
+
export async function runDeploy(name: string, opts: DeployOptions): Promise<DeployResult> {
|
|
60
|
+
if (opts.to !== "railway") {
|
|
61
|
+
throw new Error(
|
|
62
|
+
`Only "railway" is supported in v1.0 (got "${opts.to}"). Other targets: deferred.`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const entry = getAgent(name, { auggyDir: opts.auggyDir });
|
|
67
|
+
if (!entry) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
`Agent "${name}" not registered. Run \`auggy create ${name}\` first, then \`auggy deploy ${name}\`.`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
const agentDir = entry.localDir;
|
|
73
|
+
|
|
74
|
+
// 1) Presence + auth checks (fail fast before any subprocess work).
|
|
75
|
+
await opts.cli.checkPresence();
|
|
76
|
+
await opts.cli.checkAuth();
|
|
77
|
+
opts.logger.info(`Railway CLI ready.`);
|
|
78
|
+
|
|
79
|
+
// 2) Determine first-deploy vs redeploy from existing CloudRecord.
|
|
80
|
+
const existingCloud = entry.cloud;
|
|
81
|
+
const isRedeploy = existingCloud !== null;
|
|
82
|
+
|
|
83
|
+
let projectId: string;
|
|
84
|
+
if (isRedeploy && existingCloud) {
|
|
85
|
+
projectId = existingCloud.projectId;
|
|
86
|
+
opts.logger.info(`Redeploying ${name} to Railway project ${projectId}.`);
|
|
87
|
+
} else {
|
|
88
|
+
projectId = await opts.promptProjectId();
|
|
89
|
+
opts.logger.info(`First deploy of ${name} to Railway project ${projectId}.`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 3) Stage the bundle (excludes secrets + volume-bound state).
|
|
93
|
+
const stagingDir = stageBundle({ agentDir, agentName: name });
|
|
94
|
+
opts.logger.info(`Bundle staged at ${stagingDir}.`);
|
|
95
|
+
|
|
96
|
+
// 4) Write Dockerfile + entrypoint into the staging dir.
|
|
97
|
+
writeFileSync(join(stagingDir, "Dockerfile"), generateDockerfile({ agentName: name }));
|
|
98
|
+
writeFileSync(join(stagingDir, "auggy-entrypoint.sh"), generateEntrypoint());
|
|
99
|
+
|
|
100
|
+
// 5) Load secrets plan and confirm with operator unless --yes.
|
|
101
|
+
const envPath = join(agentDir, ".env");
|
|
102
|
+
const plan = loadSecretsPlan(envPath);
|
|
103
|
+
if (plan.warnings.length > 0) {
|
|
104
|
+
for (const w of plan.warnings) opts.logger.warn(w);
|
|
105
|
+
}
|
|
106
|
+
if (!opts.yes) {
|
|
107
|
+
const summary =
|
|
108
|
+
plan.variables.length === 0
|
|
109
|
+
? `No secrets to push (no .env file or all entries malformed).`
|
|
110
|
+
: `Push ${plan.variables.length} secret(s) to Railway:\n` +
|
|
111
|
+
plan.variables.map((v) => ` ${v.key} = ${v.redactedValue}`).join("\n");
|
|
112
|
+
const confirmed = await opts.promptConfirm(`${summary}\n\nProceed?`);
|
|
113
|
+
if (!confirmed) {
|
|
114
|
+
throw new Error("Deploy aborted by operator (declined secrets push).");
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// 6) Link the staging dir to the Railway service. First deploy: Railway
|
|
119
|
+
// auto-creates the service if --service name doesn't exist in the
|
|
120
|
+
// project. Redeploy: idempotent — re-links the same service.
|
|
121
|
+
await opts.cli.link({ projectId, serviceName: name, cwd: stagingDir });
|
|
122
|
+
opts.logger.info(`Linked staging dir to project ${projectId}, service ${name}.`);
|
|
123
|
+
|
|
124
|
+
// 7) Volume: only add on first deploy. Redeploys keep the existing volume
|
|
125
|
+
// (Railway preserves it via the volumeId in the existing CloudRecord).
|
|
126
|
+
if (!isRedeploy) {
|
|
127
|
+
await opts.cli.addVolume({
|
|
128
|
+
name: `${name}-data`,
|
|
129
|
+
mountPath: VOLUME_MOUNT_PATH,
|
|
130
|
+
cwd: stagingDir,
|
|
131
|
+
});
|
|
132
|
+
opts.logger.info(`Volume "${name}-data" mounted at ${VOLUME_MOUNT_PATH}.`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// 8) Generate (or recover) the public domain. Idempotent: second call
|
|
136
|
+
// returns the existing URL.
|
|
137
|
+
const url = await opts.cli.generateDomain({ cwd: stagingDir });
|
|
138
|
+
opts.logger.info(`Public URL: ${url}`);
|
|
139
|
+
|
|
140
|
+
// 9) Push secrets (.env keys + AUGGY_PUBLIC_URL). D7: AUGGY_PUBLIC_URL must
|
|
141
|
+
// be set BEFORE `up` so visitorAuth sees the publicUrl on first boot.
|
|
142
|
+
for (const v of plan.variables) {
|
|
143
|
+
await opts.cli.setVariable({ key: v.key, value: v.value, cwd: stagingDir });
|
|
144
|
+
}
|
|
145
|
+
await opts.cli.setVariable({ key: "AUGGY_PUBLIC_URL", value: url, cwd: stagingDir });
|
|
146
|
+
opts.logger.info(`Pushed ${plan.variables.length + 1} env var(s) to Railway.`);
|
|
147
|
+
|
|
148
|
+
// 10) Trigger the build + deploy. --detach so we return without tailing
|
|
149
|
+
// build logs; operator follows progress via Railway UI / `railway logs`.
|
|
150
|
+
await opts.cli.up({ cwd: stagingDir });
|
|
151
|
+
opts.logger.info(`Build queued.`);
|
|
152
|
+
|
|
153
|
+
// 11) Capture service metadata for the CloudRecord.
|
|
154
|
+
const status = await opts.cli.status({ cwd: stagingDir });
|
|
155
|
+
opts.logger.info(`Service status: ${status.deployment.status}.`);
|
|
156
|
+
|
|
157
|
+
// 12) Write CloudRecord to the agent index.
|
|
158
|
+
const result: DeployResult = {
|
|
159
|
+
url,
|
|
160
|
+
projectId,
|
|
161
|
+
serviceId: status.service.id,
|
|
162
|
+
volumeId: `${name}-data`,
|
|
163
|
+
};
|
|
164
|
+
setCloud(
|
|
165
|
+
name,
|
|
166
|
+
{
|
|
167
|
+
provider: "railway",
|
|
168
|
+
projectId: result.projectId,
|
|
169
|
+
serviceId: result.serviceId,
|
|
170
|
+
url: result.url,
|
|
171
|
+
volumeId: result.volumeId,
|
|
172
|
+
deployedAt: new Date().toISOString(),
|
|
173
|
+
},
|
|
174
|
+
{ auggyDir: opts.auggyDir },
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
return result;
|
|
178
|
+
}
|
|
@@ -5,10 +5,12 @@
|
|
|
5
5
|
* --yes). Tolerates missing localDir (still cleans the index entry).
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { existsSync, readFileSync, rmSync } from "node:fs";
|
|
8
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
9
10
|
import { join } from "node:path";
|
|
10
11
|
import { confirm } from "@inquirer/prompts";
|
|
11
|
-
import { getAgent, removeAgent } from "../agent-index";
|
|
12
|
+
import { clearCloud, getAgent, removeAgent } from "../agent-index";
|
|
13
|
+
import { createRailwayCli, type RailwayCli } from "../deploy/railway-cli";
|
|
12
14
|
import { readPidManifest, isProcessAlive, removePidManifest } from "../pid-registry";
|
|
13
15
|
|
|
14
16
|
function readConfigName(localDir: string): string | null {
|
|
@@ -26,8 +28,12 @@ function readConfigName(localDir: string): string | null {
|
|
|
26
28
|
interface RemoveOptions {
|
|
27
29
|
/** Skip the y/N prompt. */
|
|
28
30
|
yes?: boolean;
|
|
31
|
+
/** When set with a cloud-deployed agent, also destroy the Railway service. */
|
|
32
|
+
cloud?: boolean;
|
|
29
33
|
/** Override `~/.auggy/` for tests. */
|
|
30
34
|
auggyDir?: string;
|
|
35
|
+
/** Inject a RailwayCli for tests (defaults to the real one). */
|
|
36
|
+
railwayCli?: RailwayCli;
|
|
31
37
|
}
|
|
32
38
|
|
|
33
39
|
export async function runRemove(name: string, opts: RemoveOptions = {}): Promise<void> {
|
|
@@ -88,6 +94,35 @@ export async function runRemove(name: string, opts: RemoveOptions = {}): Promise
|
|
|
88
94
|
if (pidByCli) removePidManifest(name);
|
|
89
95
|
if (pidByConfig && configName) removePidManifest(configName);
|
|
90
96
|
|
|
97
|
+
// --cloud: also destroy the Railway service before clearing the index.
|
|
98
|
+
// We do this AFTER local cleanup so a failed Railway call doesn't leave
|
|
99
|
+
// local state in an inconsistent half-deleted shape.
|
|
100
|
+
if (opts.cloud && entry.cloud) {
|
|
101
|
+
const cli = opts.railwayCli ?? createRailwayCli();
|
|
102
|
+
// `railway service delete` needs to be run from a dir linked to the
|
|
103
|
+
// service. Create a temp dir, link it, then delete.
|
|
104
|
+
const tmp = mkdtempSync(join(tmpdir(), `auggy-remove-${name}-`));
|
|
105
|
+
try {
|
|
106
|
+
await cli.link({
|
|
107
|
+
projectId: entry.cloud.projectId,
|
|
108
|
+
serviceName: name,
|
|
109
|
+
cwd: tmp,
|
|
110
|
+
});
|
|
111
|
+
await cli.destroyService({ cwd: tmp });
|
|
112
|
+
console.log(`Destroyed Railway service "${name}" (project ${entry.cloud.projectId}).`);
|
|
113
|
+
} catch (err) {
|
|
114
|
+
console.warn(
|
|
115
|
+
`warn: Railway service destruction failed: ${(err as Error).message}\n` +
|
|
116
|
+
` Local cleanup proceeding; remove the Railway service manually if needed.`,
|
|
117
|
+
);
|
|
118
|
+
} finally {
|
|
119
|
+
try {
|
|
120
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
121
|
+
} catch {}
|
|
122
|
+
}
|
|
123
|
+
clearCloud(name, { auggyDir: opts.auggyDir });
|
|
124
|
+
}
|
|
125
|
+
|
|
91
126
|
// Clear index entry.
|
|
92
127
|
removeAgent(name, { auggyDir: opts.auggyDir });
|
|
93
128
|
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bundle staging — copies an agent dir into a fresh temp directory minus the
|
|
3
|
+
* exclusions defined by ADR-021 (`.env`, `workspace/`, `*.db*`, `node_modules/`,
|
|
4
|
+
* `.git/`, `.DS_Store`, `*.tmp`) plus this PR's additions (`.worktrees/`,
|
|
5
|
+
* `.claude/`).
|
|
6
|
+
*
|
|
7
|
+
* The deploy command runs `railway up` from the staging dir so volume-bound
|
|
8
|
+
* state (SQLite files) and secrets (`.env`) never enter the Railway image.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, statSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
|
|
15
|
+
interface StageBundleOptions {
|
|
16
|
+
agentDir: string;
|
|
17
|
+
agentName: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const EXCLUDED_NAMES = new Set([
|
|
21
|
+
".env",
|
|
22
|
+
".git",
|
|
23
|
+
".DS_Store",
|
|
24
|
+
"node_modules",
|
|
25
|
+
"workspace",
|
|
26
|
+
".worktrees",
|
|
27
|
+
".claude",
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
function isExcluded(name: string): boolean {
|
|
31
|
+
if (EXCLUDED_NAMES.has(name)) return true;
|
|
32
|
+
if (/\.db(-(?:wal|shm))?$/.test(name)) return true;
|
|
33
|
+
if (name.endsWith(".tmp")) return true;
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function copyTree(src: string, dst: string): void {
|
|
38
|
+
mkdirSync(dst, { recursive: true });
|
|
39
|
+
for (const entry of readdirSync(src)) {
|
|
40
|
+
if (isExcluded(entry)) continue;
|
|
41
|
+
const srcPath = join(src, entry);
|
|
42
|
+
const dstPath = join(dst, entry);
|
|
43
|
+
const stats = statSync(srcPath);
|
|
44
|
+
if (stats.isDirectory()) {
|
|
45
|
+
copyTree(srcPath, dstPath);
|
|
46
|
+
} else if (stats.isFile()) {
|
|
47
|
+
copyFileSync(srcPath, dstPath);
|
|
48
|
+
}
|
|
49
|
+
// Skip symlinks/sockets/etc — agent dirs shouldn't have them.
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Copy the agent dir into a fresh temp staging dir, minus exclusions.
|
|
55
|
+
* Returns the absolute path to the staging dir.
|
|
56
|
+
*/
|
|
57
|
+
export function stageBundle(opts: StageBundleOptions): string {
|
|
58
|
+
if (!existsSync(opts.agentDir)) {
|
|
59
|
+
throw new Error(`Agent directory not found: ${opts.agentDir}`);
|
|
60
|
+
}
|
|
61
|
+
const staging = mkdtempSync(join(tmpdir(), `auggy-deploy-${opts.agentName}-`));
|
|
62
|
+
copyTree(opts.agentDir, staging);
|
|
63
|
+
return staging;
|
|
64
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dockerfile + entrypoint generator for `auggy deploy`.
|
|
3
|
+
*
|
|
4
|
+
* The deploy command writes these strings into the staging dir before
|
|
5
|
+
* `railway up`. ADR-021 cloud design: bake-in the volume symlink dance +
|
|
6
|
+
* `auggy dev --internal-mode railway` invocation so `agent.yaml`'s
|
|
7
|
+
* `dbPath: ./<name>.db` paths work unchanged in cloud.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const BUN_VERSION = "1.1-alpine";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Known SQLite paths in the agent dir that need to live on the Railway
|
|
14
|
+
* volume across redeploys. Each is symlinked in the entrypoint script:
|
|
15
|
+
*
|
|
16
|
+
* /app/<name>.db → /app/data/<name>.db (volume target)
|
|
17
|
+
*
|
|
18
|
+
* The .db files do NOT exist at boot — they're created by SQLite on first
|
|
19
|
+
* attach, following the symlink. WAL/SHM sibling files are created
|
|
20
|
+
* alongside the resolved symlink target (i.e. on the volume).
|
|
21
|
+
*
|
|
22
|
+
* Drift risk (per plan §D2): if a new SQLite-backed augment ships with a
|
|
23
|
+
* non-listed path, its DB falls outside the volume and gets lost on
|
|
24
|
+
* redeploy. Update this list when new augments land. Today's eval suite
|
|
25
|
+
* catches data loss empirically via the cross-session-recall grader.
|
|
26
|
+
*/
|
|
27
|
+
const SQLITE_DB_NAMES = ["memory.db", "budgets.db", "visitor-auth.db", "link.db"];
|
|
28
|
+
|
|
29
|
+
interface DockerfileOptions {
|
|
30
|
+
agentName: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function generateDockerfile(opts: DockerfileOptions): string {
|
|
34
|
+
return `FROM oven/bun:${BUN_VERSION}
|
|
35
|
+
|
|
36
|
+
WORKDIR /app
|
|
37
|
+
|
|
38
|
+
# Install auggy globally so the entrypoint can call \`auggy dev\`.
|
|
39
|
+
# (Phase 3 of the deploy plan: package will be published as @auggy/cli;
|
|
40
|
+
# update this to "@auggy/cli" once published. Until then, build images
|
|
41
|
+
# from a workspace clone.)
|
|
42
|
+
RUN bun install -g auggy
|
|
43
|
+
|
|
44
|
+
COPY . /app
|
|
45
|
+
|
|
46
|
+
# Make the entrypoint executable.
|
|
47
|
+
RUN chmod +x /app/auggy-entrypoint.sh
|
|
48
|
+
|
|
49
|
+
# Railway mounts the persistent volume here.
|
|
50
|
+
VOLUME ["/app/data"]
|
|
51
|
+
|
|
52
|
+
# Railway routes traffic to the port declared by the app via PORT env.
|
|
53
|
+
EXPOSE 8080
|
|
54
|
+
|
|
55
|
+
ENTRYPOINT ["/app/auggy-entrypoint.sh", "${opts.agentName}"]
|
|
56
|
+
`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function generateEntrypoint(): string {
|
|
60
|
+
const symlinks = SQLITE_DB_NAMES.map((name) => `ln -sf /app/data/${name} /app/${name}`).join(
|
|
61
|
+
"\n",
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
return `#!/bin/sh
|
|
65
|
+
# Auggy Railway entrypoint.
|
|
66
|
+
#
|
|
67
|
+
# - The Railway volume is mounted at /app/data (persists across redeploys).
|
|
68
|
+
# - SQLite-backed augments (layeredMemory, budgets, visitorAuth, link) write
|
|
69
|
+
# to ./<name>.db; we symlink each name into the volume so paths in
|
|
70
|
+
# agent.yaml stay unchanged between local and cloud.
|
|
71
|
+
# - WAL/SHM siblings are created alongside the resolved symlink target by
|
|
72
|
+
# SQLite, i.e. they land on the volume.
|
|
73
|
+
# - -f overwrites any prior symlink so redeploys don't fail on the second run.
|
|
74
|
+
# - The .db files don't need to exist at boot — SQLite creates them on first
|
|
75
|
+
# attach, following the symlink to the volume.
|
|
76
|
+
#
|
|
77
|
+
# Drift risk: when a new SQLite-backed augment ships, add its .db name to the
|
|
78
|
+
# SQLITE_DB_NAMES list in src/cli/deploy/dockerfile.ts.
|
|
79
|
+
|
|
80
|
+
set -e
|
|
81
|
+
|
|
82
|
+
mkdir -p /app/data
|
|
83
|
+
|
|
84
|
+
${symlinks}
|
|
85
|
+
|
|
86
|
+
# $1 is the agent name passed by ENTRYPOINT.
|
|
87
|
+
exec auggy dev "$1" --internal-mode railway
|
|
88
|
+
`;
|
|
89
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Railway CLI subprocess wrapper.
|
|
3
|
+
*
|
|
4
|
+
* Sole owner of `railway` binary knowledge in the codebase. Every Railway
|
|
5
|
+
* operation calls `runRailway(args, opts)` which uses the injected spawn
|
|
6
|
+
* factory (defaults to `Bun.spawn`). Tests override the factory to mock
|
|
7
|
+
* subprocess behavior without spawning anything.
|
|
8
|
+
*
|
|
9
|
+
* Operator pre-requisites: `railway` CLI installed (https://docs.railway.com/develop/cli)
|
|
10
|
+
* and `railway login` completed. We trust the operator's auth context — same
|
|
11
|
+
* pattern as `git push` trusts `git`. No token storage in this codebase.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export class RailwayCliMissingError extends Error {
|
|
15
|
+
constructor() {
|
|
16
|
+
super(
|
|
17
|
+
"Railway CLI not found. Install it: https://docs.railway.com/develop/cli, then `railway login`.",
|
|
18
|
+
);
|
|
19
|
+
this.name = "RailwayCliMissingError";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class RailwayNotLoggedInError extends Error {
|
|
24
|
+
constructor(detail: string) {
|
|
25
|
+
super(`Railway CLI not authenticated: ${detail}\nRun \`railway login\` and try again.`);
|
|
26
|
+
this.name = "RailwayNotLoggedInError";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface SpawnHandle {
|
|
31
|
+
exited: Promise<number>;
|
|
32
|
+
stdout: ReadableStream<Uint8Array>;
|
|
33
|
+
stderr: ReadableStream<Uint8Array>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type RailwaySpawnFactory = (
|
|
37
|
+
cmd: string[],
|
|
38
|
+
opts?: { cwd?: string; env?: Record<string, string> },
|
|
39
|
+
) => SpawnHandle;
|
|
40
|
+
|
|
41
|
+
interface CreateRailwayCliOptions {
|
|
42
|
+
spawn?: RailwaySpawnFactory;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface RunOptions {
|
|
46
|
+
cwd?: string;
|
|
47
|
+
env?: Record<string, string>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface RailwayStatus {
|
|
51
|
+
project: { id: string; name: string };
|
|
52
|
+
service: { id: string; name: string };
|
|
53
|
+
deployment: { status: string };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function readAll(stream: ReadableStream<Uint8Array>): Promise<string> {
|
|
57
|
+
const reader = stream.getReader();
|
|
58
|
+
const chunks: Uint8Array[] = [];
|
|
59
|
+
while (true) {
|
|
60
|
+
const { done, value } = await reader.read();
|
|
61
|
+
if (done) break;
|
|
62
|
+
if (value) chunks.push(value);
|
|
63
|
+
}
|
|
64
|
+
const total = chunks.reduce((n, c) => n + c.byteLength, 0);
|
|
65
|
+
const merged = new Uint8Array(total);
|
|
66
|
+
let offset = 0;
|
|
67
|
+
for (const c of chunks) {
|
|
68
|
+
merged.set(c, offset);
|
|
69
|
+
offset += c.byteLength;
|
|
70
|
+
}
|
|
71
|
+
return new TextDecoder().decode(merged);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const defaultSpawn: RailwaySpawnFactory = (cmd, opts = {}) => {
|
|
75
|
+
const proc = Bun.spawn(cmd, {
|
|
76
|
+
cwd: opts.cwd,
|
|
77
|
+
env: opts.env ? { ...process.env, ...opts.env } : undefined,
|
|
78
|
+
stdout: "pipe",
|
|
79
|
+
stderr: "pipe",
|
|
80
|
+
});
|
|
81
|
+
return {
|
|
82
|
+
exited: proc.exited,
|
|
83
|
+
stdout: proc.stdout,
|
|
84
|
+
stderr: proc.stderr,
|
|
85
|
+
};
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export interface RailwayCli {
|
|
89
|
+
checkPresence(): Promise<true>;
|
|
90
|
+
checkAuth(): Promise<string>;
|
|
91
|
+
link(args: { projectId: string; serviceName: string; cwd: string }): Promise<void>;
|
|
92
|
+
setVariable(args: { key: string; value: string; cwd: string }): Promise<void>;
|
|
93
|
+
up(args: { cwd: string }): Promise<void>;
|
|
94
|
+
generateDomain(args: { cwd: string }): Promise<string>;
|
|
95
|
+
addVolume(args: { name: string; mountPath: string; cwd: string }): Promise<void>;
|
|
96
|
+
status(args: { cwd: string }): Promise<RailwayStatus>;
|
|
97
|
+
destroyService(args: { cwd: string }): Promise<void>;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function createRailwayCli(opts: CreateRailwayCliOptions = {}): RailwayCli {
|
|
101
|
+
const spawn = opts.spawn ?? defaultSpawn;
|
|
102
|
+
|
|
103
|
+
async function runRailway(
|
|
104
|
+
args: string[],
|
|
105
|
+
runOpts: RunOptions = {},
|
|
106
|
+
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
|
107
|
+
let handle: SpawnHandle;
|
|
108
|
+
try {
|
|
109
|
+
handle = spawn(["railway", ...args], runOpts);
|
|
110
|
+
} catch (err) {
|
|
111
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
112
|
+
throw new RailwayCliMissingError();
|
|
113
|
+
}
|
|
114
|
+
throw err;
|
|
115
|
+
}
|
|
116
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
117
|
+
readAll(handle.stdout),
|
|
118
|
+
readAll(handle.stderr),
|
|
119
|
+
handle.exited,
|
|
120
|
+
]);
|
|
121
|
+
return { stdout, stderr, exitCode };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function runOrThrow(
|
|
125
|
+
args: string[],
|
|
126
|
+
runOpts: RunOptions = {},
|
|
127
|
+
): Promise<{ stdout: string; stderr: string }> {
|
|
128
|
+
const { stdout, stderr, exitCode } = await runRailway(args, runOpts);
|
|
129
|
+
if (exitCode !== 0) {
|
|
130
|
+
throw new Error(
|
|
131
|
+
`railway ${args.join(" ")} exited ${exitCode}${stderr ? `: ${stderr.trim()}` : ""}`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
return { stdout, stderr };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
async checkPresence() {
|
|
139
|
+
const { exitCode } = await runRailway(["--version"]);
|
|
140
|
+
if (exitCode !== 0) throw new RailwayCliMissingError();
|
|
141
|
+
return true;
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
async checkAuth() {
|
|
145
|
+
const { stdout, stderr, exitCode } = await runRailway(["whoami"]);
|
|
146
|
+
if (exitCode !== 0) {
|
|
147
|
+
throw new RailwayNotLoggedInError(stderr.trim() || "non-zero exit");
|
|
148
|
+
}
|
|
149
|
+
// Output forms observed: "Logged in as foo@example.com" or just "foo@example.com".
|
|
150
|
+
const match = stdout.match(/(?:Logged in as\s+)?([^\s]+@[^\s]+|\S+)$/m);
|
|
151
|
+
return match ? match[1]!.trim() : stdout.trim();
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
async link({ projectId, serviceName, cwd }) {
|
|
155
|
+
await runOrThrow(["link", "--project", projectId, "--service", serviceName], { cwd });
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
async setVariable({ key, value, cwd }) {
|
|
159
|
+
await runOrThrow(["variables", "--set", `${key}=${value}`], { cwd });
|
|
160
|
+
},
|
|
161
|
+
|
|
162
|
+
async up({ cwd }) {
|
|
163
|
+
await runOrThrow(["up", "--detach"], { cwd });
|
|
164
|
+
},
|
|
165
|
+
|
|
166
|
+
async generateDomain({ cwd }) {
|
|
167
|
+
// Idempotent: first call generates, second returns the existing URL.
|
|
168
|
+
const { stdout } = await runOrThrow(["domain", "--generate"], { cwd });
|
|
169
|
+
const match = stdout.match(/https:\/\/[a-z0-9.-]+/i);
|
|
170
|
+
if (!match) {
|
|
171
|
+
throw new Error(`railway domain --generate produced no URL: ${stdout.trim()}`);
|
|
172
|
+
}
|
|
173
|
+
return match[0];
|
|
174
|
+
},
|
|
175
|
+
|
|
176
|
+
async addVolume({ name, mountPath, cwd }) {
|
|
177
|
+
await runOrThrow(["volume", "add", name, "--mount-path", mountPath], { cwd });
|
|
178
|
+
},
|
|
179
|
+
|
|
180
|
+
async status({ cwd }) {
|
|
181
|
+
const { stdout } = await runOrThrow(["status", "--json"], { cwd });
|
|
182
|
+
const parsed = JSON.parse(stdout) as RailwayStatus;
|
|
183
|
+
return parsed;
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
async destroyService({ cwd }) {
|
|
187
|
+
await runOrThrow(["service", "delete", "--yes"], { cwd });
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* .env parser + Railway env-var push plan.
|
|
3
|
+
*
|
|
4
|
+
* Parses the agent dir's `.env` file (excluded from the bundle by design) and
|
|
5
|
+
* produces a structured plan the deploy command shows to the operator for
|
|
6
|
+
* confirmation BEFORE pushing to Railway. Plan structure:
|
|
7
|
+
*
|
|
8
|
+
* { variables: [{ key, value, redactedValue }], warnings: [...] }
|
|
9
|
+
*
|
|
10
|
+
* `redactedValue` masks the middle of each value so the operator can confirm
|
|
11
|
+
* the right secret without the full key being printed to the terminal.
|
|
12
|
+
*
|
|
13
|
+
* This module does NOT call Railway. The deploy command calls `railway-cli`'s
|
|
14
|
+
* `setVariable` for each plan entry after operator confirmation.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
18
|
+
|
|
19
|
+
export interface EnvVariable {
|
|
20
|
+
key: string;
|
|
21
|
+
value: string;
|
|
22
|
+
redactedValue: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface SecretsPlan {
|
|
26
|
+
variables: EnvVariable[];
|
|
27
|
+
warnings: string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const KEY_RE = /^[A-Z_][A-Z0-9_]*$/i;
|
|
31
|
+
|
|
32
|
+
function unquote(raw: string): string {
|
|
33
|
+
if (raw.length >= 2) {
|
|
34
|
+
if ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'"))) {
|
|
35
|
+
return raw.slice(1, -1);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return raw;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Redact a secret for terminal display. Reveals the first 4 and last 4 chars
|
|
43
|
+
* for values >= 12 chars; shorter values are fully masked. Empty strings stay
|
|
44
|
+
* empty so the operator notices.
|
|
45
|
+
*/
|
|
46
|
+
export function redactValue(value: string): string {
|
|
47
|
+
if (value.length === 0) return "";
|
|
48
|
+
if (value.length < 12) return "*".repeat(value.length);
|
|
49
|
+
return `${value.slice(0, 4)}${"*".repeat(value.length - 8)}${value.slice(-4)}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Parse a `.env` file string into a SecretsPlan. Skips blank lines and
|
|
54
|
+
* comments. Tolerates quoted values + `export KEY=value` shorthand. Records
|
|
55
|
+
* a warning for any malformed line rather than throwing — operator gets the
|
|
56
|
+
* full picture before deciding to push.
|
|
57
|
+
*/
|
|
58
|
+
export function parseEnvText(text: string): SecretsPlan {
|
|
59
|
+
const variables: EnvVariable[] = [];
|
|
60
|
+
const warnings: string[] = [];
|
|
61
|
+
const seen = new Set<string>();
|
|
62
|
+
|
|
63
|
+
const lines = text.split(/\r?\n/);
|
|
64
|
+
for (let i = 0; i < lines.length; i++) {
|
|
65
|
+
const raw = lines[i]!;
|
|
66
|
+
const line = raw.trim();
|
|
67
|
+
if (line === "" || line.startsWith("#")) continue;
|
|
68
|
+
|
|
69
|
+
const rest = line.startsWith("export ") ? line.slice("export ".length).trimStart() : line;
|
|
70
|
+
const eqIdx = rest.indexOf("=");
|
|
71
|
+
if (eqIdx < 0) {
|
|
72
|
+
warnings.push(`line ${i + 1}: no '=' found, skipped: "${line}"`);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const key = rest.slice(0, eqIdx).trim();
|
|
77
|
+
const value = unquote(rest.slice(eqIdx + 1).trim());
|
|
78
|
+
|
|
79
|
+
if (!KEY_RE.test(key)) {
|
|
80
|
+
warnings.push(`line ${i + 1}: invalid key "${key}", skipped`);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (seen.has(key)) {
|
|
85
|
+
warnings.push(`line ${i + 1}: duplicate key "${key}" — later value overrides earlier`);
|
|
86
|
+
}
|
|
87
|
+
seen.add(key);
|
|
88
|
+
|
|
89
|
+
// Replace any prior entry for the same key.
|
|
90
|
+
const existing = variables.findIndex((v) => v.key === key);
|
|
91
|
+
const entry: EnvVariable = { key, value, redactedValue: redactValue(value) };
|
|
92
|
+
if (existing >= 0) {
|
|
93
|
+
variables[existing] = entry;
|
|
94
|
+
} else {
|
|
95
|
+
variables.push(entry);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { variables, warnings };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Read the agent dir's `.env` file and return a SecretsPlan. Returns an empty
|
|
104
|
+
* plan + warning when `.env` doesn't exist (the agent may have no secrets,
|
|
105
|
+
* which is rare but allowed).
|
|
106
|
+
*/
|
|
107
|
+
export function loadSecretsPlan(envPath: string): SecretsPlan {
|
|
108
|
+
if (!existsSync(envPath)) {
|
|
109
|
+
return {
|
|
110
|
+
variables: [],
|
|
111
|
+
warnings: [`no .env file at ${envPath} — nothing to push to Railway`],
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
const text = readFileSync(envPath, "utf-8");
|
|
115
|
+
return parseEnvText(text);
|
|
116
|
+
}
|
package/src/cli/index.ts
CHANGED
|
@@ -12,12 +12,14 @@
|
|
|
12
12
|
* auggy restart <name> Stop + start
|
|
13
13
|
* auggy status [name] Show running agents
|
|
14
14
|
* auggy ls List registered agents
|
|
15
|
-
* auggy remove <name> [--yes]
|
|
15
|
+
* auggy remove <name> [--yes] [--cloud] Delete an agent (dir + index, optionally Railway service)
|
|
16
|
+
* auggy deploy <name> --to railway Deploy an agent to Railway
|
|
16
17
|
* auggy chat Launch local GUI
|
|
17
18
|
* auggy eval [name] Run portable security eval suite
|
|
18
19
|
*/
|
|
19
20
|
|
|
20
21
|
import { Command } from "commander";
|
|
22
|
+
import pkg from "../../package.json" with { type: "json" };
|
|
21
23
|
import { runCreate } from "./commands/create";
|
|
22
24
|
import { runAdd } from "./commands/add";
|
|
23
25
|
import { addSkillCommand } from "./commands/add-skill";
|
|
@@ -33,7 +35,7 @@ import { runLs } from "./commands/ls";
|
|
|
33
35
|
|
|
34
36
|
const program = new Command();
|
|
35
37
|
|
|
36
|
-
program.name("auggy").description("Auggy agent runtime CLI").version(
|
|
38
|
+
program.name("auggy").description("Auggy agent runtime CLI").version(pkg.version);
|
|
37
39
|
|
|
38
40
|
program
|
|
39
41
|
.command("create <name>")
|
|
@@ -129,11 +131,14 @@ program
|
|
|
129
131
|
|
|
130
132
|
program
|
|
131
133
|
.command("remove <name>")
|
|
132
|
-
.description(
|
|
134
|
+
.description(
|
|
135
|
+
"Remove an agent (delete dir + clear index entry; --cloud also destroys Railway service)",
|
|
136
|
+
)
|
|
133
137
|
.option("--yes", "skip the confirmation prompt")
|
|
134
|
-
.
|
|
138
|
+
.option("--cloud", "also destroy the agent's Railway service (when cloud-deployed)")
|
|
139
|
+
.action(async (name: string, opts: { yes?: boolean; cloud?: boolean }) => {
|
|
135
140
|
try {
|
|
136
|
-
await runRemove(name, { yes: opts.yes });
|
|
141
|
+
await runRemove(name, { yes: opts.yes, cloud: opts.cloud });
|
|
137
142
|
} catch (err) {
|
|
138
143
|
console.error(`Error: ${(err as Error).message}`);
|
|
139
144
|
process.exit(1);
|
|
@@ -172,6 +177,47 @@ program
|
|
|
172
177
|
}
|
|
173
178
|
});
|
|
174
179
|
|
|
180
|
+
program
|
|
181
|
+
.command("deploy <name>")
|
|
182
|
+
.description("Deploy an agent to the cloud (--to railway)")
|
|
183
|
+
.option("--to <provider>", "deploy target (only `railway` supported in v1.0)", "railway")
|
|
184
|
+
.option("--yes", "skip the secrets-push confirmation prompt")
|
|
185
|
+
.action(async (name: string, opts: { to: string; yes?: boolean }) => {
|
|
186
|
+
try {
|
|
187
|
+
const { runDeploy } = await import("./commands/deploy");
|
|
188
|
+
const { createRailwayCli } = await import("./deploy/railway-cli");
|
|
189
|
+
const { input, confirm } = await import("@inquirer/prompts");
|
|
190
|
+
|
|
191
|
+
const cli = createRailwayCli();
|
|
192
|
+
const result = await runDeploy(name, {
|
|
193
|
+
to: opts.to as "railway",
|
|
194
|
+
yes: opts.yes ?? false,
|
|
195
|
+
cli,
|
|
196
|
+
promptProjectId: () =>
|
|
197
|
+
input({
|
|
198
|
+
message:
|
|
199
|
+
"Railway project ID (find it in the Railway dashboard URL or via `railway list`):",
|
|
200
|
+
validate: (v) => v.trim().length > 0 || "project ID required",
|
|
201
|
+
}),
|
|
202
|
+
promptConfirm: (message) => confirm({ message, default: false }),
|
|
203
|
+
logger: {
|
|
204
|
+
info: (msg) => console.log(msg),
|
|
205
|
+
warn: (msg) => console.warn(`warn: ${msg}`),
|
|
206
|
+
error: (msg) => console.error(`error: ${msg}`),
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
console.log(`\nDeployed ${name} to Railway.`);
|
|
210
|
+
console.log(` URL: ${result.url}`);
|
|
211
|
+
console.log(` Project: ${result.projectId}`);
|
|
212
|
+
console.log(` Service: ${result.serviceId}`);
|
|
213
|
+
console.log(` Volume: ${result.volumeId} (mounted at /app/data)`);
|
|
214
|
+
console.log(`\nFollow the build in the Railway dashboard or with \`railway logs\`.`);
|
|
215
|
+
} catch (err) {
|
|
216
|
+
console.error(`Error: ${(err as Error).message}`);
|
|
217
|
+
process.exit(1);
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
|
|
175
221
|
program.addCommand(chatCommand());
|
|
176
222
|
program.addCommand(evalCommand());
|
|
177
223
|
|