koppler 0.0.0-stage → 0.0.2
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/LICENSE +21 -0
- package/README.md +101 -2
- package/bin/run-logged +16 -0
- package/bin/secret-list +7 -0
- package/bin/secret-psql +41 -0
- package/bin/secret-set +17 -0
- package/bin/secret-store +82 -0
- package/bin/secret-test +10 -0
- package/bin/with-secrets +25 -0
- package/dist/.built +1 -0
- package/dist/cli/agent.js +144 -0
- package/dist/cli/agent.js.map +1 -0
- package/dist/cli/doctor.js +173 -0
- package/dist/cli/doctor.js.map +1 -0
- package/dist/cli/mcp.js +204 -0
- package/dist/cli/mcp.js.map +1 -0
- package/dist/cli/output.js +37 -0
- package/dist/cli/output.js.map +1 -0
- package/dist/cli/records.js +121 -0
- package/dist/cli/records.js.map +1 -0
- package/dist/cli/running.js +270 -0
- package/dist/cli/running.js.map +1 -0
- package/dist/cli/shell.js +43 -0
- package/dist/cli/shell.js.map +1 -0
- package/dist/cli/wait.js +59 -0
- package/dist/cli/wait.js.map +1 -0
- package/dist/core/addresses.js +55 -0
- package/dist/core/addresses.js.map +1 -0
- package/dist/core/arrange.js +329 -0
- package/dist/core/arrange.js.map +1 -0
- package/dist/core/cables.js +158 -0
- package/dist/core/cables.js.map +1 -0
- package/dist/core/expressions.js +72 -0
- package/dist/core/expressions.js.map +1 -0
- package/dist/core/frameworks.js +13 -0
- package/dist/core/frameworks.js.map +1 -0
- package/dist/core/kinds.js +244 -0
- package/dist/core/kinds.js.map +1 -0
- package/dist/core/launch.js +181 -0
- package/dist/core/launch.js.map +1 -0
- package/dist/core/logfmt.js +144 -0
- package/dist/core/logfmt.js.map +1 -0
- package/dist/core/mock.js +27 -0
- package/dist/core/mock.js.map +1 -0
- package/dist/core/plan.js +147 -0
- package/dist/core/plan.js.map +1 -0
- package/dist/core/ports.js +155 -0
- package/dist/core/ports.js.map +1 -0
- package/dist/core/presets.js +186 -0
- package/dist/core/presets.js.map +1 -0
- package/dist/core/secret-types.js +3 -0
- package/dist/core/secret-types.js.map +1 -0
- package/dist/core/sign-in.js +30 -0
- package/dist/core/sign-in.js.map +1 -0
- package/dist/core/stack-path.js +64 -0
- package/dist/core/stack-path.js.map +1 -0
- package/dist/core/stack.js +509 -0
- package/dist/core/stack.js.map +1 -0
- package/dist/core/terraform.js +393 -0
- package/dist/core/terraform.js.map +1 -0
- package/dist/data.js +37 -0
- package/dist/data.js.map +1 -0
- package/dist/engine/actions.js +99 -0
- package/dist/engine/actions.js.map +1 -0
- package/dist/engine/app-commands.js +106 -0
- package/dist/engine/app-commands.js.map +1 -0
- package/dist/engine/app-start.js +167 -0
- package/dist/engine/app-start.js.map +1 -0
- package/dist/engine/app-state.js +43 -0
- package/dist/engine/app-state.js.map +1 -0
- package/dist/engine/boards.js +219 -0
- package/dist/engine/boards.js.map +1 -0
- package/dist/engine/branch.js +194 -0
- package/dist/engine/branch.js.map +1 -0
- package/dist/engine/browser-console.js +45 -0
- package/dist/engine/browser-console.js.map +1 -0
- package/dist/engine/calls.js +97 -0
- package/dist/engine/calls.js.map +1 -0
- package/dist/engine/checks.js +270 -0
- package/dist/engine/checks.js.map +1 -0
- package/dist/engine/clones.js +67 -0
- package/dist/engine/clones.js.map +1 -0
- package/dist/engine/config.js +26 -0
- package/dist/engine/config.js.map +1 -0
- package/dist/engine/deployed.js +488 -0
- package/dist/engine/deployed.js.map +1 -0
- package/dist/engine/devices.js +81 -0
- package/dist/engine/devices.js.map +1 -0
- package/dist/engine/discover.js +236 -0
- package/dist/engine/discover.js.map +1 -0
- package/dist/engine/down.js +253 -0
- package/dist/engine/down.js.map +1 -0
- package/dist/engine/history.js +215 -0
- package/dist/engine/history.js.map +1 -0
- package/dist/engine/investigate.js +278 -0
- package/dist/engine/investigate.js.map +1 -0
- package/dist/engine/libraries.js +129 -0
- package/dist/engine/libraries.js.map +1 -0
- package/dist/engine/linked.js +146 -0
- package/dist/engine/linked.js.map +1 -0
- package/dist/engine/listen.js +223 -0
- package/dist/engine/listen.js.map +1 -0
- package/dist/engine/locks.js +38 -0
- package/dist/engine/locks.js.map +1 -0
- package/dist/engine/messages.js +177 -0
- package/dist/engine/messages.js.map +1 -0
- package/dist/engine/needs.js +363 -0
- package/dist/engine/needs.js.map +1 -0
- package/dist/engine/processes.js +350 -0
- package/dist/engine/processes.js.map +1 -0
- package/dist/engine/query.js +241 -0
- package/dist/engine/query.js.map +1 -0
- package/dist/engine/queues.js +100 -0
- package/dist/engine/queues.js.map +1 -0
- package/dist/engine/recipes.js +248 -0
- package/dist/engine/recipes.js.map +1 -0
- package/dist/engine/reload.js +32 -0
- package/dist/engine/reload.js.map +1 -0
- package/dist/engine/repos.js +214 -0
- package/dist/engine/repos.js.map +1 -0
- package/dist/engine/research.js +134 -0
- package/dist/engine/research.js.map +1 -0
- package/dist/engine/run-logs.js +79 -0
- package/dist/engine/run-logs.js.map +1 -0
- package/dist/engine/run.js +54 -0
- package/dist/engine/run.js.map +1 -0
- package/dist/engine/runs.js +223 -0
- package/dist/engine/runs.js.map +1 -0
- package/dist/engine/session.js +130 -0
- package/dist/engine/session.js.map +1 -0
- package/dist/engine/share.js +124 -0
- package/dist/engine/share.js.map +1 -0
- package/dist/engine/stack-file.js +206 -0
- package/dist/engine/stack-file.js.map +1 -0
- package/dist/engine/stack-share.js +86 -0
- package/dist/engine/stack-share.js.map +1 -0
- package/dist/engine/stacks.js +143 -0
- package/dist/engine/stacks.js.map +1 -0
- package/dist/engine/sync.js +214 -0
- package/dist/engine/sync.js.map +1 -0
- package/dist/engine/traces.js +65 -0
- package/dist/engine/traces.js.map +1 -0
- package/dist/engine/up.js +621 -0
- package/dist/engine/up.js.map +1 -0
- package/dist/engine/watch.js +71 -0
- package/dist/engine/watch.js.map +1 -0
- package/dist/engine/wire.js +120 -0
- package/dist/engine/wire.js.map +1 -0
- package/dist/failure.js +16 -0
- package/dist/failure.js.map +1 -0
- package/dist/github.js +95 -0
- package/dist/github.js.map +1 -0
- package/dist/ids.js +6 -0
- package/dist/ids.js.map +1 -0
- package/dist/json-files.js +36 -0
- package/dist/json-files.js.map +1 -0
- package/dist/koppler.mjs +840 -0
- package/dist/koppler.mjs.map +1 -0
- package/dist/lambda-local.mjs +250 -0
- package/dist/lambda-local.mjs.map +1 -0
- package/dist/lambda-secrets-proxy.go +31 -0
- package/dist/mock-server.mjs +23 -0
- package/dist/mock-server.mjs.map +1 -0
- package/dist/page/assets/JetBrainsMono-cyrillic-D2jMJ_bk.woff2 +0 -0
- package/dist/page/assets/JetBrainsMono-greek-D2nYnAec.woff2 +0 -0
- package/dist/page/assets/JetBrainsMono-latin-Db4Uuiha.woff2 +0 -0
- package/dist/page/assets/JetBrainsMono-latin-ext-Bnd2h4Br.woff2 +0 -0
- package/dist/page/assets/JetBrainsMono-vietnamese-CNlsqbWb.woff2 +0 -0
- package/dist/page/assets/SchibstedGrotesk-latin-DxefFeRw.woff2 +0 -0
- package/dist/page/assets/SchibstedGrotesk-latin-ext-CnpJE18X.woff2 +0 -0
- package/dist/page/assets/index-Bkj_pX35.js +42 -0
- package/dist/page/assets/index-Dq45jswH.css +1 -0
- package/dist/page/index.html +14 -0
- package/dist/page/theme.js +8 -0
- package/dist/paths.js +19 -0
- package/dist/paths.js.map +1 -0
- package/dist/safe-env.js +73 -0
- package/dist/safe-env.js.map +1 -0
- package/dist/sam-local.mjs +135 -0
- package/dist/sam-local.mjs.map +1 -0
- package/dist/scrub.js +87 -0
- package/dist/scrub.js.map +1 -0
- package/dist/secret-check.mjs +262 -0
- package/dist/secret-check.mjs.map +1 -0
- package/dist/server/browser-console.js +117 -0
- package/dist/server/browser-console.js.map +1 -0
- package/dist/server/cable-test.js +365 -0
- package/dist/server/cable-test.js.map +1 -0
- package/dist/server/choices.js +51 -0
- package/dist/server/choices.js.map +1 -0
- package/dist/server/files.js +107 -0
- package/dist/server/files.js.map +1 -0
- package/dist/server/gate.js +128 -0
- package/dist/server/gate.js.map +1 -0
- package/dist/server/logs.js +76 -0
- package/dist/server/logs.js.map +1 -0
- package/dist/server/mobile.js +113 -0
- package/dist/server/mobile.js.map +1 -0
- package/dist/server/routes.js +326 -0
- package/dist/server/routes.js.map +1 -0
- package/dist/server/runs.js +111 -0
- package/dist/server/runs.js.map +1 -0
- package/dist/server/sanitize.js +242 -0
- package/dist/server/sanitize.js.map +1 -0
- package/dist/server/secrets.js +111 -0
- package/dist/server/secrets.js.map +1 -0
- package/dist/server/status.js +321 -0
- package/dist/server/status.js.map +1 -0
- package/dist/server/token.js +38 -0
- package/dist/server/token.js.map +1 -0
- package/dist/server/waiting.js +79 -0
- package/dist/server/waiting.js.map +1 -0
- package/dist/status-watch.mjs +63 -0
- package/dist/status-watch.mjs.map +1 -0
- package/package.json +74 -4
- package/skills/koppler-run/SKILL.md +169 -0
- package/skills/koppler-run/VERSION +1 -0
- package/skills/koppler-run/references/action-log.md +50 -0
- package/skills/koppler-run/references/permissions.md +25 -0
- package/skills/koppler-run/references/preparing-nodes.md +62 -0
- package/skills/koppler-run/references/reporting-bugs.md +76 -0
- package/skills/koppler-run/references/research.md +40 -0
- package/skills/koppler-run/references/secrets.md +56 -0
- package/skills/koppler-run/references/stack.md +197 -0
- package/skills/koppler-run/references/status.md +92 -0
package/dist/koppler.mjs
ADDED
|
@@ -0,0 +1,840 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The koppler command: starts apps, tunnels and the canvas as detached processes that outlive the agent session, stops
|
|
3
|
+
// and reverts them, and everything around a run. `koppler --help` lists the commands (COMMANDS, at the end).
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { doctor, ports, secret } from "./cli/doctor.js";
|
|
9
|
+
import { confirm, flagValue, positional } from "./cli/output.js";
|
|
10
|
+
import { actionsCommand, stepCommand } from "./cli/records.js";
|
|
11
|
+
import { fail, Failure } from "./failure.js";
|
|
12
|
+
import { canvas, logs, status, stop } from "./cli/running.js";
|
|
13
|
+
import { readData, readStack } from "./data.js";
|
|
14
|
+
import { switchCommand } from "./engine/branch.js";
|
|
15
|
+
import { APPROVE, stackCommands } from "./engine/checks.js";
|
|
16
|
+
import { deployed } from "./engine/deployed.js";
|
|
17
|
+
import { discoverCommand } from "./engine/discover.js";
|
|
18
|
+
import { down } from "./engine/down.js";
|
|
19
|
+
import { investigate } from "./engine/investigate.js";
|
|
20
|
+
import { discardChanges, linkStack, linkedStatus, proposeChanges, updateStack } from "./engine/linked.js";
|
|
21
|
+
import { boardCommand } from "./engine/boards.js";
|
|
22
|
+
import { listen } from "./engine/listen.js";
|
|
23
|
+
import { lockedPaths, setLock } from "./engine/locks.js";
|
|
24
|
+
import { sayCommand } from "./engine/messages.js";
|
|
25
|
+
import { alive, readRecord, sh } from "./engine/processes.js";
|
|
26
|
+
import { writeJson } from "./json-files.js";
|
|
27
|
+
import { queryCommand } from "./engine/query.js";
|
|
28
|
+
import { recipesCommand } from "./engine/recipes.js";
|
|
29
|
+
import { findCommand } from "./engine/research.js";
|
|
30
|
+
import { run } from "./engine/run.js";
|
|
31
|
+
import { appliedCommand, historyCommand, planCommand, runningNames, sessionCommand } from "./engine/runs.js";
|
|
32
|
+
import { updateClones } from "./engine/clones.js";
|
|
33
|
+
import { readIndex } from "./engine/config.js";
|
|
34
|
+
import { candidateRepos } from "./engine/repos.js";
|
|
35
|
+
import { addApps, addOutside, editStack, stackSource, readStackAt, removeApp } from "./engine/stack-file.js";
|
|
36
|
+
import { createStack, currentStack } from "./engine/stacks.js";
|
|
37
|
+
import { init as initStack, stacksCommand, sync, useCommand } from "./engine/sync.js";
|
|
38
|
+
import { up } from "./engine/up.js";
|
|
39
|
+
import { wait } from "./cli/wait.js";
|
|
40
|
+
import { pin } from "./engine/run-logs.js";
|
|
41
|
+
import { runs } from "./engine/history.js";
|
|
42
|
+
import { mcp } from "./cli/mcp.js";
|
|
43
|
+
import { agentCommand } from "./cli/agent.js";
|
|
44
|
+
import { doCommand } from "./engine/app-commands.js";
|
|
45
|
+
import { watchCommand } from "./engine/watch.js";
|
|
46
|
+
import { shellCommand } from "./cli/shell.js";
|
|
47
|
+
import { reloadCommand } from "./engine/reload.js";
|
|
48
|
+
import { tracesCommand } from "./engine/traces.js";
|
|
49
|
+
import { checkStackFile, proposeStack, stackSchema } from "./engine/stack-share.js";
|
|
50
|
+
import { wire } from "./engine/wire.js";
|
|
51
|
+
import { APP, CODE, configDir, tempDir } from "./paths.js";
|
|
52
|
+
const without = (args, ...flags) => args.filter(arg => !flags.includes(arg));
|
|
53
|
+
/**
|
|
54
|
+
* A first-time setup ends with the full picture: ports, conflicts and calls of every app. With --pick, or on a stack
|
|
55
|
+
* that has no apps yet, init ends on the canvas, where the user ticks the clones that belong to the stack.
|
|
56
|
+
*/
|
|
57
|
+
async function init(given) {
|
|
58
|
+
let args = given;
|
|
59
|
+
// A new, empty stack: a name and its deployed environments; its apps are picked next.
|
|
60
|
+
if (args.includes('--new')) {
|
|
61
|
+
const named = flagValue(args, '--new');
|
|
62
|
+
const created = createStack({ name: named ?? '', envs: flagValue(args, '--envs') ?? '' });
|
|
63
|
+
if (created.error !== undefined)
|
|
64
|
+
fail(created.error);
|
|
65
|
+
console.log(`Created ${String(named)} in ${created.stack}; it is the stack in use.`);
|
|
66
|
+
const own = ['--new', '--envs'];
|
|
67
|
+
args = [...args.filter((arg, at) => !own.includes(arg) && !own.includes(args[at - 1] ?? '')), '--pick'];
|
|
68
|
+
}
|
|
69
|
+
const pick = args.includes('--pick');
|
|
70
|
+
initStack(without(args, '--pick', '--no-fetch'));
|
|
71
|
+
const stack = readStack();
|
|
72
|
+
// With no app in the stack yet there is no clone to update and nothing to report.
|
|
73
|
+
if (stack && !stack.setup && Object.keys(stack.apps).length) {
|
|
74
|
+
console.log('');
|
|
75
|
+
if (!args.includes('--no-fetch')) {
|
|
76
|
+
await updateClones();
|
|
77
|
+
console.log('');
|
|
78
|
+
}
|
|
79
|
+
investigate({ afterSync: true });
|
|
80
|
+
}
|
|
81
|
+
const terminal = process.stdout.isTTY;
|
|
82
|
+
if (!stack || !(pick || (!Object.keys(stack.apps).length && terminal)))
|
|
83
|
+
return;
|
|
84
|
+
if (stack.setup && !pick && !terminal)
|
|
85
|
+
return;
|
|
86
|
+
console.log(stack.setup
|
|
87
|
+
? '\nName your stack and choose its apps on the canvas.'
|
|
88
|
+
: `\nChoose which of your repos Koppler should run in ${stack.name ?? 'this stack'} on the canvas.`);
|
|
89
|
+
await canvas(['--pick']);
|
|
90
|
+
}
|
|
91
|
+
const loaded = () => readStack() ?? fail('no stack loaded (koppler init)');
|
|
92
|
+
/** After changes made outside the canvas: the stack and clones read again, and an open canvas shows them. */
|
|
93
|
+
function refresh(args) {
|
|
94
|
+
if (!args.includes('--no-sync'))
|
|
95
|
+
sync(without(args, '--no-sync'));
|
|
96
|
+
writeJson(path.join(tempDir(), 'refresh.json'), { at: new Date().toISOString() });
|
|
97
|
+
const canvas = readRecord('canvas');
|
|
98
|
+
console.log(canvas && alive(canvas)
|
|
99
|
+
? 'The open canvas reads the stack and its board again.'
|
|
100
|
+
: 'No canvas is open; it shows the changes when it opens.');
|
|
101
|
+
}
|
|
102
|
+
/** Stacks on this Mac, or switch to one; --boards lists the boards of the stack in use (the team's presets and your own). */
|
|
103
|
+
function use(args) {
|
|
104
|
+
if (args.includes('--boards')) {
|
|
105
|
+
const own = (readData('state')?.['presets'] ?? []);
|
|
106
|
+
const boards = [
|
|
107
|
+
...(loaded().presets ?? []).map(preset => ({ name: preset.name, from: 'team' })),
|
|
108
|
+
...own.map(preset => ({ name: preset.name, from: 'yours' })),
|
|
109
|
+
];
|
|
110
|
+
if (args.includes('--json'))
|
|
111
|
+
console.log(JSON.stringify(boards, null, 2));
|
|
112
|
+
else if (!boards.length)
|
|
113
|
+
console.log('No boards yet: build one on the canvas.');
|
|
114
|
+
else
|
|
115
|
+
for (const board of boards)
|
|
116
|
+
console.log(`${board.name} (${board.from === 'team' ? 'team preset' : 'your board'})`);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const wanted = positional(args);
|
|
120
|
+
if (!wanted.length) {
|
|
121
|
+
stacksCommand(args);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (runningNames().some(name => name !== 'canvas'))
|
|
125
|
+
fail('something is still running; stop and revert it first (canvas, or koppler down), then switch');
|
|
126
|
+
useCommand(wanted);
|
|
127
|
+
}
|
|
128
|
+
const NOT_LINKED = 'the stack in use is not linked to GitHub; link one with: koppler link <GitHub link to a stack.json>';
|
|
129
|
+
/** A stack shared on GitHub: link it, then see its changes, bring in GitHub's, drop yours, or propose yours. */
|
|
130
|
+
async function link(args) {
|
|
131
|
+
const [url] = positional(args);
|
|
132
|
+
if (url) {
|
|
133
|
+
const linked = await linkStack(url);
|
|
134
|
+
if (linked.error !== undefined)
|
|
135
|
+
fail(linked.error);
|
|
136
|
+
console.log(`USING ${String(linked.name)}, linked from ${url}`);
|
|
137
|
+
sync([]);
|
|
138
|
+
// A linked stack is someone else's file: say so when it brings commands of its own.
|
|
139
|
+
const own = stackCommands(readStack()).length;
|
|
140
|
+
if (own)
|
|
141
|
+
console.log(`\nThis stack carries ${String(own)} ${own === 1 ? 'command or code change' : 'commands and code changes'} of its own. None runs until you have reviewed them: ${APPROVE}`);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const dir = readStack()?.source?.replace(/\/stack\.json$/, '') ?? fail('no stack in use');
|
|
145
|
+
const linked = linkedStatus(dir) ?? fail(NOT_LINKED);
|
|
146
|
+
if (args[0] === 'update') {
|
|
147
|
+
const updated = await updateStack(dir);
|
|
148
|
+
if (updated.error !== undefined)
|
|
149
|
+
fail(updated.error);
|
|
150
|
+
console.log(updated.message);
|
|
151
|
+
sync([]);
|
|
152
|
+
}
|
|
153
|
+
else if (args[0] === 'discard') {
|
|
154
|
+
discardChanges(dir);
|
|
155
|
+
console.log('Your changes are discarded; the copy matches the shared stack again.');
|
|
156
|
+
sync([]);
|
|
157
|
+
}
|
|
158
|
+
else if (args[0] === 'pr') {
|
|
159
|
+
const proposed = await proposeChanges(dir);
|
|
160
|
+
if (proposed.error !== undefined)
|
|
161
|
+
fail(proposed.error);
|
|
162
|
+
console.log(`Pull request: ${proposed.url}`);
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
console.log(`Linked from ${linked.url} (fetched ${String(linked.fetchedAt)})`);
|
|
166
|
+
const changes = linked.changes.map(change => ` ${change}`).join('\n');
|
|
167
|
+
const request = linked.pr ? `\nPull request: ${linked.pr}` : '';
|
|
168
|
+
console.log(linked.changes.length
|
|
169
|
+
? `Changes not in the shared stack yet:\n${changes}${request}`
|
|
170
|
+
: 'No changes: this copy matches the shared stack.');
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Everything the repos say about the stack's apps: the report (ports, tunnels, calls, conflicts, gaps), then what could
|
|
175
|
+
* be added to stack.json; --deployed adds the infrastructure drafts, --find looks up repos by name first.
|
|
176
|
+
*/
|
|
177
|
+
async function discover(args) {
|
|
178
|
+
const names = flagValue(args, '--find');
|
|
179
|
+
if (names !== undefined) {
|
|
180
|
+
await findCommand([...names.split(/[,\s]+/).filter(Boolean), ...args.filter(arg => arg === '--no-github')]);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (!args.includes('--no-fetch')) {
|
|
184
|
+
await updateClones();
|
|
185
|
+
console.log('');
|
|
186
|
+
}
|
|
187
|
+
investigate();
|
|
188
|
+
console.log('');
|
|
189
|
+
discoverCommand(args.filter(arg => arg === '--apply'));
|
|
190
|
+
if (args.includes('--deployed')) {
|
|
191
|
+
console.log('');
|
|
192
|
+
deployed(positional(args, ['--find']));
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
/** What the newest Send does; with apps, the tunnels they need; with --wire, what one cable changes. */
|
|
196
|
+
function plan(args) {
|
|
197
|
+
if (args.includes('--wire'))
|
|
198
|
+
wire(without(args, '--wire'));
|
|
199
|
+
else if (positional(args, ['--env']).length)
|
|
200
|
+
ports(args);
|
|
201
|
+
else
|
|
202
|
+
planCommand(args);
|
|
203
|
+
}
|
|
204
|
+
/** A run's history; done marks one finished, which makes its board what runs. */
|
|
205
|
+
async function history([sub, ...rest]) {
|
|
206
|
+
if (sub === 'session') {
|
|
207
|
+
await sessionCommand();
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if (sub === 'pin' || sub === 'unpin') {
|
|
211
|
+
const [id = ''] = rest;
|
|
212
|
+
if (!runs().some(run => run.id === id))
|
|
213
|
+
fail(`no run ${id}; koppler history lists them`);
|
|
214
|
+
pin(id, sub === 'pin');
|
|
215
|
+
console.log(sub === 'pin' ? `Kept: run ${id}'s logs stay until you unpin it.` : `Run ${id}'s logs go with the oldest again.`);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (sub !== 'done') {
|
|
219
|
+
historyCommand([sub, ...rest].filter(arg => arg !== undefined));
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
const args = rest;
|
|
223
|
+
const [id] = positional(args, ['--failed']);
|
|
224
|
+
// Without a reason the flag still goes along, so the command asks for one.
|
|
225
|
+
const why = flagValue(args, '--failed');
|
|
226
|
+
const failed = args.includes('--failed') ? ['--failed', ...(why ? [why] : [])] : [];
|
|
227
|
+
await appliedCommand([...(id ? [id] : []), ...failed]);
|
|
228
|
+
}
|
|
229
|
+
/** Remembered code changes: list them, keep the last edit, or apply the ones that match the latest Send. */
|
|
230
|
+
function recipes(args) {
|
|
231
|
+
if (args.includes('--remember'))
|
|
232
|
+
recipesCommand('remember', without(args, '--remember'));
|
|
233
|
+
else if (args.includes('--apply'))
|
|
234
|
+
recipesCommand('prepare', without(args, '--apply'));
|
|
235
|
+
else
|
|
236
|
+
recipesCommand('recipes', args);
|
|
237
|
+
}
|
|
238
|
+
/** Apps join the stack from their clones: a repo name or slug from `koppler sync`, or a folder. */
|
|
239
|
+
function add(args) {
|
|
240
|
+
const outside = flagValue(args, '--outside');
|
|
241
|
+
if (outside !== undefined) {
|
|
242
|
+
const values = (flag) => args.filter((_, at) => args[at - 1] === flag);
|
|
243
|
+
const added = addOutside({
|
|
244
|
+
name: outside,
|
|
245
|
+
url: flagValue(args, '--url'),
|
|
246
|
+
why: flagValue(args, '--why'),
|
|
247
|
+
calledBy: values('--called-by').flatMap(value => value.split(',').map(id => id.trim())),
|
|
248
|
+
sendsTo: values('--sends-to').flatMap(value => value.split(',').map(id => id.trim())),
|
|
249
|
+
});
|
|
250
|
+
if (added.error !== undefined)
|
|
251
|
+
fail(added.error);
|
|
252
|
+
console.log(`Added ${outside} (${added.added}) as an outside system; its card shows next to the apps that call it.`);
|
|
253
|
+
sync([]);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
const wanted = positional(args);
|
|
257
|
+
if (!wanted.length)
|
|
258
|
+
fail('usage: koppler add <repo or folder>... (koppler add --list shows the repos you can add)');
|
|
259
|
+
const config = JSON.parse(fs.readFileSync(path.join(configDir(), 'config.json'), 'utf8'));
|
|
260
|
+
// The clones as the last scan found them, at their full paths: not the picker's copy, which shows them as ~/...
|
|
261
|
+
const index = readIndex() ?? fail('no repos found yet: koppler init');
|
|
262
|
+
const stack = JSON.parse(fs.readFileSync(path.join(config.stack, 'stack.json'), 'utf8'));
|
|
263
|
+
const known = candidateRepos(index, stack, config.stack);
|
|
264
|
+
const picks = wanted.map(want => {
|
|
265
|
+
const full = (given) => path.resolve(given.replace(/^~(?=\/|$)/, os.homedir()));
|
|
266
|
+
const folder = full(want);
|
|
267
|
+
const is = (slug) => slug === want || slug.split('/').pop() === want;
|
|
268
|
+
return (known.find(candidate => is(candidate.slug) || full(candidate.path) === folder) ??
|
|
269
|
+
fail(`${want} is not one of your clones that is outside the stack; koppler sync --rescan finds new clones`));
|
|
270
|
+
});
|
|
271
|
+
const added = addApps(picks.map(({ slug }) => ({ slug })));
|
|
272
|
+
if (added.error !== undefined)
|
|
273
|
+
fail(added.error);
|
|
274
|
+
console.log(added.added.length
|
|
275
|
+
? `Added ${added.added.join(', ')}; fill in how each runs with: koppler discover`
|
|
276
|
+
: 'Nothing to add: they are in the stack already.');
|
|
277
|
+
sync([]);
|
|
278
|
+
}
|
|
279
|
+
/** One field of the stack in use: read it, set it or take it out, checked against the stack's rules. */
|
|
280
|
+
async function stackCommand(args) {
|
|
281
|
+
const [what, at, ...rest] = args;
|
|
282
|
+
if (what === 'schema') {
|
|
283
|
+
console.log(JSON.stringify(stackSchema(), null, 2));
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
if (what === 'check') {
|
|
287
|
+
const file = at ??
|
|
288
|
+
path.join(currentStack() ?? fail('no stack in use: give the file, koppler stack check stack.json'), 'stack.json');
|
|
289
|
+
const problems = checkStackFile(file);
|
|
290
|
+
if (problems.length)
|
|
291
|
+
fail(`${file}: ${String(problems.length)} ${problems.length === 1 ? 'problem' : 'problems'}\n${problems.map(one => ` ${one}`).join('\n')}`);
|
|
292
|
+
console.log(`${file}: OK`);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
if (what === 'propose') {
|
|
296
|
+
await proposeStack();
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (what === 'get') {
|
|
300
|
+
const read = readStackAt(at ?? '');
|
|
301
|
+
if (read.error !== undefined)
|
|
302
|
+
fail(read.error);
|
|
303
|
+
if (read.value === undefined)
|
|
304
|
+
fail(`nothing at ${at ?? ''}`);
|
|
305
|
+
console.log(JSON.stringify(read.value, null, 2));
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
if (what === 'locks') {
|
|
309
|
+
const source = stackSource() ?? fail('no stack in use');
|
|
310
|
+
const locked = lockedPaths(source);
|
|
311
|
+
console.log(locked.length ? locked.join('\n') : 'No field is locked.');
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
if (what === 'lock' || what === 'unlock') {
|
|
315
|
+
const source = stackSource() ?? fail('no stack in use');
|
|
316
|
+
if (!at)
|
|
317
|
+
fail(`usage: koppler stack ${what} <path>`);
|
|
318
|
+
// Only the person at the terminal unlocks: an agent or a script cannot answer, and --yes is not taken.
|
|
319
|
+
if (what === 'unlock' && !(await confirm(`Unlock ${at}, so commands and the agent can change it again?`)))
|
|
320
|
+
fail(`${at} stays locked; unlocking needs your yes in a terminal, or the lock on the canvas`);
|
|
321
|
+
const done = setLock(source, at, what === 'lock');
|
|
322
|
+
if (done.error !== undefined)
|
|
323
|
+
fail(done.error);
|
|
324
|
+
console.log(`${what === 'lock' ? 'LOCKED' : 'UNLOCKED'} ${at}`);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
if ((what !== 'set' && what !== 'unset') || !at || (what === 'set' && !rest.length))
|
|
328
|
+
fail('usage: koppler stack get [<path>] | set <path> <value> | unset <path> | lock <path> | unlock <path> | locks | check [<file>] | schema | propose');
|
|
329
|
+
const raw = rest.join(' ');
|
|
330
|
+
// Typed in a terminal, 5001 may be a port or a name: whichever keeps the stack valid, JSON first.
|
|
331
|
+
const values = (() => {
|
|
332
|
+
if (what === 'unset')
|
|
333
|
+
return undefined;
|
|
334
|
+
try {
|
|
335
|
+
return [JSON.parse(raw), raw];
|
|
336
|
+
}
|
|
337
|
+
catch {
|
|
338
|
+
return [raw];
|
|
339
|
+
}
|
|
340
|
+
})();
|
|
341
|
+
const changed = editStack(at, values);
|
|
342
|
+
if (changed.error !== undefined)
|
|
343
|
+
fail(changed.error);
|
|
344
|
+
console.log(what === 'unset' ? `UNSET ${at}` : `SET ${at} = ${JSON.stringify(changed.value)}`);
|
|
345
|
+
sync([]);
|
|
346
|
+
}
|
|
347
|
+
function remove(args) {
|
|
348
|
+
const removed = removeApp(positional(args)[0] ?? fail('usage: koppler remove <app>'));
|
|
349
|
+
if (removed.error !== undefined)
|
|
350
|
+
fail(removed.error);
|
|
351
|
+
console.log(`Removed ${removed.name} from the stack (its clone stays).`);
|
|
352
|
+
sync([]);
|
|
353
|
+
}
|
|
354
|
+
/** Koppler's version: the package's, or the commit when it runs from a clone. */
|
|
355
|
+
function version() {
|
|
356
|
+
let packaged;
|
|
357
|
+
try {
|
|
358
|
+
packaged = JSON.parse(fs.readFileSync(path.join(APP, 'package.json'), 'utf8')).version;
|
|
359
|
+
}
|
|
360
|
+
catch {
|
|
361
|
+
// An unreadable package file: the commit alone says which Koppler this is.
|
|
362
|
+
}
|
|
363
|
+
const commit = sh('git', ['-C', CODE, 'rev-parse', '--short', 'HEAD']);
|
|
364
|
+
return [packaged, commit && `(${commit})`].filter(Boolean).join(' ') || 'unknown';
|
|
365
|
+
}
|
|
366
|
+
/** Every command: what it takes, what it does, and one example. People's commands come first in the help. */
|
|
367
|
+
const COMMANDS = [
|
|
368
|
+
{
|
|
369
|
+
name: 'init',
|
|
370
|
+
who: 'you',
|
|
371
|
+
args: '[--new "<name>" --envs qa,prod] [--root <dir>]... [--stack <dir>] [--pick] [--no-fetch]',
|
|
372
|
+
about: 'find your repos and the stack, or start a new stack',
|
|
373
|
+
run: init,
|
|
374
|
+
example: 'koppler init --new "Shop" --envs qa,prod',
|
|
375
|
+
},
|
|
376
|
+
{
|
|
377
|
+
name: 'canvas',
|
|
378
|
+
who: 'you',
|
|
379
|
+
args: '[start] [--board "<name>"] [--no-open] [--new-address] | stop',
|
|
380
|
+
about: 'start the canvas if needed and open it, on a board when one is named; stop stops it',
|
|
381
|
+
run: canvas,
|
|
382
|
+
example: 'koppler canvas stop && koppler canvas start',
|
|
383
|
+
forms: [
|
|
384
|
+
['[start] [--board "<name>"] [--no-open]', 'start it if needed and open it in the browser, on that board'],
|
|
385
|
+
['--new-address', 'give the canvas a new address: every address given out before stops working'],
|
|
386
|
+
['stop', 'stop the canvas; apps and tunnels keep running'],
|
|
387
|
+
],
|
|
388
|
+
},
|
|
389
|
+
{
|
|
390
|
+
name: 'use',
|
|
391
|
+
who: 'you',
|
|
392
|
+
args: '[stack] [--boards] [--json]',
|
|
393
|
+
about: 'list the stacks on this Mac (* is in use), switch to one, or list its boards',
|
|
394
|
+
run: use,
|
|
395
|
+
example: 'koppler use "Shop"',
|
|
396
|
+
},
|
|
397
|
+
{
|
|
398
|
+
name: 'link',
|
|
399
|
+
who: 'you',
|
|
400
|
+
args: '[<GitHub link to a stack.json> | update | discard | pr]',
|
|
401
|
+
about: 'use a stack shared on GitHub; then bring in its changes, drop yours, or propose yours',
|
|
402
|
+
run: link,
|
|
403
|
+
example: 'koppler link https://github.com/acme/stacks/blob/main/shop/stack.json',
|
|
404
|
+
forms: [
|
|
405
|
+
['<GitHub link to a stack.json>', 'use that stack: a local copy you can change'],
|
|
406
|
+
['', 'where the copy came from, and your changes not in the shared stack yet'],
|
|
407
|
+
['update', "bring in the shared stack's changes"],
|
|
408
|
+
['discard', 'drop your changes; the copy matches the shared stack again'],
|
|
409
|
+
['pr', 'open a pull request with your changes'],
|
|
410
|
+
],
|
|
411
|
+
},
|
|
412
|
+
{
|
|
413
|
+
name: 'doctor',
|
|
414
|
+
who: 'you',
|
|
415
|
+
args: '[app...] [--env <env>] [--no-tunnels <app,...>] [--secrets] [--flow] [--approve] [--json]',
|
|
416
|
+
about: 'can it run here: tools, what you still have to do, secrets, connections',
|
|
417
|
+
run: doctor,
|
|
418
|
+
example: 'koppler doctor orders-api --env prod',
|
|
419
|
+
},
|
|
420
|
+
{
|
|
421
|
+
name: 'secret',
|
|
422
|
+
who: 'you',
|
|
423
|
+
args: 'set|list|remove|psql <app>[@<env>] [<NAME>] [--stdin] [--pgpass]',
|
|
424
|
+
about: 'store and manage secrets; values never show',
|
|
425
|
+
run: secret,
|
|
426
|
+
example: 'koppler secret set orders-api@prod DB_URL',
|
|
427
|
+
forms: [
|
|
428
|
+
['set <app>[@<env>] <NAME> [--stdin]', 'store a secret, typed hidden or read from stdin'],
|
|
429
|
+
['list <app>[@<env>]', 'which secrets are stored, never their values'],
|
|
430
|
+
['remove <app>[@<env>] <NAME>', 'delete a stored secret'],
|
|
431
|
+
[
|
|
432
|
+
'psql <app>[@<env>] <NAME> [--pgpass]',
|
|
433
|
+
"open psql with the app's stored connection; --pgpass writes a pgpass file for another client",
|
|
434
|
+
],
|
|
435
|
+
],
|
|
436
|
+
},
|
|
437
|
+
{
|
|
438
|
+
name: 'status',
|
|
439
|
+
who: 'you',
|
|
440
|
+
args: '[--apps] [--debug [name...]] [--json] | step <app> "<label>" done|todo|user|failed [--detail "<what>"] [--copy "<command>"] [--port <n>] [--log <file>] | step <app> --clear',
|
|
441
|
+
forms: [
|
|
442
|
+
[
|
|
443
|
+
'[--apps] [--debug [name...]] [--json]',
|
|
444
|
+
"what Koppler runs; --apps adds the stack's apps; --debug gives scrubbed logs for a bug report",
|
|
445
|
+
],
|
|
446
|
+
[
|
|
447
|
+
'step <app> "<label>" done|todo|user|failed [--detail "<what>"] [--copy "<command>"] [--port <n>] [--log <file>]',
|
|
448
|
+
"set one step of an app's checklist on its card (the agent does this)",
|
|
449
|
+
],
|
|
450
|
+
['step <app> --clear', "take the agent's steps off an app's card"],
|
|
451
|
+
],
|
|
452
|
+
about: "what Koppler runs; --apps adds the stack's apps; --debug gives scrubbed logs for a bug report",
|
|
453
|
+
run: args => {
|
|
454
|
+
if (args[0] === 'step')
|
|
455
|
+
stepCommand(args.slice(1));
|
|
456
|
+
else
|
|
457
|
+
return status(args);
|
|
458
|
+
},
|
|
459
|
+
example: 'koppler status --apps',
|
|
460
|
+
},
|
|
461
|
+
{
|
|
462
|
+
name: 'logs',
|
|
463
|
+
who: 'you',
|
|
464
|
+
args: '<name> [-f] [-n <lines>] [--run <id>]',
|
|
465
|
+
about: 'show or follow a log, secrets masked; --run shows the one a past run kept',
|
|
466
|
+
run: logs,
|
|
467
|
+
example: 'koppler logs orders-api -f',
|
|
468
|
+
},
|
|
469
|
+
{
|
|
470
|
+
name: 'stop',
|
|
471
|
+
who: 'you',
|
|
472
|
+
args: '<name...> | --all [--yes]',
|
|
473
|
+
forms: [
|
|
474
|
+
['<name...>', 'stop these processes (koppler status lists them)'],
|
|
475
|
+
['--all [--yes]', 'stop every app and tunnel, after asking; not the canvas'],
|
|
476
|
+
],
|
|
477
|
+
about: 'stop by name, or --all every app and tunnel; changes stay (down reverts them, canvas stop stops the canvas)',
|
|
478
|
+
run: stop,
|
|
479
|
+
example: 'koppler stop orders-api',
|
|
480
|
+
},
|
|
481
|
+
{
|
|
482
|
+
name: 'history',
|
|
483
|
+
who: 'you',
|
|
484
|
+
args: '[--json] | session | done [<id>] [--failed "<why>"] | pin|unpin <id>',
|
|
485
|
+
about: 'recent runs and Stops; session gathers what happened since the last Stop; done marks a run finished',
|
|
486
|
+
forms: [
|
|
487
|
+
['[--json]', 'recent runs and Stops and how each ended'],
|
|
488
|
+
['session', 'gather what happened since the last Stop, for a bug report'],
|
|
489
|
+
['done [<id>] [--failed "<why>"]', 'mark a run finished, or failed and why (the agent does this)'],
|
|
490
|
+
['pin|unpin <id>', "keep a run's app logs, which otherwise go with the oldest after 20 runs"],
|
|
491
|
+
],
|
|
492
|
+
run: history,
|
|
493
|
+
example: 'koppler history',
|
|
494
|
+
},
|
|
495
|
+
{
|
|
496
|
+
name: 'reload',
|
|
497
|
+
who: 'you',
|
|
498
|
+
args: '<app> [--restart]',
|
|
499
|
+
about: 'hot reload a Flutter app Koppler runs; --restart hot restarts it',
|
|
500
|
+
run: reloadCommand,
|
|
501
|
+
example: 'koppler reload mobile-app',
|
|
502
|
+
},
|
|
503
|
+
{
|
|
504
|
+
name: 'shell',
|
|
505
|
+
who: 'you',
|
|
506
|
+
args: '<app> [--env <env>] [--query]',
|
|
507
|
+
about: "a shell in an app's clone with its run's settings, never its secrets; --query opens psql on its database",
|
|
508
|
+
run: shellCommand,
|
|
509
|
+
example: 'koppler shell orders-api',
|
|
510
|
+
},
|
|
511
|
+
{
|
|
512
|
+
name: 'traces',
|
|
513
|
+
who: 'you',
|
|
514
|
+
args: 'start|stop|open [<app>]',
|
|
515
|
+
about: 'collect traces from the apps Koppler starts and follow one request across them (Jaeger in Docker)',
|
|
516
|
+
run: tracesCommand,
|
|
517
|
+
example: 'koppler traces open orders-api',
|
|
518
|
+
},
|
|
519
|
+
{
|
|
520
|
+
name: 'query',
|
|
521
|
+
who: 'you',
|
|
522
|
+
args: '<app> [--env <env>] [--secret <NAME>] "<sql>"',
|
|
523
|
+
about: "read-only query with the app's stored connection (on PROD: in your own terminal)",
|
|
524
|
+
run: queryCommand,
|
|
525
|
+
example: 'koppler query orders-api "select count(*) from orders"',
|
|
526
|
+
},
|
|
527
|
+
{
|
|
528
|
+
name: 'refresh',
|
|
529
|
+
who: 'agent',
|
|
530
|
+
args: '[--no-sync]',
|
|
531
|
+
about: 'after changes made outside the canvas: read the stack and clones again, and have an open canvas show them',
|
|
532
|
+
run: refresh,
|
|
533
|
+
example: 'koppler refresh',
|
|
534
|
+
},
|
|
535
|
+
{
|
|
536
|
+
name: 'sync',
|
|
537
|
+
who: 'agent',
|
|
538
|
+
args: '[--stack <dir>] [--rescan]',
|
|
539
|
+
about: 'reload the stack and your clones',
|
|
540
|
+
run: sync,
|
|
541
|
+
example: 'koppler sync --rescan',
|
|
542
|
+
},
|
|
543
|
+
{
|
|
544
|
+
name: 'discover',
|
|
545
|
+
who: 'agent',
|
|
546
|
+
args: '[app...] [--deployed] [--apply] [--find "<name,...>"] [--no-fetch] [--no-github]',
|
|
547
|
+
about: 'what the repos say: ports, tunnels, calls, deployed addresses, gaps',
|
|
548
|
+
run: discover,
|
|
549
|
+
example: 'koppler discover --deployed',
|
|
550
|
+
},
|
|
551
|
+
{
|
|
552
|
+
name: 'add',
|
|
553
|
+
who: 'agent',
|
|
554
|
+
args: '<repo or folder>... | --outside "<name>" [--called-by <app,...>] [--sends-to <app,...>] [--url <address>] [--why "<why>"]',
|
|
555
|
+
forms: [
|
|
556
|
+
['<repo or folder>...', 'add apps to the stack from their clones'],
|
|
557
|
+
[
|
|
558
|
+
'--outside "<name>" [--called-by <app,...>] [--sends-to <app,...>] [--url <address>] [--why "<why>"]',
|
|
559
|
+
'add a system that never runs here: called by the apps, or sending to them (a webhook)',
|
|
560
|
+
],
|
|
561
|
+
],
|
|
562
|
+
about: 'add apps from their clones, or an outside system they call',
|
|
563
|
+
run: add,
|
|
564
|
+
example: 'koppler add acme/orders-api',
|
|
565
|
+
},
|
|
566
|
+
{
|
|
567
|
+
name: 'remove',
|
|
568
|
+
who: 'agent',
|
|
569
|
+
args: '<app>',
|
|
570
|
+
about: 'take an app out of the stack (its clone stays)',
|
|
571
|
+
run: remove,
|
|
572
|
+
example: 'koppler remove orders-api',
|
|
573
|
+
},
|
|
574
|
+
{
|
|
575
|
+
name: 'plan',
|
|
576
|
+
who: 'agent',
|
|
577
|
+
args: '[app...] [--env <env>] [--wire <caller> <callee> [--device ios|android|phone|<id>]] [--json]',
|
|
578
|
+
about: 'what the newest run does; the tunnels apps need; what one cable changes',
|
|
579
|
+
run: plan,
|
|
580
|
+
example: 'koppler plan orders-api payments-api --env qa',
|
|
581
|
+
},
|
|
582
|
+
{
|
|
583
|
+
name: 'up',
|
|
584
|
+
who: 'agent',
|
|
585
|
+
args: '<app...> [--cable <caller>:<callee>]... [--env <env>] | --board "<name>" [--env <env>] | --run <id>',
|
|
586
|
+
about: 'start a run, recorded like Run on the canvas: tunnels, local queues, then apps, callees first',
|
|
587
|
+
run: up,
|
|
588
|
+
example: 'koppler up orders-api payments-api --cable orders-api:payments-api --env qa',
|
|
589
|
+
},
|
|
590
|
+
{
|
|
591
|
+
name: 'wait',
|
|
592
|
+
who: 'agent',
|
|
593
|
+
args: '<app...> --healthy|--up|--down [--timeout <seconds>] [--json]',
|
|
594
|
+
about: 'wait until apps are healthy, up or down; fails on a timeout or an app that exits, naming each state',
|
|
595
|
+
run: wait,
|
|
596
|
+
example: 'koppler wait orders-api payments-api --healthy --timeout 180',
|
|
597
|
+
},
|
|
598
|
+
{
|
|
599
|
+
name: 'do',
|
|
600
|
+
who: 'agent',
|
|
601
|
+
args: '<app> <command> [--arg <name>=<value>]... [--env <env>] [--yes]',
|
|
602
|
+
about: "run one of an app's commands from the stack, such as seed or migrate, in its clone with its secrets",
|
|
603
|
+
run: doCommand,
|
|
604
|
+
example: 'koppler do orders-api seed --arg COUNT=50 --yes',
|
|
605
|
+
},
|
|
606
|
+
{
|
|
607
|
+
name: 'watch',
|
|
608
|
+
who: 'agent',
|
|
609
|
+
args: '<app>',
|
|
610
|
+
about: 'restart an app when a file in its clone changes (a run starts this for a card set to Restart on change)',
|
|
611
|
+
run: watchCommand,
|
|
612
|
+
example: 'koppler watch orders-api',
|
|
613
|
+
},
|
|
614
|
+
{
|
|
615
|
+
name: 'down',
|
|
616
|
+
who: 'agent',
|
|
617
|
+
args: '[--run <stop id>]',
|
|
618
|
+
about: 'stop and revert: stop what Koppler runs, undo every change, branches back',
|
|
619
|
+
run: down,
|
|
620
|
+
example: 'koppler down',
|
|
621
|
+
},
|
|
622
|
+
{
|
|
623
|
+
name: 'run',
|
|
624
|
+
who: 'agent',
|
|
625
|
+
args: '<app> [--env <env>] [--device ios|android|phone|<id>] [--lan] [--no-test] [--dry-run] [--mock] | <name> [--cwd <dir>] [--port <n>] [--bind <address>] [--ready <url>] [--restart] -- <command...>',
|
|
626
|
+
about: 'start one stack app, or any command, detached and logged',
|
|
627
|
+
run: args => run(args),
|
|
628
|
+
example: 'koppler run orders-api --env qa',
|
|
629
|
+
},
|
|
630
|
+
{
|
|
631
|
+
name: 'switch',
|
|
632
|
+
who: 'agent',
|
|
633
|
+
args: '<app> <branch> [--set-aside | --take-along] | <app> --back',
|
|
634
|
+
about: "switch a clone to the board's branch, and back",
|
|
635
|
+
run: args => {
|
|
636
|
+
switchCommand(args, { temp: tempDir() });
|
|
637
|
+
},
|
|
638
|
+
example: 'koppler switch orders-api feature/refunds --set-aside',
|
|
639
|
+
},
|
|
640
|
+
{
|
|
641
|
+
name: 'recipes',
|
|
642
|
+
who: 'agent',
|
|
643
|
+
args: '[app] | --remember <app> --why "<why>" [--when ...] [--any-os] [--private] | --apply <app...> [--env <env>]',
|
|
644
|
+
about: 'remembered code changes: list, keep the last edit, apply the matching ones',
|
|
645
|
+
run: recipes,
|
|
646
|
+
example: 'koppler recipes orders-api',
|
|
647
|
+
},
|
|
648
|
+
{
|
|
649
|
+
name: 'listen',
|
|
650
|
+
who: 'agent',
|
|
651
|
+
args: 'take | as <id> | stop',
|
|
652
|
+
forms: [
|
|
653
|
+
['take', 'connect this session, taking over from any other; prints its id'],
|
|
654
|
+
['as <id>', 'listen again as that session'],
|
|
655
|
+
['stop', 'disconnect whichever session listens'],
|
|
656
|
+
],
|
|
657
|
+
about: 'wait for requests from the canvas (one agent session at a time)',
|
|
658
|
+
run: listen,
|
|
659
|
+
example: 'koppler listen take',
|
|
660
|
+
},
|
|
661
|
+
{
|
|
662
|
+
name: 'board',
|
|
663
|
+
who: 'agent',
|
|
664
|
+
args: 'new|edit "<name>" [--add <app,...>] [--remove <app,...>] [--cable <caller>:<callee>]... [--uncable <caller>:<callee>]... [--branch <app>=<branch>]... [--note <app>="<note>"]... [--arrange] [--propose]',
|
|
665
|
+
forms: [
|
|
666
|
+
[
|
|
667
|
+
'new "<name>" [--add <app,...>] [--cable <caller>:<callee>]...',
|
|
668
|
+
'make a board of your own, with its apps and cables',
|
|
669
|
+
],
|
|
670
|
+
[
|
|
671
|
+
'edit "<name>" [--add <app,...>] [--remove <app,...>]',
|
|
672
|
+
'put apps on a board or take them off; the cards already there keep their places',
|
|
673
|
+
],
|
|
674
|
+
['edit "<name>" [--cable <caller>:<callee>]... [--uncable <caller>:<callee>]...', 'add or take off cables'],
|
|
675
|
+
['edit "<name>" [--branch <app>=<branch>]... [--note <app>="<note>"]...', "set an app's branch or note"],
|
|
676
|
+
['new|edit "<name>" ... --arrange', 'lay the board out afresh, as Arrange on the canvas does'],
|
|
677
|
+
[
|
|
678
|
+
'new|edit "<name>" ... --propose',
|
|
679
|
+
'ask the user first, in the canvas inbox; nothing changes until they say yes',
|
|
680
|
+
],
|
|
681
|
+
['share "<name>"', "propose one of your boards to the team: a pull request on the stack's repo on GitHub"],
|
|
682
|
+
[
|
|
683
|
+
'open <file or link> [--no-open]',
|
|
684
|
+
'open a board from a file or a GitHub link: it joins your boards and the canvas shows it; Reload reads it again',
|
|
685
|
+
],
|
|
686
|
+
],
|
|
687
|
+
about: 'make, change or share a board, even one open on the canvas, which shows the change',
|
|
688
|
+
run: boardCommand,
|
|
689
|
+
example: 'koppler board edit "Checkout" --add orders-api --cable web:orders-api',
|
|
690
|
+
},
|
|
691
|
+
{
|
|
692
|
+
name: 'stack',
|
|
693
|
+
who: 'agent',
|
|
694
|
+
args: 'get [<path>] | set <path> <value> | unset <path> | lock <path> | unlock <path> | locks | check [<file>] | schema | propose',
|
|
695
|
+
forms: [
|
|
696
|
+
['get [<path>]', 'print a field of the stack in use, or the whole stack, as JSON'],
|
|
697
|
+
['set <path> <value>', 'set a field: text, or JSON for a number, a list or an object; checked before it is kept'],
|
|
698
|
+
['unset <path>', 'take a field out (an item of a list closes up)'],
|
|
699
|
+
['lock <path>', 'keep a field as it is: no command, canvas edit or agent changes it'],
|
|
700
|
+
['unlock <path>', 'let it change again; asks you in the terminal, so an agent cannot'],
|
|
701
|
+
['locks', 'list the locked fields'],
|
|
702
|
+
['check [<file>]', "every check the stack must pass, for the team repo's CI; fails with each problem"],
|
|
703
|
+
['schema', "the JSON Schema of stack.json, for an editor's $schema"],
|
|
704
|
+
['propose', "a pull request on the stack's repo with what this copy changed since its last commit"],
|
|
705
|
+
],
|
|
706
|
+
about: 'read and change any field of the stack, the only way to change stack.json',
|
|
707
|
+
run: stackCommand,
|
|
708
|
+
example: 'koppler stack set apps.orders-api.ready \'{"path":"/health"}\'',
|
|
709
|
+
},
|
|
710
|
+
{
|
|
711
|
+
name: 'actions',
|
|
712
|
+
who: 'agent',
|
|
713
|
+
args: "[--json] | add '<json>' | add --file <json file> | clear [--app <app>]",
|
|
714
|
+
forms: [
|
|
715
|
+
['[--json]', 'the changes Stop will undo'],
|
|
716
|
+
[
|
|
717
|
+
"add '<json>' | add --file <json file>",
|
|
718
|
+
'log a change, or a list of them, before making it; checked, and never a secret value',
|
|
719
|
+
],
|
|
720
|
+
['clear [--app <app>]', "empty the log once everything in it is undone, or drop one app's entries"],
|
|
721
|
+
],
|
|
722
|
+
about: 'the action log: every temporary change, which Stop undoes; the only way to write it',
|
|
723
|
+
run: actionsCommand,
|
|
724
|
+
example: `koppler actions add '{"kind":"env-file","app":"orders-api","file":"/full/path/.env.local","lines":["A=1"],"created":true}'`,
|
|
725
|
+
},
|
|
726
|
+
{
|
|
727
|
+
name: 'say',
|
|
728
|
+
who: 'agent',
|
|
729
|
+
args: '[--file <json>] | --answered <id> [--text "<what was answered>"]',
|
|
730
|
+
about: 'show a question or result on the canvas',
|
|
731
|
+
run: sayCommand,
|
|
732
|
+
example: 'koppler say --file reply.json',
|
|
733
|
+
},
|
|
734
|
+
{
|
|
735
|
+
name: 'mcp',
|
|
736
|
+
who: 'agent',
|
|
737
|
+
args: '',
|
|
738
|
+
about: 'serve MCP on stdio: status, logs, doctor, plan, history, wait and say as tools for any agent',
|
|
739
|
+
run: () => mcp(version()),
|
|
740
|
+
example: 'claude mcp add koppler -- koppler mcp',
|
|
741
|
+
},
|
|
742
|
+
{
|
|
743
|
+
name: 'agent',
|
|
744
|
+
who: 'agent',
|
|
745
|
+
args: 'init [--agent claude,copilot,vscode,opencode] [--dry-run]',
|
|
746
|
+
about: 'link the skill into each agent on this Mac and register koppler mcp with it',
|
|
747
|
+
run: agentCommand,
|
|
748
|
+
example: 'koppler agent init',
|
|
749
|
+
},
|
|
750
|
+
];
|
|
751
|
+
const named = (name) => COMMANDS.find(command => command.name === name);
|
|
752
|
+
function help(command) {
|
|
753
|
+
if (command) {
|
|
754
|
+
const about = command.about.charAt(0).toUpperCase() + command.about.slice(1);
|
|
755
|
+
const width = Math.max(0, ...(command.forms ?? []).map(([form]) => form.length)) + 2;
|
|
756
|
+
const forms = (command.forms ?? []).map(([form, what]) => ` koppler ${command.name} ${form.padEnd(width)}${what}`);
|
|
757
|
+
const usage = forms.length ? `usage:\n${forms.join('\n')}` : `usage: koppler ${command.name} ${command.args}`;
|
|
758
|
+
console.log(`${usage}\n\n${about}.\n\nExample:\n ${command.example}`);
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
const width = Math.max(...COMMANDS.map(one => one.name.length)) + 2;
|
|
762
|
+
const section = (title, who) => [title, ...COMMANDS.filter(one => one.who === who).map(one => ` ${one.name.padEnd(width)}${one.about}`)].join('\n');
|
|
763
|
+
const lines = [
|
|
764
|
+
'Koppler runs the part of your stack you need on this Mac, wired together; the rest keeps calling a deployed environment.',
|
|
765
|
+
'',
|
|
766
|
+
'usage: koppler <command> [options]',
|
|
767
|
+
'',
|
|
768
|
+
section('For you', 'you'),
|
|
769
|
+
'',
|
|
770
|
+
section('For the agent', 'agent'),
|
|
771
|
+
'',
|
|
772
|
+
'koppler help <command>, or koppler <command> --help, shows its options and an example. koppler --version prints the version.',
|
|
773
|
+
'',
|
|
774
|
+
'Environment: KOPPLER_STATE (state folder, default ~/.local/state/koppler), KOPPLER_CONFIG_DIR (default ~/.config/koppler),',
|
|
775
|
+
' KOPPLER_PORT (canvas, default 4747), KOPPLER_SECRETS (a folder for secrets instead of the Keychain),',
|
|
776
|
+
' KOPPLER_KEYCHAIN (a keychain file of its own), KOPPLER_TEMP and KOPPLER_LOGS (run state and logs, default inside KOPPLER_STATE).',
|
|
777
|
+
];
|
|
778
|
+
console.log(lines.join('\n'));
|
|
779
|
+
}
|
|
780
|
+
/** How many single-letter edits turn one word into the other. */
|
|
781
|
+
function distance(a, b) {
|
|
782
|
+
let row = Array.from({ length: b.length + 1 }, (_, at) => at);
|
|
783
|
+
for (let i = 1; i <= a.length; i++) {
|
|
784
|
+
const next = [i];
|
|
785
|
+
for (let j = 1; j <= b.length; j++) {
|
|
786
|
+
const swap = (row[j - 1] ?? 0) + (a[i - 1] === b[j - 1] ? 0 : 1);
|
|
787
|
+
next.push(Math.min((row[j] ?? 0) + 1, (next[j - 1] ?? 0) + 1, swap));
|
|
788
|
+
}
|
|
789
|
+
row = next;
|
|
790
|
+
}
|
|
791
|
+
return row[b.length] ?? 0;
|
|
792
|
+
}
|
|
793
|
+
/** The closest command to a typo, if one is close enough. */
|
|
794
|
+
function closest(word) {
|
|
795
|
+
const [best] = COMMANDS.map(command => ({ name: command.name, off: distance(word, command.name) })).sort((a, b) => a.off - b.off);
|
|
796
|
+
if (best && best.off <= 2)
|
|
797
|
+
return best.name;
|
|
798
|
+
return COMMANDS.find(command => command.name.startsWith(word))?.name ?? null;
|
|
799
|
+
}
|
|
800
|
+
/** Runs what was asked for. The help, the version and a finished command return; anything else is thrown. */
|
|
801
|
+
async function main([name, ...args]) {
|
|
802
|
+
const own = args.includes('--') ? args.slice(0, args.indexOf('--')) : args;
|
|
803
|
+
if (!name || name === 'help' || name === '--help' || name === '-h') {
|
|
804
|
+
if (name === 'help' && args[0] && !named(args[0]))
|
|
805
|
+
fail(`usage: no command "${args[0]}"; koppler --help lists them`);
|
|
806
|
+
help(named(args[0]));
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
if (name === '--version' || name === '-V' || name === 'version') {
|
|
810
|
+
console.log(`koppler ${version()}`);
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
const command = named(name);
|
|
814
|
+
if (!command) {
|
|
815
|
+
const near = closest(name);
|
|
816
|
+
throw new Failure(`unknown command "${name}".${near ? ` Did you mean ${near}?` : ''}\nkoppler --help lists the commands.`, 2);
|
|
817
|
+
}
|
|
818
|
+
if (own.includes('--help') || own.includes('-h'))
|
|
819
|
+
help(command);
|
|
820
|
+
else
|
|
821
|
+
await command.run(args);
|
|
822
|
+
}
|
|
823
|
+
if (process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
824
|
+
// A failure says what went wrong in one line; KOPPLER_DEBUG=1 adds where, for a bug report.
|
|
825
|
+
try {
|
|
826
|
+
await main(process.argv.slice(2));
|
|
827
|
+
}
|
|
828
|
+
catch (error) {
|
|
829
|
+
const { message, stack } = error;
|
|
830
|
+
// A failure the command raised itself has said everything when its message is empty.
|
|
831
|
+
if (!(error instanceof Failure) || message)
|
|
832
|
+
console.error(`koppler: ${message ?? String(error)}`);
|
|
833
|
+
if (error instanceof Failure)
|
|
834
|
+
process.exit(error.code);
|
|
835
|
+
if (process.env['KOPPLER_DEBUG'])
|
|
836
|
+
console.error(stack);
|
|
837
|
+
process.exit(1);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
//# sourceMappingURL=koppler.mjs.map
|