@loicngr/kobo 2.1.15 → 2.1.18

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.
Files changed (128) hide show
  1. package/AGENTS.md +61 -0
  2. package/CHANGELOG.md +12 -0
  3. package/CONFIGURATION.md +74 -17
  4. package/README.md +7 -3
  5. package/dist/mcp-server/kobo-tasks-server.js +19 -104
  6. package/dist/mcp-server/workspace-dialogue-client.js +15 -3
  7. package/dist/server/db/migrations.js +2 -0
  8. package/dist/server/db/schema.js +2 -0
  9. package/dist/server/db/workspace-message-batch-schema.js +9 -0
  10. package/dist/server/index.js +8 -0
  11. package/dist/server/middleware/workspace-creation-maintenance.js +13 -0
  12. package/dist/server/routes/maintenance.js +32 -0
  13. package/dist/server/routes/mcp.js +69 -3
  14. package/dist/server/routes/pull-requests.js +2 -1
  15. package/dist/server/routes/workspace-messages.js +22 -0
  16. package/dist/server/routes/workspaces.js +60 -4
  17. package/dist/server/services/agent/orchestrator.js +21 -6
  18. package/dist/server/services/agent/session-controller.js +4 -1
  19. package/dist/server/services/cron-service.js +7 -0
  20. package/dist/server/services/pr-checkout-rollback-service.js +51 -0
  21. package/dist/server/services/pr-checkout-service.js +21 -1
  22. package/dist/server/services/quota-backoff-service.js +7 -0
  23. package/dist/server/services/update-maintenance-service.js +44 -0
  24. package/dist/server/services/wakeup-service.js +21 -3
  25. package/dist/server/services/workspace-creation-mcp-service.js +214 -0
  26. package/dist/server/services/workspace-group-message-mcp-service.js +58 -0
  27. package/dist/server/services/workspace-group-message-service.js +182 -0
  28. package/dist/server/services/workspace-lifecycle-mcp-service.js +72 -0
  29. package/dist/server/services/workspace-message-service.js +5 -0
  30. package/dist/shared/pr-checkout-steps.js +86 -0
  31. package/dist/shared/workspace-creation-tools.js +182 -0
  32. package/dist/shared/workspace-group-message-tools.js +134 -0
  33. package/dist/shared/workspace-group-messages.js +27 -0
  34. package/dist/shared/workspace-lifecycle-tools.js +112 -0
  35. package/docker-compose.vps.yml +60 -0
  36. package/docs/deployment-vps.md +506 -0
  37. package/docs/getting-started.md +9 -0
  38. package/package.json +8 -1
  39. package/scripts/kobo-gh +9 -0
  40. package/scripts/kobo-github-app-token +190 -0
  41. package/scripts/kobo-install +392 -0
  42. package/scripts/kobo-update +499 -0
  43. package/src/client/dist/pwa/assets/ActivityFeed-S3XGw3WW.js +2 -0
  44. package/src/client/dist/pwa/assets/{AutoLoopFinalReviewSettings-lKjfaC6V.js → AutoLoopFinalReviewSettings-CC8ICe_I.js} +1 -1
  45. package/src/client/dist/pwa/assets/{ChangelogPage-BplM7qEB.js → ChangelogPage-C1aiPBoi.js} +1 -1
  46. package/src/client/dist/pwa/assets/{ComparisonPanel-DxK5v9w_.js → ComparisonPanel-DElbn4ac.js} +1 -1
  47. package/src/client/dist/pwa/assets/CreatePage-Bx58t4YW.js +2 -0
  48. package/src/client/dist/pwa/assets/{DashboardPage-BsP-MATS.js → DashboardPage-D1S3QGp4.js} +1 -1
  49. package/src/client/dist/pwa/assets/DiffViewer-BH4X9ru1.js +8 -0
  50. package/src/client/dist/pwa/assets/{DocumentsPanel-CGrf8Q3H.js → DocumentsPanel-C4AvyZF3.js} +1 -1
  51. package/src/client/dist/pwa/assets/{DrawerToggleButton-BANeLSsR.js → DrawerToggleButton-LzIEeeJY.js} +1 -1
  52. package/src/client/dist/pwa/assets/GitPanel-BP3N_4nh.js +3 -0
  53. package/src/client/dist/pwa/assets/{HealthPage-BVTNx-92.js → HealthPage-Cm13vxOi.js} +1 -1
  54. package/src/client/dist/pwa/assets/MainLayout-DGmMkfuw.js +2 -0
  55. package/src/client/dist/pwa/assets/{MainLayout-C6nvIQjU.css → MainLayout-DoAEgANU.css} +1 -1
  56. package/src/client/dist/pwa/assets/{MemoryPanel-C71rTpv9.js → MemoryPanel-C6tzCv6D.js} +1 -1
  57. package/src/client/dist/pwa/assets/{MemoryProposalList-CoqhUGrv.js → MemoryProposalList-CzpzIirK.js} +1 -1
  58. package/src/client/dist/pwa/assets/{QBtnDropdown-U9Ey5W8h.js → QBtnDropdown-BnuKPevV.js} +1 -1
  59. package/src/client/dist/pwa/assets/{QExpansionItem-Be_A1MT1.js → QExpansionItem-DjaUhgjt.js} +1 -1
  60. package/src/client/dist/pwa/assets/{QScrollArea-D4lYzAPQ.js → QScrollArea-Dpksslru.js} +1 -1
  61. package/src/client/dist/pwa/assets/QSelect-Dh4CjliE.js +1 -0
  62. package/src/client/dist/pwa/assets/{QTooltip-kStOgY0X.js → QTooltip-CH45NuVs.js} +1 -1
  63. package/src/client/dist/pwa/assets/{SchedulePanel-C7nTGdWE.js → SchedulePanel-QAjXuTIN.js} +1 -1
  64. package/src/client/dist/pwa/assets/{SearchPage-C6J72pkQ.js → SearchPage-B1pbwDej.js} +1 -1
  65. package/src/client/dist/pwa/assets/SettingsPage-CUQefsDh.js +9 -0
  66. package/src/client/dist/pwa/assets/{SplitWorkspacePage-Bqn4gdVw.js → SplitWorkspacePage-CJ7e-KPh.js} +1 -1
  67. package/src/client/dist/pwa/assets/{TerminalPanel-zLeDCReI.js → TerminalPanel-DVomz3oo.js} +10 -10
  68. package/src/client/dist/pwa/assets/{ToolCallItem-B8b_Sagj.js → ToolCallItem-BntDHc6p.js} +3 -3
  69. package/src/client/dist/pwa/assets/{TouchPan-Bl8VzIDK.js → TouchPan-DwqfsAxH.js} +1 -1
  70. package/src/client/dist/pwa/assets/{TourReplayButton-pL0HCboU.js → TourReplayButton-OIavXdUg.js} +1 -1
  71. package/src/client/dist/pwa/assets/{WorkspaceAttentionLabels-9Eomx0rQ.js → WorkspaceAttentionLabels-CYwN1-Rf.js} +1 -1
  72. package/src/client/dist/pwa/assets/WorkspacePage-CAsY7Hl-.js +4 -0
  73. package/src/client/dist/pwa/assets/{browser-C-PJ4alw.js → browser-Cl0d1gbu.js} +1 -1
  74. package/src/client/dist/pwa/assets/{build-path-tree-SW9rj7B_.js → build-path-tree-J3YQeMuV.js} +1 -1
  75. package/src/client/dist/pwa/assets/{custom-sounds-toz-4drX.js → custom-sounds-D-T5rpgv.js} +1 -1
  76. package/src/client/dist/pwa/assets/de-C-RtXLqL.js +17 -0
  77. package/src/client/dist/pwa/assets/{editor.api-BJTPhnh8.js → editor.api-BoFiCjBZ.js} +1 -1
  78. package/src/client/dist/pwa/assets/{efforts-BO9xGn9q.js → efforts-Y6XotW97.js} +1 -1
  79. package/src/client/dist/pwa/assets/es-CBlW91ZK.js +17 -0
  80. package/src/client/dist/pwa/assets/{expand-template-BOZMtWau.js → expand-template-sfOHWudd.js} +1 -1
  81. package/src/client/dist/pwa/assets/{format-hEnpRyit.js → format-mWov137C.js} +1 -1
  82. package/src/client/dist/pwa/assets/{formatters-DD_eFc4z.js → formatters-B_XsveM0.js} +1 -1
  83. package/src/client/dist/pwa/assets/fr-Hfzp_sF5.js +17 -0
  84. package/src/client/dist/pwa/assets/{freemarker2-B6nUofvT.js → freemarker2-2pGNakhH.js} +1 -1
  85. package/src/client/dist/pwa/assets/{handlebars-4P3gtM8p.js → handlebars-Bn2V6Liw.js} +1 -1
  86. package/src/client/dist/pwa/assets/{html-C9ZzWo-y.js → html-BBhkJkf2.js} +1 -1
  87. package/src/client/dist/pwa/assets/i18n-sBCWwf8Y.js +1 -0
  88. package/src/client/dist/pwa/assets/index-DCpe-Tfm.js +20 -0
  89. package/src/client/dist/pwa/assets/it-Bl-v27qg.js +17 -0
  90. package/src/client/dist/pwa/assets/{javascript-BGEC5ggd.js → javascript-Ba0G68U2.js} +1 -1
  91. package/src/client/dist/pwa/assets/{liquid-DNtF630O.js → liquid-C5VrzHr_.js} +1 -1
  92. package/src/client/dist/pwa/assets/{mdx-CICwyOd0.js → mdx-DrPRiuKb.js} +1 -1
  93. package/src/client/dist/pwa/assets/{monaco-contributions-BkuwvgrT.js → monaco-contributions-qQUY6VS7.js} +1 -1
  94. package/src/client/dist/pwa/assets/{monaco.contribution-GX8XA-PE.js → monaco.contribution-Dtq2RsyB.js} +2 -2
  95. package/src/client/dist/pwa/assets/network-auth-BPJpXK4L.js +1 -0
  96. package/src/client/dist/pwa/assets/notify-theme-Bl9iRsZB.js +1 -0
  97. package/src/client/dist/pwa/assets/{project-color-B1SMFjSe.js → project-color-Dzvvmaml.js} +1 -1
  98. package/src/client/dist/pwa/assets/{python-n5uPdTVS.js → python-Cuf_7umq.js} +1 -1
  99. package/src/client/dist/pwa/assets/{razor-AOj0nsUt.js → razor-DcVpxFeh.js} +1 -1
  100. package/src/client/dist/pwa/assets/{typescript-Uhkrqihh.js → typescript-Bjcc3kYC.js} +1 -1
  101. package/src/client/dist/pwa/assets/{use-search-index-status-CW0BdpBZ.js → use-search-index-status-Bwf40BEI.js} +1 -1
  102. package/src/client/dist/pwa/assets/use-tours-CWafZt-m.js +2 -0
  103. package/src/client/dist/pwa/assets/{use-worktree-restore-BZxibZS5.js → use-worktree-restore-DkcM2FAZ.js} +1 -1
  104. package/src/client/dist/pwa/assets/{workspace-templates-W557e1-m.js → workspace-templates-DL_Cs7mD.js} +1 -1
  105. package/src/client/dist/pwa/assets/{xml-B-hnMgEs.js → xml-BmJwqJOz.js} +1 -1
  106. package/src/client/dist/pwa/assets/{yaml-DqXkTvgm.js → yaml-DAssfLhS.js} +1 -1
  107. package/src/client/dist/pwa/index.html +1 -1
  108. package/src/client/dist/pwa/sw.js +1 -1
  109. package/src/mcp-server/README.md +130 -12
  110. package/src/mcp-server/kobo-tasks-server.ts +27 -105
  111. package/src/mcp-server/workspace-dialogue-client.ts +18 -3
  112. package/src/client/dist/pwa/assets/ActivityFeed-D7jouiuP.js +0 -2
  113. package/src/client/dist/pwa/assets/CreatePage-D5ilP5kt.js +0 -2
  114. package/src/client/dist/pwa/assets/DiffViewer-Cd15vFWj.js +0 -8
  115. package/src/client/dist/pwa/assets/GitPanel-CEPV5Qw0.js +0 -3
  116. package/src/client/dist/pwa/assets/MainLayout-DBKtwilW.js +0 -2
  117. package/src/client/dist/pwa/assets/QSelect-fS0DLOd1.js +0 -1
  118. package/src/client/dist/pwa/assets/SettingsPage-SuH-aIzv.js +0 -9
  119. package/src/client/dist/pwa/assets/WorkspacePage-B6clgURl.js +0 -4
  120. package/src/client/dist/pwa/assets/de-Ddxe8cc6.js +0 -17
  121. package/src/client/dist/pwa/assets/es-DBJIQeWW.js +0 -17
  122. package/src/client/dist/pwa/assets/fr-yBzIIPaQ.js +0 -17
  123. package/src/client/dist/pwa/assets/i18n-2-ULabk2.js +0 -1
  124. package/src/client/dist/pwa/assets/index-Vk99jtxD.js +0 -20
  125. package/src/client/dist/pwa/assets/it-D44U-PT1.js +0 -17
  126. package/src/client/dist/pwa/assets/network-auth-KnWzjYIc.js +0 -1
  127. package/src/client/dist/pwa/assets/notify-theme-CWuZTz4Z.js +0 -1
  128. package/src/client/dist/pwa/assets/use-tours-CndFlyL-.js +0 -2
@@ -0,0 +1,190 @@
1
+ #!/usr/bin/env python3
2
+ """Print a short-lived GitHub App installation token, or a secret-free --check result."""
3
+ import argparse
4
+ import base64
5
+ from datetime import datetime
6
+ import fcntl
7
+ import hashlib
8
+ import json
9
+ import os
10
+ from pathlib import Path
11
+ import re
12
+ import stat
13
+ import subprocess
14
+ import sys
15
+ import tempfile
16
+ import time
17
+ import urllib.request
18
+
19
+ RENEW_MARGIN = 120
20
+ LOCK_TIMEOUT = 45
21
+ MAX_BYTES = 65536
22
+
23
+
24
+ class TokenError(Exception):
25
+ """An operator-facing failure that never contains credential material."""
26
+
27
+
28
+ class NoRedirect(urllib.request.HTTPRedirectHandler):
29
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
30
+ return None
31
+
32
+
33
+ def read_config(path):
34
+ try:
35
+ data = json.loads(Path(path).read_text())
36
+ app_id = data['app_id']
37
+ installation_id = data['installation_id']
38
+ key = data['private_key']
39
+ if isinstance(app_id, bool) or not re.fullmatch(r'[A-Za-z0-9_.-]{1,100}', str(app_id)):
40
+ raise ValueError()
41
+ if isinstance(installation_id, bool) or not re.fullmatch(r'[1-9][0-9]*', str(installation_id)):
42
+ raise ValueError()
43
+ if not isinstance(key, str) or not Path(key).is_absolute():
44
+ raise ValueError()
45
+ key_bytes = Path(key).read_bytes()
46
+ if not key_bytes or len(key_bytes) > MAX_BYTES:
47
+ raise ValueError()
48
+ config = {'app_id': str(app_id), 'installation_id': str(installation_id), 'private_key': key}
49
+ identity = hashlib.sha256(json.dumps(config, sort_keys=True).encode() + b'\0' + key_bytes).hexdigest()
50
+ return config, identity
51
+ except (OSError, ValueError, KeyError, TypeError):
52
+ raise TokenError('Cannot read GitHub App configuration: check IDs and the absolute RSA key path.') from None
53
+
54
+
55
+ def secure_directory(path):
56
+ path.mkdir(mode=0o700, parents=True, exist_ok=True)
57
+ info = path.lstat()
58
+ if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077:
59
+ raise TokenError('The token cache directory must be owned by this user, private (0700), and not a symlink.')
60
+
61
+
62
+ def private_file(path, flags):
63
+ fd = os.open(path, flags | os.O_NOFOLLOW | os.O_NONBLOCK, 0o600)
64
+ info = os.fstat(fd)
65
+ if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077:
66
+ os.close(fd)
67
+ raise TokenError('Token cache files must be regular private files (0600) owned by this user.')
68
+ return fd
69
+
70
+
71
+ def usable_token(data):
72
+ if not isinstance(data, dict):
73
+ return False
74
+ token, expiry = data.get('token'), data.get('expires_at')
75
+ if not isinstance(token, str) or not re.fullmatch(r'[A-Za-z0-9_.-]{1,8192}', token):
76
+ return False
77
+ try:
78
+ parsed = datetime.fromisoformat(expiry.replace('Z', '+00:00'))
79
+ return parsed.tzinfo is not None and parsed.timestamp() > time.time() + RENEW_MARGIN
80
+ except (AttributeError, TypeError, ValueError, OverflowError):
81
+ return False
82
+
83
+
84
+ def read_cache(path, identity):
85
+ try:
86
+ with os.fdopen(private_file(path, os.O_RDONLY)) as handle:
87
+ data = json.loads(handle.read(MAX_BYTES))
88
+ if data.get('identity') == identity and usable_token(data):
89
+ return data
90
+ except (FileNotFoundError, ValueError, AttributeError):
91
+ pass
92
+ return None
93
+
94
+
95
+ def write_cache(path, data):
96
+ fd, temp = tempfile.mkstemp(prefix='.token-', dir=path.parent)
97
+ try:
98
+ with os.fdopen(fd, 'w') as handle:
99
+ json.dump(data, handle)
100
+ handle.flush()
101
+ os.fsync(handle.fileno())
102
+ os.replace(temp, path)
103
+ finally:
104
+ if os.path.exists(temp):
105
+ os.unlink(temp)
106
+
107
+
108
+ def encode(data):
109
+ return base64.urlsafe_b64encode(data).rstrip(b'=').decode('ascii')
110
+
111
+
112
+ def jwt(config):
113
+ now = int(time.time())
114
+ header = encode(json.dumps({'alg': 'RS256', 'typ': 'JWT'}, separators=(',', ':')).encode())
115
+ payload = encode(json.dumps({'iat': now - 60, 'exp': now + 540, 'iss': config['app_id']},
116
+ separators=(',', ':')).encode())
117
+ unsigned = f'{header}.{payload}'.encode()
118
+ try:
119
+ # Reject EC/encrypted keys instead of sending a mislabeled or interactive JWT.
120
+ subprocess.run(['openssl', 'rsa', '-in', config['private_key'], '-passin', 'pass:',
121
+ '-check', '-noout'], check=True, capture_output=True, timeout=10)
122
+ signature = subprocess.run(['openssl', 'dgst', '-sha256', '-sign', config['private_key'],
123
+ '-passin', 'pass:'], input=unsigned, check=True,
124
+ capture_output=True, timeout=10).stdout
125
+ except (OSError, subprocess.SubprocessError):
126
+ raise TokenError('Cannot sign the App JWT: install OpenSSL and provide an unencrypted RSA private key.') from None
127
+ return f'{header}.{payload}.{encode(signature)}'
128
+
129
+
130
+ def mint(config):
131
+ request = urllib.request.Request(
132
+ f"https://api.github.com/app/installations/{config['installation_id']}/access_tokens",
133
+ data=b'{}', method='POST', headers={
134
+ 'Authorization': f'Bearer {jwt(config)}',
135
+ 'Accept': 'application/vnd.github+json',
136
+ 'Content-Type': 'application/json',
137
+ 'User-Agent': 'kobo-github-app-token',
138
+ 'X-GitHub-Api-Version': '2022-11-28',
139
+ })
140
+ try:
141
+ with urllib.request.build_opener(NoRedirect()).open(request, timeout=20) as response:
142
+ data = json.loads(response.read(MAX_BYTES))
143
+ if not usable_token(data):
144
+ raise ValueError()
145
+ return {'token': data['token'], 'expires_at': data['expires_at']}
146
+ except (OSError, ValueError, TypeError):
147
+ raise TokenError('GitHub App token renewal failed: check network, App installation, IDs and key.') from None
148
+
149
+
150
+ def get_token(config_path, cache_dir):
151
+ config, identity = read_config(config_path)
152
+ cache_dir = Path(cache_dir).expanduser()
153
+ secure_directory(cache_dir)
154
+ with os.fdopen(private_file(cache_dir / 'token.lock', os.O_CREAT | os.O_RDWR), 'w') as lock:
155
+ deadline = time.monotonic() + LOCK_TIMEOUT
156
+ while True:
157
+ try:
158
+ fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
159
+ break
160
+ except BlockingIOError:
161
+ if time.monotonic() >= deadline:
162
+ raise TokenError('Timed out waiting for another GitHub App token renewal.') from None
163
+ time.sleep(0.05)
164
+ cached = read_cache(cache_dir / 'token.json', identity)
165
+ if cached:
166
+ return {key: cached[key] for key in ('token', 'expires_at')}
167
+ token = mint(config)
168
+ write_cache(cache_dir / 'token.json', {**token, 'identity': identity})
169
+ return token
170
+
171
+
172
+ def main(argv=None):
173
+ parser = argparse.ArgumentParser(description=__doc__)
174
+ parser.add_argument('--config', type=Path, default=Path('/etc/kobo/github-app.json'))
175
+ parser.add_argument('--cache-dir', type=Path, default=Path.home() / '.cache/kobo/github-app')
176
+ parser.add_argument('--check', action='store_true', help='Validate authentication without printing the token.')
177
+ args = parser.parse_args(argv)
178
+ try:
179
+ result = get_token(args.config, args.cache_dir)
180
+ print(json.dumps({'status': 'ok', 'expires_at': result['expires_at']}) if args.check else result['token'])
181
+ return 0
182
+ except TokenError as error:
183
+ print(str(error), file=sys.stderr)
184
+ except (OSError, ValueError):
185
+ print('Cannot access the private token configuration or cache. Check paths and permissions.', file=sys.stderr)
186
+ return 1
187
+
188
+
189
+ if __name__ == '__main__':
190
+ sys.exit(main())
@@ -0,0 +1,392 @@
1
+ #!/usr/bin/env python3
2
+ """Install a fresh Kōbō instance behind an existing Traefik. Python 3.10+."""
3
+ import argparse
4
+ import fcntl
5
+ import hashlib
6
+ import importlib.machinery
7
+ import importlib.util
8
+ import json
9
+ import os
10
+ from pathlib import Path
11
+ import re
12
+ import shutil
13
+ import sys
14
+ import uuid
15
+
16
+ sys.dont_write_bytecode = True
17
+ ASSETS = Path(__file__).resolve().parent
18
+ loader = importlib.machinery.SourceFileLoader('kobo_updater', str(ASSETS / 'kobo-update'))
19
+ spec = importlib.util.spec_from_loader(loader.name, loader)
20
+ updater = importlib.util.module_from_spec(spec)
21
+ loader.exec_module(updater)
22
+ InstallError = updater.UpdateError
23
+ TOOLS = ('kobo-install', 'kobo-update', 'kobo-github-app-token', 'kobo-gh')
24
+ ENV_KEYS = updater.COMPOSE_ENV_KEYS
25
+
26
+
27
+ def safe_path(value):
28
+ path = Path(value)
29
+ if not path.is_absolute() or any(re.search(r'[\x00-\x1f\x7f\"\'`$,:\\]', str(p)) for p in (path, path.resolve())):
30
+ raise InstallError('Use absolute paths without control characters, quotes, $, commas, colons or backslashes.')
31
+ return path.resolve()
32
+
33
+
34
+ def validate_config(config):
35
+ domain = config['domain']
36
+ if (len(domain) > 253 or '.' not in domain or
37
+ not all(re.fullmatch(r'[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?', label)
38
+ for label in domain.split('.'))):
39
+ raise InstallError('Domain must be a DNS hostname, without scheme, port or path.')
40
+ for name in ('network', 'entrypoint', 'certresolver'):
41
+ if name == 'certresolver' and config[name] == '':
42
+ continue
43
+ if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_.-]{0,99}', config[name]):
44
+ raise InstallError(f'Invalid {name}; use letters, numbers, dots, underscores or hyphens.')
45
+ projects = safe_path(config['projects_directory'])
46
+ # A bind over the runtime/home/config would hide executables or persistent credentials.
47
+ protected = ('/root', '/app', '/usr', '/bin', '/sbin', '/etc', '/run', '/var', '/proc', '/sys', '/dev')
48
+ if projects == Path('/') or any(projects == Path(p) or Path(p) in projects.parents for p in protected):
49
+ raise InstallError('Projects must be outside container system paths, /app and /root (for example /opt/kobo/projects).')
50
+ if projects.exists() and not projects.is_dir():
51
+ raise InstallError('The projects path must be a directory.')
52
+ app = [config.get(k) for k in ('github_app_id', 'github_installation_id', 'github_private_key')]
53
+ if any(app):
54
+ if not all(app):
55
+ raise InstallError('GitHub App requires all three flags: App ID, installation ID and private key.')
56
+ if not re.fullmatch(r'[A-Za-z0-9_.-]{1,100}', app[0]) or not re.fullmatch(r'[1-9][0-9]*', app[1]):
57
+ raise InstallError('Invalid GitHub App or installation ID.')
58
+ key = safe_path(app[2])
59
+ if not key.is_file() or not os.access(key, os.R_OK) or key.stat().st_mode & 0o077:
60
+ raise InstallError('The GitHub private key must be a readable regular file with private permissions (0600).')
61
+ if config['version'] != 'latest':
62
+ updater.normalize_version(config['version'])
63
+
64
+
65
+ def render(config, stack):
66
+ text = (ASSETS.parent / 'docker-compose.vps.yml').read_text()
67
+ if not config['certresolver']:
68
+ text = '\n'.join(line for line in text.split('\n') if 'tls.certresolver:' not in line)
69
+ if config['docker_socket']:
70
+ text = text.replace(' # - /var/run/docker.sock:', ' - /var/run/docker.sock:')
71
+ if config.get('github_app_id'):
72
+ mounts = ''
73
+ for source, target in (
74
+ (stack / 'bin/kobo-gh', '/usr/local/bin/gh'),
75
+ (stack / 'bin/kobo-github-app-token', '/usr/local/bin/kobo-github-app-token'),
76
+ (stack / 'auth/github-app.json', '/etc/kobo/github-app.json'),
77
+ (safe_path(config['github_private_key']), '/run/secrets/kobo-github-app.pem'),
78
+ ):
79
+ mounts += (f' - type: bind\n source: {json.dumps(str(source))}\n'
80
+ f' target: {target}\n read_only: true\n'
81
+ ' bind:\n create_host_path: false\n')
82
+ text = text.replace(' networks:\n', mounts + ' networks:\n', 1)
83
+ values = (config['domain'], config['projects_directory'], config['network'],
84
+ config['entrypoint'], config['certresolver'])
85
+ env = ''.join(f"{key}='{value}'\n" for key, value in zip(ENV_KEYS, values))
86
+ return text, env
87
+
88
+
89
+ def digest(name, data):
90
+ # kobo-update owns the image pin after installation; all other edits still require review.
91
+ if name == 'compose.yaml':
92
+ data = updater.image_config(data.decode(), 'kobo:managed').encode()
93
+ return hashlib.sha256(data).hexdigest()
94
+
95
+
96
+ class Installer(updater.Updater):
97
+ def __init__(self, stack):
98
+ super().__init__(safe_path(str(stack)))
99
+ self.manifest = self.stack / '.kobo-install.json'
100
+ self.project_name = 'kobo'
101
+ self.state = json.loads(self.manifest.read_text()) if self.manifest.exists() else {}
102
+ if self.state and (self.state.get('format') != 1 or self.state.get('directory') != str(self.stack)):
103
+ raise InstallError('Unrecognized or relocated installation manifest; review it manually.')
104
+
105
+ def preflight(self, config):
106
+ for tool in ('docker', 'git'):
107
+ if not shutil.which(tool):
108
+ raise InstallError(f'Install {tool} first.')
109
+ endpoint = os.environ.get('DOCKER_HOST')
110
+ if not endpoint or os.environ.get('DOCKER_CONTEXT'):
111
+ context = json.loads(self.run(['docker', 'context', 'inspect']))[0]
112
+ endpoint = context['Endpoints']['docker']['Host']
113
+ if not endpoint.startswith('unix://'):
114
+ raise InstallError('Run this installer on the Docker host using a local Unix socket context.')
115
+ info = json.loads(self.run(['docker', 'info', '--format', '{{json .}}']))
116
+ if info.get('OSType') != 'linux' or info.get('Architecture') not in ('x86_64', 'amd64'):
117
+ raise InstallError('This image currently requires Linux amd64.')
118
+ self.run(['docker', 'compose', 'version'])
119
+ self.run(['docker', 'network', 'inspect', config['network']])
120
+ if config['docker_socket'] and not Path('/var/run/docker.sock').is_socket():
121
+ raise InstallError('Docker socket access requires /var/run/docker.sock on this host.')
122
+
123
+ def discover(self):
124
+ """Suggest CLI/env-based Traefik settings without displaying raw container configuration."""
125
+ found = {name: set() for name in ('network', 'entrypoint', 'certresolver')}
126
+ ids = self.run(['docker', 'ps', '-q']).split()
127
+ if not ids:
128
+ return found
129
+ for container in json.loads(self.run(['docker', 'inspect', *ids])):
130
+ config = container.get('Config', {})
131
+ if 'traefik' not in config.get('Image', '').lower():
132
+ continue
133
+ found['network'].update(name for name in container.get('NetworkSettings', {}).get('Networks', {})
134
+ if name not in {'bridge', 'host', 'none'})
135
+ args = (config.get('Cmd') or []) + (config.get('Env') or [])
136
+ for arg in args:
137
+ entry = re.fullmatch(r'--entrypoints\.([\w-]+)\.address=:(?:443)(?:/tcp)?', arg, re.I)
138
+ env_entry = re.fullmatch(r'TRAEFIK_ENTRYPOINTS_([\w-]+)_ADDRESS=:443(?:/tcp)?', arg, re.I)
139
+ resolver = re.match(r'--certificatesresolvers\.([\w-]+)\.', arg, re.I)
140
+ env_resolver = re.match(r'TRAEFIK_CERTIFICATESRESOLVERS_([\w-]+)_ACME_', arg, re.I)
141
+ if entry or env_entry:
142
+ found['entrypoint'].add((entry or env_entry).group(1).lower())
143
+ if resolver or env_resolver:
144
+ found['certresolver'].add((resolver or env_resolver).group(1).lower())
145
+ return found
146
+
147
+ def collisions(self):
148
+ containers = self.run(['docker', 'ps', '-aq', '--filter', 'label=com.docker.compose.project=kobo'])
149
+ volumes = self.run(['docker', 'volume', 'ls', '-q', '--filter', 'name=^kobo_kobo-home$'])
150
+ routers = self.run(['docker', 'ps', '-aq', '--filter', 'label=traefik.http.routers.kobo.rule'])
151
+ if containers or volumes or routers:
152
+ raise InstallError('Compose project kobo, router kobo or volume kobo_kobo-home already exists. Refusing to adopt or replace it.')
153
+
154
+ def assert_fresh(self):
155
+ for name in ('compose.yaml', '.env', '.kobo-install.json', 'bin', 'auth', 'docker-compose.vps.yml'):
156
+ path = self.stack / name
157
+ if path.exists() or path.is_symlink():
158
+ raise InstallError(f'Unmanaged installation file already exists: {path}. Use the manual guide.')
159
+
160
+ def save(self, **changes):
161
+ self.state.update(changes)
162
+ updater.atomic_write(self.manifest, (json.dumps(self.state, indent=2) + '\n').encode())
163
+
164
+ def initialize(self, config):
165
+ self.assert_fresh()
166
+ compose, env = render(config, self.stack)
167
+ files = {'compose.yaml': compose.encode(), '.env': env.encode(),
168
+ 'docker-compose.vps.yml': (ASSETS.parent / 'docker-compose.vps.yml').read_bytes()}
169
+ files.update({f'bin/{name}': (ASSETS / name).read_bytes() for name in TOOLS})
170
+ if config.get('github_app_id'):
171
+ files['auth/github-app.json'] = json.dumps({
172
+ 'app_id': config['github_app_id'], 'installation_id': config['github_installation_id'],
173
+ 'private_key': '/run/secrets/kobo-github-app.pem',
174
+ }, indent=2).encode()
175
+ self.stack.mkdir(parents=True, exist_ok=True, mode=0o700)
176
+ # Record intent first. An interrupted file copy is explicit, never silently adopted.
177
+ self.save(format=1, directory=str(self.stack), config=config, phase='writing-files',
178
+ hashes={name: digest(name, data) for name, data in files.items()})
179
+ for name, data in files.items():
180
+ path = self.stack / name
181
+ path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
182
+ updater.atomic_write(path, data, 0o755 if name.startswith('bin/') else 0o600)
183
+ Path(config['projects_directory']).mkdir(parents=True, exist_ok=True)
184
+ self.save(phase='configured')
185
+
186
+ def verify_files(self):
187
+ if self.state['phase'] == 'writing-files':
188
+ raise InstallError('File preparation was interrupted. Review .kobo-install.json and files before retrying; nothing was started.')
189
+ for name, expected in self.state['hashes'].items():
190
+ path = self.stack / name
191
+ if path.is_symlink() or not path.is_file() or digest(name, path.read_bytes()) != expected:
192
+ raise InstallError(f'Managed file changed or missing: {path}. Preserved; review it manually.')
193
+ if self.state['phase'] != 'complete' and self.state.get('image'):
194
+ if f' image: {self.state["image"]}\n' not in self.compose_file.read_text():
195
+ raise InstallError('Prepared image was changed; refusing to start it.')
196
+
197
+ def validate_compose(self, config):
198
+ # No files or directories are needed for --check. All substitutions are supplied explicitly.
199
+ text, _ = render(config, self.stack)
200
+ for key, value in zip(ENV_KEYS, (config['domain'], config['projects_directory'], config['network'],
201
+ config['entrypoint'], config['certresolver'])):
202
+ text = re.sub(r'\$\{' + key + r'(?::[^}]*)?\}', lambda _: value, text)
203
+ self.run(['docker', 'compose', '--env-file', '/dev/null', '-p', 'kobo', '-f', '-', 'config', '--quiet'],
204
+ data=text.encode())
205
+
206
+ def configure_github(self):
207
+ self.run(self.compose('exec', '-T', 'kobo', '/usr/local/bin/kobo-github-app-token', '--check'))
208
+ helper = 'credential.https://github.com.helper'
209
+ self.run(self.compose('exec', '-T', 'kobo', 'git', 'config', '--global', '--replace-all', helper, ''))
210
+ self.run(self.compose('exec', '-T', 'kobo', 'git', 'config', '--global', '--add', helper,
211
+ '!/usr/local/bin/gh auth git-credential'))
212
+
213
+ def assert_started(self):
214
+ container = self.run(self.compose('ps', '-aq', 'kobo'))
215
+ if not container or len(container.split()) != 1:
216
+ raise InstallError('Expected installation container is missing or ambiguous. Inspect compose ps; no automatic restart was attempted.')
217
+ info = json.loads(self.run(['docker', 'inspect', container]))[0]
218
+ expected_hash = self.run(self.compose('config', '--hash', 'kobo')).split()
219
+ labels = info.get('Config', {}).get('Labels', {}) or {}
220
+ state = info.get('State', {})
221
+ expected_labels = {'com.docker.compose.project': 'kobo', 'com.docker.compose.service': 'kobo',
222
+ 'com.docker.compose.project.working_dir': str(self.stack)}
223
+ if (not state.get('Running') or state.get('Health', {}).get('Status') != 'healthy'
224
+ or info.get('Config', {}).get('Image') != self.state['image']
225
+ or any(labels.get(key) != value for key, value in expected_labels.items())
226
+ or len(expected_hash) != 2 or expected_hash[0] != 'kobo'
227
+ or labels.get('com.docker.compose.config-hash') != expected_hash[1]):
228
+ raise InstallError('Installation container is unhealthy or differs from the prepared configuration. Inspect compose ps and private logs; no automatic restart was attempted.')
229
+ self.container = container
230
+
231
+ def finish(self, config):
232
+ # A previous up may have succeeded even if its CLI or the HTTPS check failed.
233
+ # Verify ownership before doing anything; never recreate an uncertain container.
234
+ self.assert_started()
235
+ self.save(phase='verifying')
236
+ if config.get('github_app_id') and not self.state.get('github_configured'):
237
+ self.configure_github()
238
+ self.save(github_configured=True)
239
+ self.public_url = 'https://' + config['domain']
240
+ self.verify(self.state['version'])
241
+ self.save(phase='complete')
242
+ print(f'Kōbō is ready at {self.public_url}. Follow docs/deployment-vps.md to retrieve the private login link and sign in to an engine.')
243
+
244
+ def execute(self, config, check=False, prepare=False):
245
+ validate_config(config)
246
+ if self.state:
247
+ if config != self.state['config']:
248
+ raise InstallError('This directory is already configured with different options. No reconfiguration was applied.')
249
+ self.verify_files()
250
+ else:
251
+ self.assert_fresh()
252
+ self.preflight(config)
253
+ if not self.state or self.state['phase'] in {'configured', 'building', 'prepared'}:
254
+ self.collisions()
255
+ self.validate_compose(config)
256
+ if check:
257
+ print('Configuration and local Docker checks passed. No files, images or containers changed.')
258
+ return
259
+ if self.state.get('phase') == 'complete':
260
+ print('Installation already complete; no restart or reconfiguration. Use bin/kobo-update to upgrade.')
261
+ return
262
+ if self.state.get('phase') in {'starting', 'verifying'}:
263
+ if prepare:
264
+ raise InstallError('Start was already attempted. Inspect compose ps or rerun without --prepare to verify it.')
265
+ self.finish(config)
266
+ return
267
+ if not self.state:
268
+ self.initialize(config)
269
+ if self.state['phase'] != 'prepared':
270
+ self.installed = '0.0.0'
271
+ version, sha = self.resolve(config['version'])
272
+ self.job = self.stack / 'install-builds' / uuid.uuid4().hex
273
+ self.job.mkdir(mode=0o700, parents=True)
274
+ self.log = self.job / 'install.log'
275
+ self.log.touch(mode=0o600)
276
+ self.save(phase='building', version=version, sha=sha, log=str(self.log))
277
+ print(f'Building Kōbō {version}. Private log: {self.log}', flush=True)
278
+ image = self.build(version, sha)
279
+ # Older release images also need the explicit helper dependencies.
280
+ self.run(['docker', 'run', '--rm', '--network', 'none', '--entrypoint', 'sh', image,
281
+ '-c', 'python3 -c "import sys; assert sys.version_info >= (3,10)" && openssl version'])
282
+ self.verify_files()
283
+ self.save(phase='pinning', image=image)
284
+ updater.atomic_write(self.compose_file, updater.image_config(self.compose_file.read_text(), image).encode())
285
+ self.save(phase='prepared')
286
+ if prepare:
287
+ print('Image and configuration ready. Run the same command without --prepare to start.')
288
+ return
289
+ self.verify_files()
290
+ self.collisions()
291
+ self.run(self.compose('config', '--quiet'))
292
+ self.save(phase='starting')
293
+ self.run(self.compose('up', '-d', '--wait', '--wait-timeout', '180', 'kobo'), timeout=240)
294
+ self.finish(config)
295
+
296
+
297
+ def arguments(argv=None):
298
+ parser = argparse.ArgumentParser(description=__doc__)
299
+ parser.add_argument('--directory', type=Path, required=True)
300
+ for name in ('domain', 'projects-directory', 'network', 'entrypoint', 'certresolver', 'version',
301
+ 'github-app-id', 'github-installation-id', 'github-private-key'):
302
+ parser.add_argument('--' + name)
303
+ parser.add_argument('--docker-socket', action='store_true', default=None,
304
+ help='Give agents root-equivalent access to this host through Docker.')
305
+ parser.add_argument('--yes', action='store_true', help='Use explicit flags without interactive questions.')
306
+ mode = parser.add_mutually_exclusive_group()
307
+ mode.add_argument('--check', action='store_true', help='Read-only local prerequisite/configuration checks.')
308
+ mode.add_argument('--prepare', action='store_true', help='Write configuration and build without starting.')
309
+ return parser.parse_args(argv)
310
+
311
+
312
+ def configure(args, installer):
313
+ names = ('domain', 'projects_directory', 'network', 'entrypoint', 'certresolver', 'version',
314
+ 'github_app_id', 'github_installation_id', 'github_private_key', 'docker_socket')
315
+ config = installer.state.get('config', {}).copy()
316
+ config.update({name: getattr(args, name) for name in names if getattr(args, name) is not None})
317
+ interactive = sys.stdin.isatty() and not args.yes
318
+ suggestions = installer.discover() if interactive and not installer.state else {}
319
+
320
+ def ask(name, label, default=None):
321
+ if name in config:
322
+ return
323
+ if interactive:
324
+ options = sorted(suggestions.get(name, []))
325
+ if len(options) == 1:
326
+ default = options[0]
327
+ if options:
328
+ print(f'Detected {name}: {", ".join(options)}')
329
+ answer = input(f'{label}' + (f' [{default}]' if default is not None else '') + ': ').strip()
330
+ config[name] = answer or default
331
+ else:
332
+ config[name] = default
333
+ if config[name] is None:
334
+ raise InstallError(f'Missing --{name.replace("_", "-")}.')
335
+
336
+ ask('domain', 'Public hostname')
337
+ ask('projects_directory', 'Projects directory', str(installer.stack / 'projects'))
338
+ ask('network', 'Existing Traefik Docker network')
339
+ ask('entrypoint', 'Traefik HTTPS entrypoint')
340
+ ask('certresolver', 'Traefik certificate resolver (empty for preloaded certificate)', '')
341
+ ask('version', 'Stable release version', 'latest')
342
+ if config.get('docker_socket') is None:
343
+ config['docker_socket'] = interactive and input('Give agents Docker access (root-equivalent host control)? [y/N]: ').lower() == 'y'
344
+ if not any(config.get(name) for name in ('github_app_id', 'github_installation_id', 'github_private_key')):
345
+ if interactive and input('Configure a GitHub App bot? [y/N]: ').lower() == 'y':
346
+ ask('github_app_id', 'GitHub App client ID or App ID')
347
+ ask('github_installation_id', 'GitHub installation ID')
348
+ ask('github_private_key', 'Absolute path to private RSA key (0600)')
349
+ for name in ('github_app_id', 'github_installation_id', 'github_private_key'):
350
+ config.setdefault(name, None)
351
+ config['projects_directory'] = str(safe_path(config['projects_directory']))
352
+ if config['github_private_key']:
353
+ config['github_private_key'] = str(safe_path(config['github_private_key']))
354
+ return config
355
+
356
+
357
+ def main(argv=None):
358
+ args = arguments(argv)
359
+ try:
360
+ installer = Installer(args.directory)
361
+ config = configure(args, installer)
362
+ validate_config(config)
363
+ if args.check:
364
+ installer.execute(config, check=True)
365
+ return 0
366
+ print(f'Install directory: {installer.stack}\nURL: https://{config["domain"]}\n'
367
+ f'Projects: {config["projects_directory"]}\nDocker socket: {config["docker_socket"]}\n'
368
+ f'GitHub App: {bool(config["github_app_id"])}')
369
+ if not args.yes and not installer.state:
370
+ if not sys.stdin.isatty() or input('Create this installation? [y/N]: ').lower() != 'y':
371
+ raise InstallError('Installation not started. Use --yes with explicit configuration for unattended setup.')
372
+ installer.stack.mkdir(mode=0o700, parents=True, exist_ok=True)
373
+ with (installer.stack / '.update.lock').open('a') as lock:
374
+ try:
375
+ fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
376
+ except BlockingIOError:
377
+ raise InstallError('An installer or updater already holds this directory lock.') from None
378
+ # Reload after locking so concurrent installers cannot adopt one another's stale state.
379
+ installer = Installer(args.directory)
380
+ installer.execute(config, prepare=args.prepare)
381
+ return 0
382
+ except (InstallError, OSError, ValueError, KeyError, EOFError) as error:
383
+ print(str(error) if isinstance(error, InstallError) else
384
+ 'Installation failed: check local paths, prerequisites and the private installation state.', file=sys.stderr)
385
+ return 1
386
+ except KeyboardInterrupt:
387
+ print('Interrupted. Files and data retained; inspect .kobo-install.json before resuming.', file=sys.stderr)
388
+ return 130
389
+
390
+
391
+ if __name__ == '__main__':
392
+ sys.exit(main())