pinokiod 5.1.10 → 5.1.11

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 (56) hide show
  1. package/kernel/api/fs/download_worker.js +158 -0
  2. package/kernel/api/fs/index.js +95 -91
  3. package/kernel/api/index.js +3 -0
  4. package/kernel/bin/index.js +5 -2
  5. package/kernel/environment.js +19 -2
  6. package/kernel/git.js +972 -1
  7. package/kernel/index.js +65 -30
  8. package/kernel/peer.js +1 -2
  9. package/kernel/plugin.js +0 -8
  10. package/kernel/procs.js +92 -36
  11. package/kernel/prototype.js +45 -22
  12. package/kernel/shells.js +30 -6
  13. package/kernel/sysinfo.js +33 -13
  14. package/kernel/util.js +61 -24
  15. package/kernel/workspace_status.js +131 -7
  16. package/package.json +1 -1
  17. package/pipe/index.js +1 -1
  18. package/server/index.js +1169 -350
  19. package/server/public/create-launcher.js +157 -2
  20. package/server/public/install.js +135 -41
  21. package/server/public/style.css +32 -1
  22. package/server/public/tab-link-popover.js +45 -14
  23. package/server/public/terminal-settings.js +51 -35
  24. package/server/public/urldropdown.css +89 -3
  25. package/server/socket.js +12 -7
  26. package/server/views/agents.ejs +4 -3
  27. package/server/views/app.ejs +798 -30
  28. package/server/views/bootstrap.ejs +2 -1
  29. package/server/views/checkpoints.ejs +1014 -0
  30. package/server/views/checkpoints_registry_beta.ejs +260 -0
  31. package/server/views/columns.ejs +4 -4
  32. package/server/views/connect.ejs +1 -0
  33. package/server/views/d.ejs +74 -4
  34. package/server/views/download.ejs +28 -28
  35. package/server/views/editor.ejs +4 -5
  36. package/server/views/env_editor.ejs +1 -1
  37. package/server/views/file_explorer.ejs +1 -1
  38. package/server/views/index.ejs +3 -1
  39. package/server/views/init/index.ejs +2 -1
  40. package/server/views/install.ejs +2 -1
  41. package/server/views/net.ejs +9 -7
  42. package/server/views/network.ejs +15 -14
  43. package/server/views/pro.ejs +5 -2
  44. package/server/views/prototype/index.ejs +2 -1
  45. package/server/views/registry_link.ejs +76 -0
  46. package/server/views/rows.ejs +4 -4
  47. package/server/views/screenshots.ejs +1 -0
  48. package/server/views/settings.ejs +1 -0
  49. package/server/views/shell.ejs +4 -6
  50. package/server/views/terminal.ejs +528 -38
  51. package/server/views/tools.ejs +1 -0
  52. package/undefined/logs/dev/plugin/cursor-agent.git/pinokio.js/1764297248545 +0 -45
  53. package/undefined/logs/dev/plugin/cursor-agent.git/pinokio.js/1764335557118 +0 -45
  54. package/undefined/logs/dev/plugin/cursor-agent.git/pinokio.js/1764335834126 +0 -45
  55. package/undefined/logs/dev/plugin/cursor-agent.git/pinokio.js/events +0 -12
  56. package/undefined/logs/dev/plugin/cursor-agent.git/pinokio.js/latest +0 -45
package/server/index.js CHANGED
@@ -18,7 +18,7 @@ const cors = require('cors');
18
18
  const path = require("path")
19
19
  const fs = require('fs');
20
20
  const os = require('os')
21
- const { fork, exec } = require('child_process');
21
+ const { fork, execFile } = require('child_process');
22
22
  const semver = require('semver')
23
23
  const fse = require('fs-extra')
24
24
  const QRCode = require('qrcode')
@@ -68,18 +68,6 @@ const Setup = require("../kernel/bin/setup")
68
68
  return (str.endsWith('\n') ? str : str + '\n').replace(/\r\n/g, '\n');
69
69
  }
70
70
 
71
- this.gitEnv = (repoPath) => {
72
- const gitBin = this.kernel.bin && this.kernel.bin.git ? this.kernel.bin.git : null
73
- if (gitBin && typeof gitBin.env === 'function') {
74
- const env = gitBin.env(repoPath)
75
- if (this.kernel.git && typeof this.kernel.git.clearStaleLock === "function" && repoPath) {
76
- this.kernel.git.clearStaleLock(repoPath).catch(() => {})
77
- }
78
- return env
79
- }
80
- return {}
81
- }
82
-
83
71
  class Server {
84
72
  constructor(config) {
85
73
  this.tabs = {}
@@ -103,7 +91,6 @@ class Server {
103
91
  this.kernel.version = this.version
104
92
  this.upload = multer();
105
93
  this.cf = new Cloudflare()
106
- this.virtualEnvCache = new Map()
107
94
  this.gitStatusIgnorePatterns = [
108
95
  /(^|\/)node_modules\//,
109
96
  // /(^|\/)vendor\//,
@@ -121,9 +108,28 @@ class Server {
121
108
  /(^|\/)\.pytest_cache\//,
122
109
  /(^|\/)\.git\//
123
110
  ]
111
+ this.apiGitRefreshTimer = null
124
112
  this.workspaceStatus = new WorkspaceStatusManager({
125
113
  enableWatchers: process.env.PINOKIO_DISABLE_WATCH === '1' ? false : true,
126
114
  fallbackIntervalMs: 60000,
115
+ onEvent: (workspaceName, events) => {
116
+ if (!this.kernel.homedir) return
117
+ if (!events || events.length === 0) return
118
+ const apiRoot = this.kernel.path('api')
119
+ const topLevel = events.some(({ path: evtPath, type }) => {
120
+ if (!evtPath) return false
121
+ const rel = path.relative(apiRoot, evtPath)
122
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return false
123
+ const parts = rel.split(path.sep)
124
+ return parts.length === 1 && (type === 'create' || type === 'delete')
125
+ })
126
+ if (topLevel) {
127
+ if (this.apiGitRefreshTimer) clearTimeout(this.apiGitRefreshTimer)
128
+ this.apiGitRefreshTimer = setTimeout(() => {
129
+ this.kernel.git.repos(apiRoot).catch(() => {})
130
+ }, 1000)
131
+ }
132
+ },
127
133
  })
128
134
 
129
135
  // sometimes the C:\Windows\System32 is not in PATH, need to add
@@ -857,6 +863,7 @@ class Server {
857
863
  }
858
864
 
859
865
  async chrome(req, res, type, options) {
866
+ console.log("Chrome")
860
867
 
861
868
  let d = Date.now()
862
869
  console.time("bin check")
@@ -1092,6 +1099,13 @@ class Server {
1092
1099
  git_fork_url: `/run/scripts/git/fork.json?cwd=${encodeURIComponent(this.kernel.path('api', name))}`
1093
1100
  // rawpath,
1094
1101
  }
1102
+ result.hasSnapshots = !!(options && options.hasSnapshots)
1103
+ result.pendingSnapshotId = options && options.pendingSnapshotId ? String(options.pendingSnapshotId) : null
1104
+ const registryBetaEnabled = await this.isRegistryBetaEnabled().catch(() => false)
1105
+ result.registryBetaEnabled = registryBetaEnabled
1106
+ if (!registryBetaEnabled) {
1107
+ result.pendingSnapshotId = null
1108
+ }
1095
1109
  // if (!this.kernel.proto.config) {
1096
1110
  // await this.kernel.proto.init()
1097
1111
  // }
@@ -1112,259 +1126,279 @@ class Server {
1112
1126
  }
1113
1127
  return { editorUrl, prevUrl }
1114
1128
  }
1115
- get_shell_id(name, i, rendered) {
1116
- let shell_id
1117
- if (rendered.id) {
1118
- shell_id = encodeURIComponent(`${name}_${rendered.id}`)
1119
- } else {
1120
- let hash = crypto.createHash('md5').update(JSON.stringify(rendered)).digest('hex')
1121
- //shell_id = encodeURIComponent(`${name}_${i}_session_${hash}`)
1122
- shell_id = encodeURIComponent(`${name}_session_${hash}`)
1123
- }
1124
- return shell_id
1125
- }
1126
- is_subpath(parent, child) {
1127
- const relative = path.relative(parent, child);
1128
- let check = !!relative && !relative.startsWith('..') && !path.isAbsolute(relative);
1129
- return check
1129
+ parseRegistryBetaValue(value) {
1130
+ if (value === true) return true
1131
+ if (value === false) return false
1132
+ if (value == null) return null
1133
+ const str = String(value).trim().toLowerCase()
1134
+ if (!str) return null
1135
+ if (["1", "true", "yes", "on", "enable", "enabled"].includes(str)) return true
1136
+ if (["0", "false", "no", "off", "disable", "disabled"].includes(str)) return false
1137
+ return null
1130
1138
  }
1131
- async discoverVirtualEnvDirs(dir) {
1132
- const cacheKey = path.resolve(dir)
1133
- const cached = this.virtualEnvCache.get(cacheKey)
1134
- const now = Date.now()
1135
- if (cached && cached.timestamp && (now - cached.timestamp) < 60000) {
1136
- return cached.dirs
1137
- }
1138
-
1139
- const normalizePath = (p) => p.replace(/\\/g, '/').replace(/\/+/g, '/')
1140
- const ignored = new Set()
1141
- const autoExclude = new Set()
1142
- const seen = new Set()
1143
- const stack = [{ abs: dir, rel: '', depth: 0 }]
1144
- const maxDepth = 6
1145
-
1146
- const shouldIgnoreRelative = (relative) => {
1147
- if (!relative) {
1148
- return false
1139
+ async isRegistryBetaEnabled() {
1140
+ let raw = null
1141
+ try {
1142
+ const env = await Environment.get(this.kernel.homedir, this.kernel)
1143
+ if (env && Object.prototype.hasOwnProperty.call(env, "PINOKIO_REGISTRY_BETA")) {
1144
+ raw = env.PINOKIO_REGISTRY_BETA
1149
1145
  }
1150
- const normalized = normalizePath(relative)
1151
- return this.gitStatusIgnorePatterns.some((regex) => regex.test(normalized) || regex.test(`${normalized}/`))
1146
+ } catch (_) {}
1147
+ if (raw == null && process.env.PINOKIO_REGISTRY_BETA != null) {
1148
+ raw = process.env.PINOKIO_REGISTRY_BETA
1152
1149
  }
1153
-
1154
- while (stack.length > 0) {
1155
- const { abs, rel, depth } = stack.pop()
1156
- const normalizedAbs = normalizePath(abs)
1157
- if (seen.has(normalizedAbs)) {
1158
- continue
1159
- }
1160
- seen.add(normalizedAbs)
1161
-
1162
- let stats
1163
- try {
1164
- stats = await fs.promises.stat(abs)
1165
- } catch (err) {
1166
- continue
1150
+ return this.parseRegistryBetaValue(raw) === true
1151
+ }
1152
+ async updateEnvironmentVars(updates) {
1153
+ await Environment.init({}, this.kernel)
1154
+ const fullpath = path.resolve(this.kernel.homedir, "ENVIRONMENT")
1155
+ await Util.update_env(fullpath, updates)
1156
+ }
1157
+ async getRegistryConfig() {
1158
+ let url = null
1159
+ let apiKey = null
1160
+ try {
1161
+ const env = await Environment.get(this.kernel.homedir, this.kernel)
1162
+ if (env && env.PINOKIO_REGISTRY_URL) {
1163
+ const v = String(env.PINOKIO_REGISTRY_URL).trim()
1164
+ if (v) url = v
1167
1165
  }
1168
- if (!stats.isDirectory()) {
1169
- continue
1166
+ if (env && env.PINOKIO_REGISTRY_API_KEY) {
1167
+ const v = String(env.PINOKIO_REGISTRY_API_KEY).trim()
1168
+ if (v) apiKey = v
1170
1169
  }
1171
-
1172
- const relPath = rel ? normalizePath(rel) : ''
1173
- if (relPath && shouldIgnoreRelative(relPath)) {
1174
- ignored.add(relPath)
1175
- const relSegments = relPath.split('/')
1176
- const lastSegment = relSegments[relSegments.length - 1]
1177
- if (lastSegment && ['node_modules', '.venv', 'venv', '.virtualenv', 'env'].includes(lastSegment)) {
1178
- autoExclude.add(relPath)
1170
+ } catch (_) {}
1171
+ if ((url == null || apiKey == null) && this.kernel && this.kernel.store) {
1172
+ const registry = this.kernel.store.get('registry') || {}
1173
+ const updates = {}
1174
+ if (url == null && registry.url) {
1175
+ const v = String(registry.url).trim()
1176
+ if (v) {
1177
+ url = v
1178
+ updates.PINOKIO_REGISTRY_URL = v
1179
1179
  }
1180
- continue
1181
1180
  }
1182
-
1183
- const entries = await fs.promises.readdir(abs, { withFileTypes: true }).catch(() => [])
1184
- let hasPyvenvCfg = false
1185
- let hasExecutables = false
1186
- let hasSitePackages = false
1187
- let hasInclude = false
1188
- let hasNestedGit = false
1189
-
1190
- for (const entry of entries) {
1191
- if (entry.name === '.git') {
1192
- let treatAsGit = false
1193
- if (entry.isDirectory && entry.isDirectory()) {
1194
- treatAsGit = true
1195
- } else if (entry.isFile && entry.isFile()) {
1196
- treatAsGit = true
1197
- } else if (entry.isSymbolicLink && entry.isSymbolicLink()) {
1198
- treatAsGit = true
1199
- }
1200
- if (treatAsGit) {
1201
- hasNestedGit = true
1202
- }
1181
+ if (apiKey == null && registry.apiKey) {
1182
+ const v = String(registry.apiKey).trim()
1183
+ if (v) {
1184
+ apiKey = v
1185
+ updates.PINOKIO_REGISTRY_API_KEY = v
1203
1186
  }
1204
- if (entry.isFile() && entry.name === 'pyvenv.cfg') {
1205
- hasPyvenvCfg = true
1206
- }
1207
- if (!entry.isDirectory()) {
1208
- continue
1209
- }
1210
- const lower = entry.name.toLowerCase()
1211
- if (lower === 'include') {
1212
- hasInclude = true
1213
- }
1214
- if (lower === 'bin' || lower === 'scripts') {
1215
- const execEntries = await fs.promises.readdir(path.join(abs, entry.name)).catch(() => [])
1216
- if (execEntries.some((name) => /^activate(\..*)?$/i.test(name) || /^python(\d*(\.\d+)*)?(\.exe)?$/i.test(name))) {
1217
- hasExecutables = true
1218
- }
1219
- }
1220
- if (lower === 'site-packages') {
1221
- hasSitePackages = true
1222
- }
1223
- if (lower === 'lib' || lower === 'lib64') {
1224
- const libPath = path.join(abs, entry.name)
1225
- const libEntries = await fs.promises.readdir(libPath, { withFileTypes: true }).catch(() => [])
1226
- for (const libEntry of libEntries) {
1227
- if (!libEntry.isDirectory()) {
1228
- continue
1229
- }
1230
- if (/^python\d+(\.\d+)?$/i.test(libEntry.name)) {
1231
- const sitePackages = path.join(libPath, libEntry.name, 'site-packages')
1232
- try {
1233
- const siteStats = await fs.promises.stat(sitePackages)
1234
- if (siteStats.isDirectory()) {
1235
- hasSitePackages = true
1236
- break
1237
- }
1238
- } catch (err) {}
1239
- }
1240
- if (libEntry.name === 'site-packages') {
1241
- hasSitePackages = true
1242
- break
1243
- }
1244
- }
1245
- }
1246
- }
1247
-
1248
- const looksLikeVenv = hasPyvenvCfg || (hasExecutables && (hasSitePackages || hasInclude))
1249
- if (looksLikeVenv && relPath) {
1250
- ignored.add(relPath)
1251
- autoExclude.add(relPath)
1252
- continue
1253
- }
1254
-
1255
- if (hasNestedGit && relPath) {
1256
- ignored.add(relPath)
1257
- autoExclude.add(relPath)
1258
- continue
1259
1187
  }
1260
-
1261
- if (depth >= maxDepth) {
1262
- continue
1263
- }
1264
-
1265
- for (const entry of entries) {
1266
- if (!entry.isDirectory()) {
1267
- continue
1268
- }
1269
- const childRel = rel ? `${rel}/${entry.name}` : entry.name
1270
- stack.push({ abs: path.join(abs, entry.name), rel: childRel, depth: depth + 1 })
1188
+ if (Object.keys(updates).length) {
1189
+ await this.updateEnvironmentVars(updates).catch(() => {})
1271
1190
  }
1272
1191
  }
1273
-
1274
- this.virtualEnvCache.set(cacheKey, { dirs: ignored, timestamp: now })
1275
- if (autoExclude.size > 0) {
1276
- try {
1277
- await this.syncGitInfoExclude(dir, autoExclude)
1278
- } catch (error) {
1279
- console.warn('syncGitInfoExclude failed', dir, error)
1192
+ return { url, apiKey }
1193
+ }
1194
+ async getRegistryConnectUrl() {
1195
+ let raw = null
1196
+ let source = null
1197
+ try {
1198
+ const env = await Environment.get(this.kernel.homedir, this.kernel)
1199
+ if (env && Object.prototype.hasOwnProperty.call(env, "PINOKIO_REGISTRY_CONNECT_URL")) {
1200
+ raw = env.PINOKIO_REGISTRY_CONNECT_URL
1201
+ source = "env-url"
1202
+ } else if (env && Object.prototype.hasOwnProperty.call(env, "PINOKIO_REGISTRY_CONNECT_ENDPOINT")) {
1203
+ raw = env.PINOKIO_REGISTRY_CONNECT_ENDPOINT
1204
+ source = "env-endpoint"
1280
1205
  }
1281
- }
1282
- return ignored
1206
+ } catch (_) {}
1207
+ if (raw == null && process.env.PINOKIO_REGISTRY_CONNECT_URL != null) {
1208
+ raw = process.env.PINOKIO_REGISTRY_CONNECT_URL
1209
+ source = "process-url"
1210
+ } else if (raw == null && process.env.PINOKIO_REGISTRY_CONNECT_ENDPOINT != null) {
1211
+ raw = process.env.PINOKIO_REGISTRY_CONNECT_ENDPOINT
1212
+ source = "process-endpoint"
1213
+ }
1214
+ let value = raw != null ? String(raw).trim() : ""
1215
+ if (!value && this.kernel && this.kernel.store) {
1216
+ const registry = this.kernel.store.get('registry') || {}
1217
+ if (registry.connectUrl) {
1218
+ value = String(registry.connectUrl).trim()
1219
+ if (value) {
1220
+ await this.updateEnvironmentVars({
1221
+ PINOKIO_REGISTRY_CONNECT_URL: value,
1222
+ PINOKIO_REGISTRY_CONNECT_ENDPOINT: ""
1223
+ }).catch(() => {})
1224
+ }
1225
+ }
1226
+ }
1227
+ if (value && source === "env-endpoint") {
1228
+ await this.updateEnvironmentVars({
1229
+ PINOKIO_REGISTRY_CONNECT_URL: value,
1230
+ PINOKIO_REGISTRY_CONNECT_ENDPOINT: ""
1231
+ }).catch(() => {})
1232
+ }
1233
+ return value ? value : null
1283
1234
  }
1284
- async syncGitInfoExclude(dir, prefixes) {
1285
- if (!prefixes || prefixes.size === 0) {
1286
- return
1235
+ async getRegistryViewUrl(hash) {
1236
+ if (!hash) return null
1237
+ const registry = await this.getRegistryConfig().catch(() => ({ url: null, apiKey: null }))
1238
+ const baseUrlRaw = registry && registry.url ? String(registry.url) : ""
1239
+ const baseUrl = baseUrlRaw ? baseUrlRaw.replace(/\/$/, "") : ""
1240
+ const connectUrl = await this.getRegistryConnectUrl().catch(() => null)
1241
+ let uiOrigin = null
1242
+ if (connectUrl) {
1243
+ try {
1244
+ uiOrigin = new URL(connectUrl).origin
1245
+ } catch (_) {}
1287
1246
  }
1288
-
1289
- const gitdir = path.join(dir, '.git')
1290
- let gitStats
1291
- try {
1292
- gitStats = await fs.promises.stat(gitdir)
1293
- } catch (error) {
1294
- return
1247
+ if (!uiOrigin && baseUrlRaw) {
1248
+ try {
1249
+ uiOrigin = new URL(baseUrlRaw).origin
1250
+ } catch (_) {}
1295
1251
  }
1296
- if (!gitStats.isDirectory()) {
1297
- return
1252
+ if (!uiOrigin || !baseUrl) return null
1253
+ let appSlug = null
1254
+ try {
1255
+ const res = await axios.get(`${baseUrl}/checkpoints/${encodeURIComponent(String(hash))}`, {
1256
+ headers: { Accept: "application/json" },
1257
+ timeout: 10000,
1258
+ })
1259
+ appSlug = res && res.data && res.data.app && res.data.app.slug ? String(res.data.app.slug) : null
1260
+ } catch (_) {}
1261
+ if (!appSlug) return null
1262
+ const origin = uiOrigin.replace(/\/$/, "")
1263
+ return `${origin}/apps/${encodeURIComponent(appSlug)}/checkpoints/${encodeURIComponent(String(hash))}`
1264
+ }
1265
+ async getSnapshotStatus(name) {
1266
+ let hasSnapshots = false
1267
+ let pendingSnapshotId = null
1268
+ const debugLogs = []
1269
+ const registryBetaEnabled = await this.isRegistryBetaEnabled().catch(() => false)
1270
+ const shouldTreatPending = (entry) => {
1271
+ if (!registryBetaEnabled || !entry || entry.decision) return false
1272
+ const status = entry.sync && entry.sync.status ? String(entry.sync.status) : "local"
1273
+ if (status === "published" || status === "imported") return false
1274
+ return true
1298
1275
  }
1299
-
1300
- const infoDir = path.join(gitdir, 'info')
1301
- await fs.promises.mkdir(infoDir, { recursive: true })
1302
-
1303
- const excludePath = path.join(infoDir, 'exclude')
1304
- let existing = ''
1305
1276
  try {
1306
- existing = await fs.promises.readFile(excludePath, 'utf8')
1307
- } catch (error) {}
1308
-
1309
- const markerStart = '# >>> pinokiod auto-ignore >>>'
1310
- const markerEnd = '# <<< pinokiod auto-ignore <<<'
1311
- const lines = existing.split(/\r?\n/)
1312
- const preserved = []
1313
- const managed = new Set()
1314
- let inBlock = false
1315
-
1316
- for (const rawLine of lines) {
1317
- const line = rawLine.trimEnd()
1318
- if (line === markerStart) {
1319
- inBlock = true
1320
- continue
1321
- }
1322
- if (line === markerEnd) {
1323
- inBlock = false
1324
- continue
1325
- }
1326
- if (inBlock) {
1327
- const entry = rawLine.trim()
1328
- if (entry && !entry.startsWith('#')) {
1329
- managed.add(entry)
1277
+ const apiRoot = this.kernel.path("api", name)
1278
+ let remoteKey = null
1279
+ let remoteEntry = null
1280
+ try {
1281
+ const mainRemote = await git.getConfig({
1282
+ fs,
1283
+ http,
1284
+ dir: apiRoot,
1285
+ path: 'remote.origin.url'
1286
+ })
1287
+ if (mainRemote) {
1288
+ remoteKey = this.kernel.git.normalizeRemote(mainRemote)
1289
+ const apps = this.kernel.git && this.kernel.git.history && this.kernel.git.history.apps ? this.kernel.git.history.apps : {}
1290
+ remoteEntry = remoteKey && apps[remoteKey] ? apps[remoteKey] : null
1291
+ debugLogs.push({ stage: "remote-derived", mainRemote, remoteKey, remoteEntryFound: !!remoteEntry })
1292
+ } else {
1293
+ debugLogs.push({ stage: "remote-derived", mainRemote: null })
1330
1294
  }
1331
- continue
1332
- }
1333
- preserved.push(rawLine)
1334
- }
1335
-
1336
- const beforeSize = managed.size
1337
- for (const prefix of prefixes) {
1338
- if (!prefix) {
1339
- continue
1340
- }
1341
- const normalized = prefix.replace(/\\/g, '/').replace(/\/+/g, '/')
1342
- if (!normalized || normalized === '.git' || normalized.startsWith('.git/')) {
1343
- continue
1295
+ } catch (e) {
1296
+ debugLogs.push({ stage: "remote-derived-error", error: e && e.message ? e.message : String(e) })
1297
+ }
1298
+ if (remoteEntry) {
1299
+ const activeRaw = this.kernel.git && this.kernel.git.activeSnapshot && this.kernel.git.activeSnapshot[name]
1300
+ const active = typeof activeRaw === "object" && activeRaw !== null ? activeRaw.id : activeRaw
1301
+ if (active) {
1302
+ hasSnapshots = true
1303
+ debugLogs.push({ stage: "active-snapshot", active })
1304
+ const activeEntry = remoteEntry && Array.isArray(remoteEntry.checkpoints)
1305
+ ? remoteEntry.checkpoints.find((c) => c && String(c.id) === String(active))
1306
+ : null
1307
+ if (activeEntry && activeEntry.decision === "pending") {
1308
+ pendingSnapshotId = String(active)
1309
+ } else if (activeEntry && shouldTreatPending(activeEntry)) {
1310
+ pendingSnapshotId = String(active)
1311
+ await this.kernel.git.setCheckpointDecision(remoteKey, String(active), "pending").catch(() => {})
1312
+ }
1313
+ } else {
1314
+ const repos = await this.kernel.git.repos(apiRoot)
1315
+ const current = []
1316
+ for (const repo of repos) {
1317
+ if (!repo) continue
1318
+ const rel = repo.gitRelPath ? path.dirname(repo.gitRelPath) : null
1319
+ const normalizedPath = rel && rel !== "" && rel !== "." ? rel : "."
1320
+ const repoPath = this.kernel.path("api", name, normalizedPath === "." ? "" : normalizedPath)
1321
+ let commit = null
1322
+ try {
1323
+ const headFile = path.join(repoPath, ".git", "HEAD")
1324
+ const headRefRaw = await fs.promises.readFile(headFile, "utf8").catch(() => null)
1325
+ const headRef = headRefRaw ? headRefRaw.trim() : null
1326
+ if (headRef) {
1327
+ const refMatch = headRef.match(/^ref: (.+)$/)
1328
+ if (refMatch) {
1329
+ const refPath = path.join(repoPath, ".git", refMatch[1])
1330
+ const loose = await fs.promises.readFile(refPath, "utf8").catch(() => null)
1331
+ if (loose && loose.trim()) {
1332
+ commit = loose.trim()
1333
+ } else {
1334
+ // Try packed-refs
1335
+ try {
1336
+ const packed = await fs.promises.readFile(path.join(repoPath, ".git", "packed-refs"), "utf8")
1337
+ const lines = packed.split(/\r?\n/)
1338
+ for (const line of lines) {
1339
+ if (!line || line.startsWith('#') || line.startsWith('^')) continue
1340
+ const [sha, refname] = line.split(' ')
1341
+ if (refname && refname.trim() === refMatch[1]) {
1342
+ commit = sha.trim()
1343
+ break
1344
+ }
1345
+ }
1346
+ } catch (_) {}
1347
+ }
1348
+ } else {
1349
+ commit = headRef
1350
+ }
1351
+ }
1352
+ } catch (_) {}
1353
+ current.push({
1354
+ path: normalizedPath,
1355
+ remote: repo && repo.url ? this.kernel.git.canonicalRepoUrl(repo.url) : null,
1356
+ commit
1357
+ })
1358
+ }
1359
+ const snaps = await this.kernel.git.listSnapshotsForRemote(remoteKey)
1360
+ const snapNorm = snaps.map((snap) => this.kernel.git.normalizeReposArray(snap.repos || []))
1361
+ const currNorm = this.kernel.git.normalizeReposArray(current)
1362
+ const matchIndex = snapNorm.findIndex((snapRepos) => JSON.stringify(snapRepos) === JSON.stringify(currNorm))
1363
+ hasSnapshots = matchIndex >= 0
1364
+ if (hasSnapshots) {
1365
+ const matched = snaps[matchIndex]
1366
+ if (matched && matched.decision === "pending" && matched.id != null) {
1367
+ pendingSnapshotId = String(matched.id)
1368
+ } else if (matched && matched.id != null && shouldTreatPending(matched)) {
1369
+ pendingSnapshotId = String(matched.id)
1370
+ await this.kernel.git.setCheckpointDecision(remoteKey, String(matched.id), "pending").catch(() => {})
1371
+ }
1372
+ }
1373
+ debugLogs.push({
1374
+ stage: "compare",
1375
+ current: currNorm,
1376
+ snaps: snapNorm.map((sr, idx) => ({ id: snaps[idx] && snaps[idx].id, repos: sr })),
1377
+ match: hasSnapshots
1378
+ })
1379
+ }
1380
+ } else {
1381
+ hasSnapshots = false
1382
+ debugLogs.push({ stage: "no-remote-entry" })
1344
1383
  }
1345
- const withSlash = normalized.endsWith('/') ? normalized : `${normalized}/`
1346
- managed.add(withSlash)
1347
- }
1348
-
1349
- if (managed.size === beforeSize && existing.includes(markerStart)) {
1350
- return
1351
- }
1352
-
1353
- const sortedEntries = Array.from(managed)
1354
- .filter(Boolean)
1355
- .sort((a, b) => a.localeCompare(b))
1356
-
1357
- const blocks = []
1358
- const preservedContent = preserved.join('\n').trimEnd()
1359
- if (preservedContent) {
1360
- blocks.push(preservedContent)
1361
- }
1362
- if (sortedEntries.length > 0) {
1363
- blocks.push([markerStart, ...sortedEntries, markerEnd].join('\n'))
1384
+ } catch (_) {}
1385
+ return { hasSnapshots, pendingSnapshotId, debugLogs }
1386
+ }
1387
+ get_shell_id(name, i, rendered) {
1388
+ let shell_id
1389
+ if (rendered.id) {
1390
+ shell_id = encodeURIComponent(`${name}_${rendered.id}`)
1391
+ } else {
1392
+ let hash = crypto.createHash('md5').update(JSON.stringify(rendered)).digest('hex')
1393
+ //shell_id = encodeURIComponent(`${name}_${i}_session_${hash}`)
1394
+ shell_id = encodeURIComponent(`${name}_session_${hash}`)
1364
1395
  }
1365
-
1366
- const finalContent = blocks.join('\n\n')
1367
- await fs.promises.writeFile(excludePath, finalContent ? `${finalContent}\n` : '', 'utf8')
1396
+ return shell_id
1397
+ }
1398
+ is_subpath(parent, child) {
1399
+ const relative = path.relative(parent, child);
1400
+ let check = !!relative && !relative.startsWith('..') && !path.isAbsolute(relative);
1401
+ return check
1368
1402
  }
1369
1403
  async getRepoHeadStatus(repoRelPath) {
1370
1404
  const repoParam = repoRelPath || ""
@@ -1379,123 +1413,175 @@ class Server {
1379
1413
  return { changes: [], git_commit_url: null }
1380
1414
  }
1381
1415
 
1382
-
1383
- const normalizePath = (p) => p.replace(/\\/g, '/').replace(/\/+/g, '/')
1384
- const ignoredPrefixes = await this.discoverVirtualEnvDirs(dir)
1385
-
1386
- const shouldIncludePath = (relativePath) => {
1387
- if (!relativePath) {
1388
- return true
1389
- }
1390
- const normalized = normalizePath(relativePath)
1391
- if (this.gitStatusIgnorePatterns && this.gitStatusIgnorePatterns.some((regex) => regex.test(normalized) || regex.test(`${normalized}/`))) {
1392
- return false
1393
- }
1394
- for (const prefix of ignoredPrefixes) {
1395
- if (normalized === prefix || normalized.startsWith(`${prefix}/`)) {
1396
- return false
1416
+ let gitIgnoreEngine = null
1417
+ let workspaceName = null
1418
+ let workspaceRelBase = ''
1419
+ try {
1420
+ if (this.workspaceStatus && this.kernel && typeof this.kernel.path === 'function') {
1421
+ if (repoParam && repoParam.length > 0) {
1422
+ const parts = repoParam.split('/')
1423
+ workspaceName = parts[0]
1424
+ const workspaceRoot = this.kernel.path("api", workspaceName)
1425
+ await this.workspaceStatus.ensureGitIgnoreEngine(workspaceName, workspaceRoot)
1426
+ gitIgnoreEngine = this.workspaceStatus.gitIgnoreEngines && this.workspaceStatus.gitIgnoreEngines.get
1427
+ ? this.workspaceStatus.gitIgnoreEngines.get(workspaceName) || null
1428
+ : null
1429
+ if (parts.length > 1) {
1430
+ workspaceRelBase = parts.slice(1).join('/')
1431
+ }
1432
+ }
1397
1433
  }
1434
+ } catch (_) {
1398
1435
  }
1399
- if (normalized.includes('/site-packages/')) {
1400
- return false
1401
- }
1402
- if (normalized.includes('/Scripts/')) {
1403
- return false
1404
- }
1405
- if (normalized.includes('/bin/activate')) {
1406
- return false
1407
- }
1408
- return true
1409
- }
1410
-
1411
- let statusMatrix = await git.statusMatrix({ dir, fs })
1412
- statusMatrix = statusMatrix.filter(Boolean)
1413
1436
 
1414
- let headOid = null
1415
- const getHeadOid = async () => {
1416
- if (headOid) return headOid
1417
- headOid = await git.resolveRef({ fs, dir, ref: 'HEAD' })
1418
- return headOid
1419
- }
1420
- const readNormalized = async (source, filepath) => {
1421
- if (source === 'head') {
1422
- const oid = await getHeadOid()
1423
- const { blob } = await git.readBlob({ fs, dir, oid, filepath })
1424
- return normalize(Buffer.from(blob).toString('utf8'))
1425
- } else {
1426
- const content = await fs.promises.readFile(path.join(dir, filepath), 'utf8')
1427
- return normalize(content)
1437
+ const normalizePath = (p) => p.replace(/\\/g, '/').replace(/\/+/g, '/')
1438
+ const shouldIncludePath = (relativePath) => {
1439
+ if (!relativePath) {
1440
+ return true
1441
+ }
1442
+ const normalized = normalizePath(relativePath)
1443
+ if (this.gitStatusIgnorePatterns && this.gitStatusIgnorePatterns.some((regex) => regex.test(normalized) || regex.test(`${normalized}/`))) {
1444
+ return false
1445
+ }
1446
+ if (normalized.includes('/site-packages/')) {
1447
+ return false
1448
+ }
1449
+ if (normalized.includes('/Scripts/')) {
1450
+ return false
1451
+ }
1452
+ if (normalized.includes('/bin/activate')) {
1453
+ return false
1454
+ }
1455
+ return true
1428
1456
  }
1429
- }
1430
1457
 
1431
- const changes = []
1432
- for (const [filepath, head, workdir, stage] of statusMatrix) {
1433
- if (!shouldIncludePath(filepath)) {
1434
- continue
1435
- }
1436
- if (head === workdir && head === stage) {
1437
- continue
1438
- }
1439
- const absolutePath = path.join(dir, filepath)
1440
- let stats
1458
+ let stdout = ""
1441
1459
  try {
1442
- stats = await fs.promises.stat(absolutePath)
1460
+ const env = this.kernel && this.kernel.envs
1461
+ ? this.kernel.envs
1462
+ : (this.kernel && this.kernel.bin && typeof this.kernel.bin.envs === 'function'
1463
+ ? this.kernel.bin.envs(process.env)
1464
+ : process.env)
1465
+ stdout = await new Promise((resolve) => {
1466
+ execFile(
1467
+ 'git',
1468
+ ['status', '--porcelain=v1', '--untracked-files=all'],
1469
+ {
1470
+ cwd: dir,
1471
+ env,
1472
+ maxBuffer: 10 * 1024 * 1024,
1473
+ },
1474
+ (err, out) => {
1475
+ if (err) {
1476
+ console.warn('[git] status failed', dir, err && err.message ? err.message : err)
1477
+ resolve("")
1478
+ return
1479
+ }
1480
+ resolve(out || "")
1481
+ }
1482
+ )
1483
+ })
1443
1484
  } catch (error) {
1444
- stats = null
1445
- }
1446
- if (stats && stats.isDirectory()) {
1447
- continue
1485
+ console.warn('[git] status threw', dir, error && error.message ? error.message : error)
1486
+ stdout = ""
1448
1487
  }
1488
+ const lines = stdout.split(/\r?\n/).filter((line) => line && line.trim().length > 0)
1449
1489
 
1450
- const status = Util.classifyChange(head, workdir, stage)
1451
- if (!status) {
1452
- continue
1453
- }
1454
-
1455
- // Skip entries where HEAD and worktree match after normalization
1456
- if (status && status.startsWith('modified')) {
1457
- try {
1458
- const headContent = await readNormalized('head', filepath)
1459
- const worktreeContent = await readNormalized('worktree', filepath)
1460
- if (headContent === worktreeContent) {
1490
+ const changes = []
1491
+ for (const line of lines) {
1492
+ if (line.length < 3) {
1493
+ continue
1494
+ }
1495
+ const x = line[0]
1496
+ const y = line[1]
1497
+ if (x === '!' && y === '!') {
1498
+ continue
1499
+ }
1500
+ let rest = line.slice(3)
1501
+ let filepath
1502
+ const renameIdx = rest.indexOf(' -> ')
1503
+ if (renameIdx !== -1) {
1504
+ filepath = rest.slice(renameIdx + 4)
1505
+ } else {
1506
+ filepath = rest
1507
+ }
1508
+ if (!filepath) {
1509
+ continue
1510
+ }
1511
+ if (gitIgnoreEngine && workspaceName) {
1512
+ const workspaceRelative = workspaceRelBase ? `${workspaceRelBase}/${filepath}` : filepath
1513
+ const normalizedWorkspaceRel = normalizePath(workspaceRelative)
1514
+ if (gitIgnoreEngine.ignores(normalizedWorkspaceRel)) {
1461
1515
  continue
1462
1516
  }
1463
- } catch (_) {
1464
- // fall through if comparison fails
1465
1517
  }
1466
- }
1518
+ if (!shouldIncludePath(filepath)) {
1519
+ continue
1520
+ }
1521
+ const normalizedFile = normalizePath(filepath)
1522
+ const absolutePath = path.join(dir, filepath)
1523
+ let stats
1524
+ try {
1525
+ stats = await fs.promises.stat(absolutePath)
1526
+ } catch (error) {
1527
+ stats = null
1528
+ }
1529
+ if (stats && stats.isDirectory()) {
1530
+ continue
1531
+ }
1532
+
1533
+ let status
1534
+ if (x === '?' && y === '?') {
1535
+ status = 'new (untracked)'
1536
+ } else if (x === 'R' || y === 'R') {
1537
+ status = 'renamed'
1538
+ } else if (x === 'C' || y === 'C') {
1539
+ status = 'copied'
1540
+ } else if (x === 'A' || y === 'A') {
1541
+ status = 'added (staged)'
1542
+ } else if (x === 'D' || y === 'D') {
1543
+ status = 'deleted'
1544
+ } else if (x === 'M' || y === 'M') {
1545
+ if (x === 'M' && y === 'M') {
1546
+ status = 'modified (staged + unstaged)'
1547
+ } else if (x === 'M') {
1548
+ status = 'modified (staged)'
1549
+ } else {
1550
+ status = 'modified (unstaged)'
1551
+ }
1552
+ } else {
1553
+ status = `unknown (${x}${y})`
1554
+ }
1467
1555
 
1468
- const webpath = "/asset/" + path.relative(this.kernel.homedir, absolutePath)
1556
+ const webpath = "/asset/" + path.relative(this.kernel.homedir, absolutePath)
1469
1557
 
1470
- changes.push({
1471
- ref: 'HEAD',
1472
- webpath,
1473
- file: normalizePath(filepath),
1474
- path: absolutePath,
1475
- diffpath: `/gitdiff/HEAD/${repoParam}/${normalizePath(filepath)}`,
1476
- status,
1477
- })
1478
- }
1558
+ changes.push({
1559
+ ref: 'HEAD',
1560
+ webpath,
1561
+ file: normalizedFile,
1562
+ path: absolutePath,
1563
+ diffpath: `/gitdiff/HEAD/${repoParam}/${normalizedFile}`,
1564
+ status,
1565
+ })
1566
+ }
1479
1567
 
1480
- const repoHistoryUrl = repoParam ? `/info/git/HEAD/${repoParam}` : null
1568
+ const repoHistoryUrl = repoParam ? `/info/git/HEAD/${repoParam}` : null
1481
1569
 
1482
- const forkUrl = `/run/scripts/git/fork.json?cwd=${encodeURIComponent(dir)}`
1483
- const pushUrl = `/run/scripts/git/push.json?cwd=${encodeURIComponent(dir)}`
1570
+ const forkUrl = `/run/scripts/git/fork.json?cwd=${encodeURIComponent(dir)}`
1571
+ const pushUrl = `/run/scripts/git/push.json?cwd=${encodeURIComponent(dir)}`
1484
1572
 
1485
- return {
1486
- changes,
1487
- git_commit_url: `/run/scripts/git/commit.json?cwd=${dir}&callback_target=parent&callback=$location.href`,
1488
- git_history_url: repoHistoryUrl,
1489
- git_fork_url: forkUrl,
1490
- git_push_url: pushUrl,
1491
- }
1573
+ return {
1574
+ changes,
1575
+ git_commit_url: `/run/scripts/git/commit.json?cwd=${dir}&callback_target=parent&callback=$location.href`,
1576
+ git_history_url: repoHistoryUrl,
1577
+ git_fork_url: forkUrl,
1578
+ git_push_url: pushUrl,
1579
+ }
1492
1580
  }
1493
1581
  async computeWorkspaceGitStatus(workspaceName) {
1494
1582
  const workspacePath = this.kernel.path("api", workspaceName)
1495
1583
  const repos = await this.kernel.git.repos(workspacePath)
1496
1584
 
1497
- // await Util.ignore_subrepos(workspacePath, repos)
1498
-
1499
1585
  const statuses = []
1500
1586
  for (const repo of repos) {
1501
1587
  const repoParam = repo.gitParentRelPath || workspaceName
@@ -4240,6 +4326,7 @@ class Server {
4240
4326
  let str = await Environment.ENV("system", this.kernel.homedir, this.kernel)
4241
4327
  await fs.promises.writeFile(path.resolve(this.kernel.homedir, "ENVIRONMENT"), str)
4242
4328
  }
4329
+ this.workspaceStatus.ensureWatcher('api', this.kernel.path('api')).catch(() => {})
4243
4330
  }
4244
4331
 
4245
4332
 
@@ -4523,6 +4610,634 @@ class Server {
4523
4610
  list,
4524
4611
  })
4525
4612
  }))
4613
+ this.app.get("/checkpoints", ex(async (req, res) => {
4614
+ const registryBetaEnabled = await this.isRegistryBetaEnabled().catch(() => false)
4615
+ const peerAccess = await this.composePeerAccessPayload()
4616
+ const list = this.getPeers()
4617
+ const history = this.kernel.git && this.kernel.git.history ? this.kernel.git.history : { version: "1", apps: {} }
4618
+
4619
+ const normalizeSha256Digest = (raw) => {
4620
+ if (!raw) return null
4621
+ const str = String(raw).trim()
4622
+ if (!str) return null
4623
+ const m = str.match(/^sha256:([0-9a-f]{64})$/i)
4624
+ if (m) return `sha256:${m[1].toLowerCase()}`
4625
+ const m2 = str.match(/^([0-9a-f]{64})$/i)
4626
+ if (m2) return `sha256:${m2[1].toLowerCase()}`
4627
+ return null
4628
+ }
4629
+
4630
+ const importHash = normalizeSha256Digest(typeof req.query.hash === "string" ? req.query.hash : "")
4631
+ const importRegistryRaw = typeof req.query.registry === "string" ? req.query.registry.trim() : ""
4632
+ let autoInstall = null
4633
+ let importError = null
4634
+ if (importHash) {
4635
+ let registryUrl = importRegistryRaw
4636
+ if (!registryUrl) {
4637
+ try {
4638
+ const registry = await this.getRegistryConfig()
4639
+ registryUrl = registry && registry.url ? String(registry.url) : ""
4640
+ } catch (_) {}
4641
+ }
4642
+ registryUrl = (registryUrl || "").replace(/\/$/, "")
4643
+ if (!registryUrl) {
4644
+ importError = "Missing registry URL"
4645
+ } else {
4646
+
4647
+ try {
4648
+ const response = await axios.get(`${registryUrl}/checkpoints/${encodeURIComponent(importHash)}`, {
4649
+ headers: { Accept: "application/json" },
4650
+ timeout: 15000,
4651
+ })
4652
+ const detail = response && response.data ? response.data : null
4653
+ const checkpoint = detail && detail.checkpoint && typeof detail.checkpoint === "object" ? detail.checkpoint : null
4654
+ if (!checkpoint) throw new Error("Invalid checkpoint payload")
4655
+
4656
+ const hashed = this.kernel.git.hashCheckpoint(checkpoint)
4657
+ if (!hashed || !hashed.canonical || !hashed.digest) throw new Error("Invalid checkpoint payload")
4658
+ if (String(hashed.digest) !== importHash) throw new Error("Checkpoint hash mismatch")
4659
+
4660
+ // Persist commit metadata (subject/authorName/committedAt) returned by the registry for richer UI.
4661
+ const commitInfo = detail && detail.commitInfo && typeof detail.commitInfo === "object" ? detail.commitInfo : null
4662
+ if (commitInfo && hashed.canonical && Array.isArray(hashed.canonical.repos)) {
4663
+ for (const repo of hashed.canonical.repos) {
4664
+ if (!repo || !repo.repo || !repo.commit) continue
4665
+ const key = `${repo.repo}@${repo.commit}`
4666
+ const info = commitInfo[key]
4667
+ if (!info || typeof info !== "object") continue
4668
+ try {
4669
+ this.kernel.git.upsertCommitMeta(repo.repo, repo.commit, {
4670
+ subject: info.subject || null,
4671
+ authorName: info.authorName || null,
4672
+ committedAt: info.committedAt || null,
4673
+ })
4674
+ } catch (_) {}
4675
+ }
4676
+ }
4677
+
4678
+ const rootRemote = hashed.canonical.root
4679
+ const remoteEntry = this.kernel.git.ensureApp(rootRemote)
4680
+ if (!remoteEntry) throw new Error("Invalid checkpoint root")
4681
+ const { remoteKey, entry } = remoteEntry
4682
+
4683
+ let snapshotId = null
4684
+ if (entry && Array.isArray(entry.checkpoints)) {
4685
+ const existing = entry.checkpoints.find((c) => c && String(c.hash) === importHash && c.id != null)
4686
+ if (existing) snapshotId = String(existing.id)
4687
+ }
4688
+
4689
+ if (!snapshotId) {
4690
+ snapshotId = String(Date.now())
4691
+ await this.kernel.git.writeCheckpointPayload(remoteKey, rootRemote, {
4692
+ checkpoint: hashed.canonical,
4693
+ id: snapshotId,
4694
+ visibility: "public",
4695
+ system: null,
4696
+ })
4697
+ } else {
4698
+ const filePath = this.kernel.git.checkpointFilePath(importHash)
4699
+ if (filePath) {
4700
+ try {
4701
+ await fs.promises.access(filePath, fs.constants.F_OK)
4702
+ } catch (_) {
4703
+ await fs.promises.mkdir(path.dirname(filePath), { recursive: true }).catch(() => {})
4704
+ await fs.promises.writeFile(filePath, JSON.stringify(hashed.canonical, null, 2))
4705
+ }
4706
+ }
4707
+ }
4708
+
4709
+ await this.kernel.git.setCheckpointSync(remoteKey, snapshotId, {
4710
+ status: "imported",
4711
+ at: Date.now(),
4712
+ source: registryUrl,
4713
+ hash: importHash,
4714
+ }).catch(() => {})
4715
+ autoInstall = { remoteKey, snapshotId, hash: importHash }
4716
+ } catch (err) {
4717
+ importError = err && err.message ? err.message : String(err)
4718
+ }
4719
+ }
4720
+ }
4721
+
4722
+ const apps = history && history.apps ? history.apps : {}
4723
+ const apiRoot = this.kernel.path("api")
4724
+ const checkpointsDir = (this.kernel && this.kernel.git && typeof this.kernel.git.checkpointsDir === "function")
4725
+ ? this.kernel.git.checkpointsDir()
4726
+ : null
4727
+ const foldersByRemote = {}
4728
+ try {
4729
+ const entries = await fs.promises.readdir(apiRoot, { withFileTypes: true })
4730
+ for (const entry of entries) {
4731
+ if (!entry || !entry.isDirectory()) continue
4732
+ const folderName = entry.name
4733
+ const workspaceRoot = this.kernel.path("api", folderName)
4734
+ let remote = null
4735
+ try {
4736
+ remote = await git.getConfig({
4737
+ fs,
4738
+ http,
4739
+ dir: workspaceRoot,
4740
+ path: 'remote.origin.url'
4741
+ })
4742
+ } catch (_) {}
4743
+ if (!remote) continue
4744
+ let headHash = null
4745
+ try {
4746
+ const head = await this.kernel.git.getHead(workspaceRoot)
4747
+ headHash = head && head.hash ? head.hash : null
4748
+ } catch (_) {}
4749
+ const remoteKey = this.kernel.git.normalizeRemote(remote)
4750
+ if (!remoteKey) continue
4751
+ if (!foldersByRemote[remoteKey]) foldersByRemote[remoteKey] = []
4752
+ foldersByRemote[remoteKey].push({ name: folderName, head: headHash })
4753
+ }
4754
+ } catch (_) {}
4755
+ const items = []
4756
+ for (const [remoteKey, entry] of Object.entries(apps)) {
4757
+ const folders = Array.isArray(foldersByRemote[remoteKey])
4758
+ ? foldersByRemote[remoteKey].slice().sort((a, b) => a.name.localeCompare(b.name))
4759
+ : []
4760
+ const snapshots = await this.kernel.git.listSnapshotsForRemote(remoteKey)
4761
+ const installedBySnapshot = {}
4762
+ for (const snap of snapshots) {
4763
+ const mainRepo = snap.repos.find((repo) => repo && repo.path === ".")
4764
+ if (!mainRepo || !mainRepo.commit) continue
4765
+ const matches = folders.filter((f) => f.head && f.head === mainRepo.commit).map((f) => f.name)
4766
+ if (matches.length > 0) {
4767
+ installedBySnapshot[snap.id] = matches
4768
+ }
4769
+ }
4770
+ let icon = null
4771
+ let title = null
4772
+ let description = ""
4773
+ if (this.kernel && this.kernel.api && typeof this.kernel.api.meta === "function") {
4774
+ for (const snap of snapshots) {
4775
+ const installedFolders = installedBySnapshot[snap.id]
4776
+ if (installedFolders && installedFolders.length > 0) {
4777
+ const folderName = installedFolders[0]
4778
+ try {
4779
+ const meta = await this.kernel.api.meta(folderName)
4780
+ if (meta && typeof meta === "object") {
4781
+ if (meta.icon) {
4782
+ icon = meta.icon
4783
+ }
4784
+ if (typeof meta.title === "string" && meta.title.trim()) {
4785
+ title = meta.title.trim()
4786
+ }
4787
+ if (typeof meta.description === "string" && meta.description.trim()) {
4788
+ description = meta.description.trim()
4789
+ }
4790
+ }
4791
+ } catch (_) {}
4792
+ break
4793
+ }
4794
+ }
4795
+ }
4796
+ items.push({
4797
+ remoteKey,
4798
+ remoteUrl: entry.remote || remoteKey,
4799
+ displayName: entry.remote || remoteKey,
4800
+ folders,
4801
+ snapshots,
4802
+ installedBySnapshot,
4803
+ icon,
4804
+ title,
4805
+ description,
4806
+ })
4807
+ }
4808
+ items.sort((a, b) => a.displayName.localeCompare(b.displayName))
4809
+ res.render("checkpoints", {
4810
+ current_host: this.kernel.peer.host,
4811
+ ...peerAccess,
4812
+ history,
4813
+ items,
4814
+ autoInstall,
4815
+ importError,
4816
+ checkpointsDir,
4817
+ registryBetaEnabled,
4818
+ portal: this.portal,
4819
+ logo: this.logo,
4820
+ theme: this.theme,
4821
+ agent: req.agent,
4822
+ list,
4823
+ })
4824
+ }))
4825
+ this.app.get("/checkpoints/registry_beta", ex(async (req, res) => {
4826
+ const registryBetaEnabled = await this.isRegistryBetaEnabled().catch(() => false)
4827
+ const registryConnectUrl = await this.getRegistryConnectUrl().catch(() => null)
4828
+ const registryUrlPrefill = typeof req.query.registry_url === "string" ? req.query.registry_url.trim() : ""
4829
+ res.render("checkpoints_registry_beta", {
4830
+ registryBetaEnabled,
4831
+ registryConnectUrl,
4832
+ registryUrlPrefill,
4833
+ theme: this.theme,
4834
+ agent: req.agent,
4835
+ })
4836
+ }))
4837
+
4838
+ // Registry linking endpoint
4839
+ this.app.get("/registry/link", ex(async (req, res) => {
4840
+ const { token, registry, app: appOrigin, next: nextRaw } = req.query
4841
+
4842
+ if (!token || !registry) {
4843
+ res.status(400).render("registry_link", {
4844
+ ok: false,
4845
+ message: "Missing token or registry parameters",
4846
+ registryUrl: registry ? String(registry) : null,
4847
+ })
4848
+ return
4849
+ }
4850
+
4851
+ const registryUrl = String(registry)
4852
+ let connectUrl = null
4853
+ let redirectUrl = null
4854
+ try {
4855
+ if (appOrigin) {
4856
+ const u = new URL(String(appOrigin))
4857
+ u.pathname = "/connect/pinokio"
4858
+ u.search = ""
4859
+ u.hash = ""
4860
+ connectUrl = u.toString().replace(/\/$/, "")
4861
+ }
4862
+ } catch (_) {}
4863
+
4864
+ if (connectUrl) {
4865
+ try {
4866
+ const u = new URL(connectUrl)
4867
+ u.searchParams.set("linked", "1")
4868
+ redirectUrl = u.toString()
4869
+ } catch (_) {}
4870
+ }
4871
+
4872
+ if (appOrigin && nextRaw) {
4873
+ try {
4874
+ const base = new URL(String(appOrigin))
4875
+ const nextVal = String(nextRaw)
4876
+ const candidate = new URL(nextVal, base.toString())
4877
+ if (candidate.origin === base.origin) redirectUrl = candidate.toString()
4878
+ } catch (_) {}
4879
+ }
4880
+
4881
+ try {
4882
+ // Exchange token for API key at the specified registry
4883
+ const response = await axios.post(
4884
+ `${registryUrl}/registry/exchange`,
4885
+ { token: String(token) },
4886
+ { headers: { 'Content-Type': 'application/json' } }
4887
+ )
4888
+
4889
+ const { apiKey, registryUrl: confirmedUrl } = response.data
4890
+
4891
+ const envUpdates = {
4892
+ PINOKIO_REGISTRY_API_KEY: apiKey,
4893
+ PINOKIO_REGISTRY_URL: confirmedUrl || registryUrl
4894
+ }
4895
+ if (connectUrl) {
4896
+ envUpdates.PINOKIO_REGISTRY_CONNECT_URL = connectUrl
4897
+ envUpdates.PINOKIO_REGISTRY_CONNECT_ENDPOINT = ""
4898
+ }
4899
+ await this.updateEnvironmentVars(envUpdates)
4900
+
4901
+ if (redirectUrl) {
4902
+ res.redirect(redirectUrl)
4903
+ return
4904
+ }
4905
+
4906
+ res.render("registry_link", { ok: true, registryUrl: confirmedUrl || registryUrl })
4907
+ } catch (error) {
4908
+ console.log("Error", error)
4909
+ if (connectUrl) {
4910
+ try {
4911
+ const u = new URL(connectUrl)
4912
+ u.searchParams.set("error", "1")
4913
+ const msg = error && error.message ? String(error.message) : String(error)
4914
+ u.searchParams.set("message", msg.slice(0, 240))
4915
+ res.redirect(u.toString())
4916
+ return
4917
+ } catch (_) {}
4918
+ }
4919
+ res.status(500).render("registry_link", {
4920
+ ok: false,
4921
+ message: error && error.message ? error.message : String(error),
4922
+ registryUrl,
4923
+ })
4924
+ }
4925
+ }))
4926
+
4927
+ this.app.get("/api/registry/status", ex(async (req, res) => {
4928
+ const registry = await this.getRegistryConfig()
4929
+ const baseUrl = registry && registry.url ? String(registry.url).replace(/\/$/, '') : null
4930
+ const apiKey = registry && registry.apiKey ? String(registry.apiKey) : null
4931
+ res.json({ linked: !!apiKey, url: baseUrl })
4932
+ }))
4933
+
4934
+ this.app.post("/checkpoints/registry_beta", ex(async (req, res) => {
4935
+ const rawValue = Object.prototype.hasOwnProperty.call(req.body || {}, "enabled")
4936
+ ? req.body.enabled
4937
+ : (Object.prototype.hasOwnProperty.call(req.query || {}, "enabled") ? req.query.enabled : null)
4938
+ const hasConnectField = Object.prototype.hasOwnProperty.call(req.body || {}, "connectUrl")
4939
+ || Object.prototype.hasOwnProperty.call(req.body || {}, "connectEndpoint")
4940
+ || Object.prototype.hasOwnProperty.call(req.body || {}, "registry_url")
4941
+ || Object.prototype.hasOwnProperty.call(req.query || {}, "connectUrl")
4942
+ || Object.prototype.hasOwnProperty.call(req.query || {}, "connectEndpoint")
4943
+ || Object.prototype.hasOwnProperty.call(req.query || {}, "registry_url")
4944
+ const connectRaw = Object.prototype.hasOwnProperty.call(req.body || {}, "connectUrl")
4945
+ ? req.body.connectUrl
4946
+ : (Object.prototype.hasOwnProperty.call(req.body || {}, "registry_url")
4947
+ ? req.body.registry_url
4948
+ : (Object.prototype.hasOwnProperty.call(req.body || {}, "connectEndpoint")
4949
+ ? req.body.connectEndpoint
4950
+ : (Object.prototype.hasOwnProperty.call(req.query || {}, "connectUrl")
4951
+ ? req.query.connectUrl
4952
+ : (Object.prototype.hasOwnProperty.call(req.query || {}, "registry_url")
4953
+ ? req.query.registry_url
4954
+ : (Object.prototype.hasOwnProperty.call(req.query || {}, "connectEndpoint")
4955
+ ? req.query.connectEndpoint
4956
+ : null)))))
4957
+ const connectUrl = connectRaw != null ? String(connectRaw).trim() : ""
4958
+ const parsed = this.parseRegistryBetaValue(rawValue)
4959
+ if (parsed == null) {
4960
+ res.status(400).json({ ok: false, error: "Invalid enabled value" })
4961
+ return
4962
+ }
4963
+ const updates = {
4964
+ PINOKIO_REGISTRY_BETA: parsed ? "1" : ""
4965
+ }
4966
+ if (hasConnectField) {
4967
+ updates.PINOKIO_REGISTRY_CONNECT_URL = connectUrl
4968
+ updates.PINOKIO_REGISTRY_CONNECT_ENDPOINT = ""
4969
+ }
4970
+ await this.updateEnvironmentVars(updates)
4971
+ res.json({ ok: true, enabled: parsed, connectUrl: connectUrl || null })
4972
+ }))
4973
+
4974
+ this.app.post("/checkpoints/publish", ex(async (req, res) => {
4975
+ const registryBetaEnabled = await this.isRegistryBetaEnabled().catch(() => false)
4976
+ if (!registryBetaEnabled) {
4977
+ res.status(404).json({ ok: false, error: "Not found" })
4978
+ return
4979
+ }
4980
+ const snapshotRaw = typeof req.query.snapshotId === 'string' || typeof req.query.snapshotId === 'number' ? req.query.snapshotId : ''
4981
+ const snapshotId = snapshotRaw === 'latest' ? 'latest' : String(snapshotRaw || '')
4982
+ if (!snapshotId) {
4983
+ res.status(400).json({ ok: false, error: "Missing snapshotId" })
4984
+ return
4985
+ }
4986
+ const payload = await this.kernel.git.readCheckpointPayload(snapshotId)
4987
+ if (!payload || !payload.app || !payload.hash) {
4988
+ res.status(404).json({ ok: false, error: "Snapshot not found" })
4989
+ return
4990
+ }
4991
+
4992
+ const registry = await this.getRegistryConfig()
4993
+ const baseUrl = registry && registry.url ? String(registry.url).replace(/\/$/, '') : null
4994
+ const apiKey = registry && registry.apiKey ? String(registry.apiKey) : null
4995
+ const connectUrl = await this.getRegistryConnectUrl().catch(() => null)
4996
+
4997
+ if (!baseUrl || !apiKey) {
4998
+ await this.kernel.git.setCheckpointSync(payload.app, snapshotId, { status: "needs_link", at: Date.now() }).catch(() => {})
4999
+ res.json({ ok: true, publish: { ok: false, code: "not_linked", connectUrl } })
5000
+ return
5001
+ }
5002
+
5003
+ try {
5004
+ await this.kernel.git.setCheckpointSync(payload.app, snapshotId, { status: "syncing", at: Date.now() })
5005
+ const filePath = this.kernel.git.checkpointFilePath(payload.hash)
5006
+ const checkpoint = filePath ? JSON.parse(await fs.promises.readFile(filePath, "utf8")) : null
5007
+ const system = payload.system && typeof payload.system === "object" ? payload.system : {
5008
+ platform: payload.platform || null,
5009
+ arch: payload.arch || null,
5010
+ gpu: payload.gpu || null,
5011
+ ram: typeof payload.ram === "number" ? payload.ram : null,
5012
+ vram: typeof payload.vram === "number" ? payload.vram : null,
5013
+ }
5014
+ const response = await axios.post(
5015
+ `${baseUrl}/checkpoints`,
5016
+ {
5017
+ hash: String(payload.hash),
5018
+ visibility: "public",
5019
+ checkpoint,
5020
+ system,
5021
+ },
5022
+ { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` } }
5023
+ )
5024
+ const remoteId = response && response.data
5025
+ ? (response.data.checkpoint && response.data.checkpoint.id ? response.data.checkpoint.id : (response.data.id ? response.data.id : null))
5026
+ : null
5027
+ const viewUrl = await this.getRegistryViewUrl(payload.hash)
5028
+ await this.kernel.git.setCheckpointSync(payload.app, snapshotId, { status: "published", at: Date.now(), remoteId })
5029
+ await this.kernel.git.setCheckpointDecision(payload.app, snapshotId, "published").catch(() => {})
5030
+ res.json({ ok: true, publish: { ok: true, remoteId, hash: payload.hash, url: viewUrl } })
5031
+ } catch (error) {
5032
+ const status = error && error.response && error.response.status ? Number(error.response.status) : null
5033
+ const message = error && error.message ? error.message : String(error)
5034
+ if (status === 401 || status === 403) {
5035
+ await this.updateEnvironmentVars({ PINOKIO_REGISTRY_API_KEY: "" }).catch(() => {})
5036
+ await this.kernel.git.setCheckpointSync(payload.app, snapshotId, { status: "needs_link", at: Date.now(), error: message }).catch(() => {})
5037
+ res.json({ ok: true, publish: { ok: false, code: "not_linked", connectUrl } })
5038
+ return
5039
+ }
5040
+ await this.kernel.git.setCheckpointSync(payload.app, snapshotId, { status: "error", at: Date.now(), error: message }).catch(() => {})
5041
+ res.json({ ok: true, publish: { ok: false, code: "error", error: message } })
5042
+ }
5043
+ }))
5044
+
5045
+ this.app.post("/checkpoints/decision", ex(async (req, res) => {
5046
+ const registryBetaEnabled = await this.isRegistryBetaEnabled().catch(() => false)
5047
+ if (!registryBetaEnabled) {
5048
+ res.status(404).json({ ok: false, error: "Not found" })
5049
+ return
5050
+ }
5051
+ const snapshotRaw = typeof req.query.snapshotId === 'string' || typeof req.query.snapshotId === 'number' ? req.query.snapshotId : ''
5052
+ const snapshotId = snapshotRaw === 'latest' ? 'latest' : String(snapshotRaw || '')
5053
+ const decisionRaw = typeof req.query.decision === 'string' ? req.query.decision.trim().toLowerCase() : ''
5054
+ if (!snapshotId || !decisionRaw) {
5055
+ res.status(400).json({ ok: false, error: "Missing parameters" })
5056
+ return
5057
+ }
5058
+ if (decisionRaw !== "later") {
5059
+ res.status(400).json({ ok: false, error: "Invalid decision" })
5060
+ return
5061
+ }
5062
+ const payload = await this.kernel.git.readCheckpointPayload(snapshotId)
5063
+ if (!payload || !payload.app) {
5064
+ res.status(404).json({ ok: false, error: "Snapshot not found" })
5065
+ return
5066
+ }
5067
+ const ok = await this.kernel.git.setCheckpointDecision(payload.app, snapshotId, decisionRaw)
5068
+ res.json({ ok: !!ok })
5069
+ }))
5070
+
5071
+ this.app.post("/checkpoints/snapshot", ex(async (req, res) => {
5072
+ const name = typeof req.query.workspace === 'string' ? req.query.workspace.trim() : ''
5073
+ if (!name) {
5074
+ res.json({ ok: false })
5075
+ return
5076
+ }
5077
+ const registryBetaEnabled = await this.isRegistryBetaEnabled().catch(() => false)
5078
+ const publishRaw = typeof req.query.publish === 'string' ? req.query.publish.trim().toLowerCase() : ''
5079
+ const wantPublish = registryBetaEnabled && (publishRaw === '1' || publishRaw === 'true' || publishRaw === 'cloud')
5080
+ const root = this.kernel.path("api", name)
5081
+ const repos = await this.kernel.git.repos(root)
5082
+ const created = await this.kernel.git.appendWorkspaceSnapshot(name, repos)
5083
+ if (registryBetaEnabled && created && created.remoteKey && created.id) {
5084
+ const existingDecision = this.kernel.git.getCheckpointDecision(created.remoteKey, created.id)
5085
+ if (!existingDecision) {
5086
+ await this.kernel.git.setCheckpointDecision(created.remoteKey, created.id, "pending").catch(() => {})
5087
+ }
5088
+ }
5089
+ if (created && created.remoteKey && created.id && created.hash) {
5090
+ if (wantPublish) {
5091
+ const registry = await this.getRegistryConfig()
5092
+ const baseUrl = registry && registry.url ? String(registry.url).replace(/\/$/, '') : null
5093
+ const apiKey = registry && registry.apiKey ? String(registry.apiKey) : null
5094
+ const connectUrl = await this.getRegistryConnectUrl().catch(() => null)
5095
+ if (!baseUrl || !apiKey) {
5096
+ await this.kernel.git.setCheckpointSync(created.remoteKey, created.id, { status: "needs_link", at: Date.now() }).catch(() => {})
5097
+ res.json({ ok: true, created: created || null, publish: { ok: false, code: "not_linked", connectUrl } })
5098
+ return
5099
+ }
5100
+ try {
5101
+ await this.kernel.git.setCheckpointSync(created.remoteKey, created.id, { status: "syncing", at: Date.now() })
5102
+ const snapshot = await this.kernel.git.readCheckpointPayload(created.id)
5103
+ const filePath = this.kernel.git.checkpointFilePath(created.hash)
5104
+ const checkpoint = filePath ? JSON.parse(await fs.promises.readFile(filePath, "utf8")) : null
5105
+ const system = snapshot && snapshot.system && typeof snapshot.system === "object" ? snapshot.system : null
5106
+ const response = await axios.post(
5107
+ `${baseUrl}/checkpoints`,
5108
+ {
5109
+ hash: String(created.hash),
5110
+ visibility: "public",
5111
+ checkpoint,
5112
+ system,
5113
+ },
5114
+ { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` } }
5115
+ )
5116
+ const remoteId = response && response.data
5117
+ ? (response.data.checkpoint && response.data.checkpoint.id ? response.data.checkpoint.id : (response.data.id ? response.data.id : null))
5118
+ : null
5119
+ const viewUrl = await this.getRegistryViewUrl(created.hash)
5120
+ await this.kernel.git.setCheckpointSync(created.remoteKey, created.id, { status: "published", at: Date.now(), remoteId })
5121
+ await this.kernel.git.setCheckpointDecision(created.remoteKey, created.id, "published").catch(() => {})
5122
+ res.json({ ok: true, created: created || null, publish: { ok: true, remoteId, hash: created.hash, url: viewUrl } })
5123
+ return
5124
+ } catch (error) {
5125
+ const status = error && error.response && error.response.status ? Number(error.response.status) : null
5126
+ const message = error && error.message ? error.message : String(error)
5127
+ if (status === 401 || status === 403) {
5128
+ await this.updateEnvironmentVars({ PINOKIO_REGISTRY_API_KEY: "" }).catch(() => {})
5129
+ await this.kernel.git.setCheckpointSync(created.remoteKey, created.id, { status: "needs_link", at: Date.now(), error: message }).catch(() => {})
5130
+ res.json({ ok: true, created: created || null, publish: { ok: false, code: "not_linked", connectUrl } })
5131
+ return
5132
+ }
5133
+ await this.kernel.git.setCheckpointSync(created.remoteKey, created.id, { status: "error", at: Date.now(), error: message }).catch(() => {})
5134
+ res.json({ ok: true, created: created || null, publish: { ok: false, code: "error", error: message } })
5135
+ return
5136
+ }
5137
+ }
5138
+ }
5139
+ res.json({ ok: true, created: created || null })
5140
+ }))
5141
+ this.app.post("/checkpoints/install", ex(async (req, res) => {
5142
+ const remote = typeof req.body.remote === 'string' ? req.body.remote.trim() : ''
5143
+ const folder = typeof req.body.folder === 'string' ? req.body.folder.trim() : ''
5144
+ const snapshotRaw = typeof req.body.snapshotId === 'string' || typeof req.body.snapshotId === 'number' ? req.body.snapshotId : ''
5145
+ const snapshotId = snapshotRaw === 'latest' ? 'latest' : String(snapshotRaw)
5146
+ if (!remote || !folder || (!snapshotRaw && snapshotRaw !== 0)) {
5147
+ res.status(400).json({ ok: false, error: "Missing parameters" })
5148
+ return
5149
+ }
5150
+ if (folder.includes('/') || folder.includes('\\')) {
5151
+ res.status(400).json({ ok: false, error: "Invalid folder name" })
5152
+ return
5153
+ }
5154
+ const apiRoot = this.kernel.path("api")
5155
+ const targetPath = this.kernel.path("api", folder)
5156
+ let exists = false
5157
+ try {
5158
+ await fs.promises.access(targetPath, fs.constants.F_OK)
5159
+ exists = true
5160
+ } catch (_) {}
5161
+ if (exists) {
5162
+ res.json({ ok: false, code: "exists", error: "Folder already exists" })
5163
+ return
5164
+ }
5165
+ await fs.promises.mkdir(apiRoot, { recursive: true }).catch(() => {})
5166
+ if (snapshotId === 'latest') {
5167
+ const safeRemote = remote.replace(/"/g, '\\"')
5168
+ try {
5169
+ await this.kernel.exec({
5170
+ message: [`git clone "${safeRemote}" "${folder}"`],
5171
+ path: apiRoot
5172
+ }, () => {})
5173
+ } catch (err) {
5174
+ await fs.promises.rm(targetPath, { recursive: true, force: true }).catch(() => {})
5175
+ res.json({ ok: false, error: err && err.message ? err.message : 'Clone failed' })
5176
+ return
5177
+ }
5178
+ res.json({ ok: true, redirect: `/p/${encodeURIComponent(folder)}` })
5179
+ return
5180
+ }
5181
+ if (snapshotId !== 'latest' && String(snapshotId).trim() === "") {
5182
+ res.status(400).json({ ok: false, error: "Invalid snapshot" })
5183
+ return
5184
+ }
5185
+ const found = await this.kernel.git.findSnapshotByRemote(remote, snapshotId)
5186
+ if (!found || !found.snapshot) {
5187
+ res.status(404).json({ ok: false, error: "Snapshot not found" })
5188
+ return
5189
+ }
5190
+ const { remoteKey, snapshot } = found
5191
+ let addedSnapshot = false
5192
+ let ok = false
5193
+ try {
5194
+ ok = await this.kernel.git.downloadMainFromSnapshot(folder, snapshot.id, remote)
5195
+ } catch (_) {
5196
+ ok = false
5197
+ }
5198
+ if (!ok) {
5199
+ await fs.promises.rm(targetPath, { recursive: true, force: true }).catch(() => {})
5200
+ res.json({ ok: false, error: "Failed to download snapshot" })
5201
+ return
5202
+ }
5203
+ if (this.kernel.git && this.kernel.git.activeSnapshot) {
5204
+ this.kernel.git.activeSnapshot[folder] = { id: snapshot.id, remoteKey }
5205
+ }
5206
+ res.json({ ok: true, redirect: `/p/${encodeURIComponent(folder)}` })
5207
+ }))
5208
+ this.app.get("/checkpoints/restore/:workspace/:snapshotId", ex(async (req, res) => {
5209
+ const workspace = typeof req.params.workspace === 'string' ? req.params.workspace : ''
5210
+ const snapshotId = req.params.snapshotId
5211
+ const ok = await this.kernel.git.downloadMainFromSnapshot(workspace, snapshotId)
5212
+ if (ok) {
5213
+ // Mark this workspace as being in "restore from snapshot" mode so that
5214
+ // as the install script clones additional repos, they can be pinned to
5215
+ // the commits recorded in checkpoints.json. Then send the user to the
5216
+ // normal workspace page so installation happens through the usual UI.
5217
+ if (this.kernel.git && this.kernel.git.activeSnapshot) {
5218
+ const found = await this.kernel.git.findSnapshotForFolder(workspace, snapshotId)
5219
+ const remoteKey = found && found.remoteKey ? found.remoteKey : null
5220
+ this.kernel.git.activeSnapshot[workspace] = { id: snapshotId, remoteKey }
5221
+ }
5222
+ res.redirect(`/p/${workspace}`)
5223
+ } else {
5224
+ res.redirect("/backups")
5225
+ }
5226
+ }))
5227
+ this.app.post("/checkpoints/restore/:workspace/:snapshotId", ex(async (req, res) => {
5228
+ const workspace = typeof req.params.workspace === 'string' ? req.params.workspace : ''
5229
+ const snapshotId = req.params.snapshotId
5230
+ const ok = await this.kernel.git.downloadMainFromSnapshot(workspace, snapshotId)
5231
+ let meta = null
5232
+ if (ok) {
5233
+ try {
5234
+ meta = await this.kernel.api.meta(workspace)
5235
+ } catch (_) {
5236
+ meta = null
5237
+ }
5238
+ }
5239
+ res.json({ ok: !!ok, meta })
5240
+ }))
4526
5241
  this.app.get("/agents", ex(async (req, res) => {
4527
5242
  let pluginMenu = []
4528
5243
  try {
@@ -7417,10 +8132,31 @@ class Server {
7417
8132
  }))
7418
8133
  this.app.get("/info/gitstatus/:name", ex(async (req, res) => {
7419
8134
  try {
7420
- const workspaceRoot = this.kernel.path("api", req.params.name)
8135
+ const workspaceName = req.params.name
8136
+ const workspaceRoot = this.kernel.path("api", workspaceName)
8137
+ const forceRefresh = req.query && (req.query.force === '1' || req.query.force === 'true')
8138
+
8139
+ if (forceRefresh && this.workspaceStatus && typeof this.workspaceStatus.markDirty === 'function') {
8140
+ this.workspaceStatus.markDirty(workspaceName)
8141
+ }
8142
+
8143
+ if (forceRefresh) {
8144
+ const data = await this.computeWorkspaceGitStatus(workspaceName)
8145
+ if (this.workspaceStatus && this.workspaceStatus.cache instanceof Map) {
8146
+ this.workspaceStatus.cache.set(workspaceName, {
8147
+ dirty: false,
8148
+ inflight: null,
8149
+ data,
8150
+ updatedAt: Date.now()
8151
+ })
8152
+ }
8153
+ res.json(data)
8154
+ return
8155
+ }
8156
+
7421
8157
  const data = await this.workspaceStatus.getStatus(
7422
- req.params.name,
7423
- () => this.computeWorkspaceGitStatus(req.params.name),
8158
+ workspaceName,
8159
+ () => this.computeWorkspaceGitStatus(workspaceName),
7424
8160
  workspaceRoot
7425
8161
  )
7426
8162
  res.json(data)
@@ -7824,7 +8560,11 @@ class Server {
7824
8560
  req.launcher_root = launcher.launcher_root
7825
8561
  config = await this.processMenu(name, config)
7826
8562
  } catch(e) {
7827
- config.menu = []
8563
+ if (config) {
8564
+ config.menu = []
8565
+ } else {
8566
+ config = { menu: [] }
8567
+ }
7828
8568
  err = e.stack
7829
8569
  }
7830
8570
  await this.renderMenu(req, uri, name, config, [])
@@ -8340,16 +9080,20 @@ class Server {
8340
9080
  })
8341
9081
  }))
8342
9082
  this.app.get("/p/:name/dev", ex(async (req, res) => {
8343
- await this.chrome(req, res, "browse")
9083
+ const { hasSnapshots, pendingSnapshotId } = await this.getSnapshotStatus(req.params.name)
9084
+ await this.chrome(req, res, "browse", { hasSnapshots, pendingSnapshotId })
8344
9085
  }))
8345
9086
  this.app.get("/p/:name/files", ex(async (req, res) => {
8346
- await this.chrome(req, res, "files")
9087
+ const { hasSnapshots, pendingSnapshotId } = await this.getSnapshotStatus(req.params.name)
9088
+ await this.chrome(req, res, "files", { hasSnapshots, pendingSnapshotId })
8347
9089
  }))
8348
9090
  this.app.get("/p/:name/browse", ex(async (req, res) => {
8349
- await this.chrome(req, res, "browse")
9091
+ const { hasSnapshots, pendingSnapshotId } = await this.getSnapshotStatus(req.params.name)
9092
+ await this.chrome(req, res, "browse", { hasSnapshots, pendingSnapshotId })
8350
9093
  }))
8351
9094
  this.app.get("/p/:name", ex(async (req, res) => {
8352
- await this.chrome(req, res, "run")
9095
+ const { hasSnapshots, pendingSnapshotId } = await this.getSnapshotStatus(req.params.name)
9096
+ await this.chrome(req, res, "run", { hasSnapshots, pendingSnapshotId })
8353
9097
  }))
8354
9098
  this.app.post("/pinokio/delete", ex(async (req, res) => {
8355
9099
  try {
@@ -8487,6 +9231,55 @@ class Server {
8487
9231
  let queryStr = new URLSearchParams(req.query).toString()
8488
9232
  res.redirect("/home?mode=download&" + queryStr)
8489
9233
  }))
9234
+ this.app.post("/pinokio/install/exists", ex(async (req, res) => {
9235
+ if (!this.kernel || !this.kernel.homedir) {
9236
+ return res.status(500).json({ error: "Pinokio home directory not set" })
9237
+ }
9238
+
9239
+ const body = req.body && typeof req.body === "object" ? req.body : {}
9240
+ const folderName = typeof body.folderName === "string" ? body.folderName.trim() : ""
9241
+
9242
+ if (!folderName) {
9243
+ return res.status(400).json({ error: "folderName is required" })
9244
+ }
9245
+
9246
+ if (folderName === "." || folderName === ".." || /[\\/]/.test(folderName) || folderName.includes("\0")) {
9247
+ return res.status(400).json({ error: "Invalid folderName" })
9248
+ }
9249
+
9250
+ let sanitizedPath = null
9251
+ if (typeof body.relativePath === "string") {
9252
+ let trimmed = body.relativePath.trim()
9253
+ if (trimmed) {
9254
+ trimmed = trimmed.replace(/^~[\\/]?/, "").replace(/^[\\/]+/, "")
9255
+ if (trimmed) {
9256
+ const segments = trimmed.split(/[\\/]+/).filter(Boolean)
9257
+ if (segments.length > 0 && !segments.some((segment) => segment === "." || segment === "..")) {
9258
+ sanitizedPath = segments.join("/")
9259
+ }
9260
+ }
9261
+ }
9262
+ }
9263
+
9264
+ if (!sanitizedPath) {
9265
+ sanitizedPath = "api"
9266
+ }
9267
+
9268
+ const home = path.resolve(this.kernel.homedir)
9269
+ const target = path.resolve(home, sanitizedPath, folderName)
9270
+ const homeWithSep = home.endsWith(path.sep) ? home : home + path.sep
9271
+
9272
+ if (!target.startsWith(homeWithSep)) {
9273
+ return res.status(400).json({ error: "Invalid install destination" })
9274
+ }
9275
+
9276
+ const exists = await fs.promises
9277
+ .access(target, fs.constants.F_OK)
9278
+ .then(() => true)
9279
+ .catch(() => false)
9280
+
9281
+ res.json({ exists })
9282
+ }))
8490
9283
  this.app.post("/pinokio/install", ex((req, res) => {
8491
9284
  req.session.requirements = req.body.requirements
8492
9285
  req.session.callback = req.body.callback
@@ -8594,6 +9387,32 @@ class Server {
8594
9387
  }
8595
9388
 
8596
9389
  }))
9390
+ this.app.post("/create-upload", this.upload.any(), ex(async (req, res) => {
9391
+ try {
9392
+ const files = Array.isArray(req.files) ? req.files : [];
9393
+ if (!files.length) {
9394
+ return res.status(400).json({ error: "No files provided" });
9395
+ }
9396
+ const token = crypto.randomBytes(16).toString("hex");
9397
+ const baseDir = this.kernel.path("tmp", "create", token);
9398
+ await fs.promises.mkdir(baseDir, { recursive: true });
9399
+ const stored = [];
9400
+ for (const file of files) {
9401
+ if (!file || !file.buffer) continue;
9402
+ const filename = path.basename(file.originalname || "file");
9403
+ const target = path.resolve(baseDir, filename);
9404
+ await fs.promises.writeFile(target, file.buffer);
9405
+ stored.push({ name: filename, size: file.size });
9406
+ }
9407
+ if (!stored.length) {
9408
+ return res.status(400).json({ error: "No valid files provided" });
9409
+ }
9410
+ res.json({ uploadToken: token, files: stored });
9411
+ } catch (error) {
9412
+ console.log("create-upload error", error);
9413
+ res.status(500).json({ error: error.message || "Upload failed" });
9414
+ }
9415
+ }))
8597
9416
  /*
8598
9417
  SYNTAX
8599
9418
  fs.uri(<bin|api>, path)